Compare commits

...
Author SHA1 Message Date
Hornwitser bcb2365c9a 2.0.0-alpha.13 2022-07-20 13:30:35 +02:00
Hornwitser 259d18eb30 Fix Web UI not displaying date for last seen 2022-07-20 13:30:35 +02:00
Hornwitser 04852196ad Rename play time to online time
Be a bit more accurate in the online time stat name and call it for what
it is.  It counts time spend online on the server, not necessarily
time spent playing.
2022-07-20 13:18:47 +02:00
Hornwitser fc1daa7e71 Bump path traversal test timeout (#472)
Double timeout on these two tests as they sometimes time out on GitHub
Actions.
2022-07-19 21:24:44 +02:00
Hornwitser e234d3db36 Fix disconnect_reason missing before Factorio 1.1.3 (#471)
Provide "quit" as the reason for disconnecting in versions of Factorio
before 1.1.3 which added reason to the on_player_left_game event.
2022-07-19 21:24:26 +02:00
Hornwitser 7b8127fef8 Refactor inventory_sync serialisation (#470)
Fix broken support for retrying uploads and background downloads by
making crafting queue serialisation non-destructive and only performing
the serialisation and deserialisation while the player is online.

This removes the /save-crafts and /restore-crafts commands because the
existing implementation would voids the non-saved crafting queue items
if /save-crafts is performed and then new items are added to the
crafting queue before switching server.

Fixes #419.
2022-07-19 21:24:12 +02:00
Hornwitser db84b5a6ce Shut up Dependabot (#469)
If only the bot was clever enough to see that node-forge is only used
for a test that has no user input it might have had the foresight to
realize that crying wolf about completely irrelevant security issues
will only lead to legitimate issues being ignored in the future. But
alas, the AI overlords are not taking over any time soon.
2022-07-19 21:23:33 +02:00
Hornwitser c79ced28dd Merge pull request #468 from Hornwitser/installer-fixes
Installer fixes
2022-07-19 21:23:09 +02:00
Hornwitser 2408dc8985 Remove obsolete Factorio download instructions
On Linux the installer offers to correctly download and install the
latest version of Factorio.  Remove no longer needed instructions for
how to do it incorrectly.
2022-07-18 20:10:08 +02:00
Hornwitser 2953eb17d6 Refuse installing Clusterio as root
I have seen multiple people have installed Clusterio as the root account
and then run it as root when tasked with setting up Clusterio on Linux.
Make this mistake harder to do by refusing to install as root by
default.
2022-07-18 20:05:37 +02:00
Hornwitser c9619397b6 Use current user's group for systemd files
Generate systemd files using the current user's main group name instead
of nogroup as the latter is not present in all systems.
2022-07-18 19:39:01 +02:00
Daniel c8a6b8094e Merge pull request #452 from Danielv123/slave-guided-setup
Added setup guide for slaves from web interface
2022-07-16 16:51:41 +02:00
danielv 7ee9c60e38 Add syntax highlighting to codeblock 2022-07-16 15:59:57 +02:00
danielv 4b0a33174d Added setup guide for slaves from web interface 2022-07-16 15:59:27 +02:00
Hornwitser db27650b60 Merge pull request #467 from Hornwitser/user-ui-update
User UI update
2022-07-16 15:11:21 +02:00
Hornwitser aadf24db99 Add test for sending user ban_reason 2022-07-16 14:30:06 +02:00
Hornwitser 2496e756f1 Fix display of stats in Web UI when empty 2022-07-15 16:14:04 +02:00
Hornwitser 5ba9f94b7e Only show admin/whitelist switches if usable
Since the admin and whitelist status is shown with a tag next to the
name it's no longer necessary to show the admin and whitelisted switches
when the user does not have permission to use them.
2022-07-15 16:14:04 +02:00
Hornwitser 9fa41e37f3 Show ban reason in Web UI
Show and update live the reason given for someone being banned in the
Web UI.
2022-07-15 15:57:45 +02:00
Hornwitser d184b9db81 Merge pull request #466 from Hornwitser/player-tracking
Per player statistics
2022-07-13 22:49:33 +02:00
Hornwitser 08e7434d24 Add command to extract play time from save
Add command that extracts play time (and players) from the currently
running save to the recorded instance statistics.  This is useful for
getting correct play time statistics when an existing save game is
imported.
2022-07-13 00:31:02 +02:00
Hornwitser 0245ccd16c Share player stats with master server
Send the per player stats with the player event updates to the master
server and expose it to the Web UI and ctl interfaces.
2022-07-12 23:03:50 +02:00
Hornwitser deb6842a4d Record all players as leaving on server exit
In the case of shutdown the Factorio server will not notify the
currently online players as leaving the game.  Notify the master server
that all remaining online players quit the game in this case.
2022-07-12 23:03:50 +02:00
Hornwitser ac62f66aa1 Move PlayerStats to separate data class 2022-07-12 23:03:50 +02:00
Hornwitser 097827b515 Update linter for ES2022 syntax
Allow modern JavaScript syntax including class property declarations
since Node.js v12 supports this.
2022-07-12 17:29:22 +02:00
Hornwitser 9a3edc8c11 Fix web build broken by assert and buffer imports
Not sure what made it break, but it seems like it shouldn't have worked
to begin with.
2022-07-08 01:00:54 +02:00
Hornwitser 096f338277 Add live updating of users
Provied a subscription for user updates and send updates whenever the
details of a user account is changed.
2022-07-08 01:00:54 +02:00
Hornwitser db562ce6a0 Provide reason for player leaving
Expose the reason for why a player is leaving a server in playerEvent.
2022-07-08 01:00:54 +02:00
Hornwitser 8017497bdd Merge pull request #464 from Hornwitser/player-time-autosave
Player online autosave pool
2022-06-30 14:33:19 +02:00
Hornwitser 3cb9843f23 Add player online autosave pool
Add separate autosave pool for autosaves that have had players online
since the previous autosave.
2022-06-30 01:03:29 +02:00
Hornwitser a063cf2db5 Track player stats on instances
Track which players are online and record basic stats for each player on
each instance including when save patching is disabled.
2022-06-29 21:35:56 +02:00
Hornwitser 72357dfd23 Fix wrong action in load scenario error message 2022-06-29 21:25:25 +02:00
Hornwitser 0df6701326 Use consistent path for saves in Instance 2022-06-29 21:25:25 +02:00
Hornwitser f5e558cb2f Sneaky tab removal 2022-06-29 21:25:25 +02:00
Hornwitser 995f0934ed Merge pull request #463 from Hornwitser/player-time-autosave-bugfixes
Player time autosave bugfixes
2022-06-24 13:08:35 +02:00
Hornwitser ef74436cde Fix jsdoc for #462
Fix references and missing files from the jsdoc output after the
refactor in #462.
2022-06-23 16:57:52 +02:00
Hornwitser 51c025c79b Fix Log out button not logging out 2022-06-23 16:57:52 +02:00
Hornwitser 12767fd58c Merge pull request #462 from Hornwitser/minor-refactor
Minor refactor
2022-06-23 16:40:14 +02:00
Hornwitser 826d89c7dc Fix race condition on creating instance directory 2022-06-23 00:09:28 +02:00
Hornwitser 393652c1d9 Allow instance names to be any string
Replace bad characters with underscore instead of rejecting bad instance
names.
2022-06-23 00:08:23 +02:00
Hornwitser 246ce73d94 Break commands out of ctl.js 2022-06-22 22:33:54 +02:00
Hornwitser 951bad4134 Refactor slave code
Move classes from slave.js monolit into their own source files.
2022-06-22 22:16:58 +02:00
Hornwitser ae17b00b6f Hide start button based on permissions
Hide the start/stop buttin in instance view if the current used does not
have permission to use it.
2022-06-22 21:26:41 +02:00
Hornwitser 36808b34de Merge pull request #461 from Hornwitser/small-things-8
Small things 8
2022-06-20 13:26:14 +02:00
Hornwitser 105f99ced3 Preserve entered vehicle on inventory sync
Save vehicle the player is driving before downloading the inventory and
restore the player position to this vehicle if possible.
2022-06-19 19:51:22 +02:00
Hornwitser 430a64722e Use empty slots as end for logistics requests
Count up to 100 empty slots past the last seen slot with data in it
before stopping to look for personal logistics slots in inventory_sync.
Allows unlimeted amount of logistics request slots to be used for
heavily modded games.
2022-06-19 17:03:08 +02:00
Hornwitser 7e9604a254 Fix LogConsole scroll anchor trasmutation
Fix react mutating the scroll anchor element in LogConsole into a log
line and breaking scroll anchoring to the bottom as a result.
2022-06-19 16:38:02 +02:00
Hornwitser 5369f283d7 Merge pull request #460 from Hornwitser/cache-web
Cache static web files
2022-06-19 15:34:19 +02:00
Hornwitser 654465e4eb Fix LogConsole using duplicated keys
Fix past lines loaded from the log containing keys that clash with new
items logged.
2022-06-09 00:53:12 +02:00
Hornwitser c5a0c0a873 Migrate to new <Menu> syntax
The use of <Menu.Item> children to <Menu> is deprecated, convert
existing code to use the new items={...} syntax.
2022-06-09 00:52:27 +02:00
Hornwitser 16a466aed0 Merge pull request #458 from Hornwitser/replace-npm
Replace npm and lerna with pnm
2022-06-09 00:11:24 +02:00
Hornwitser 3e836c1dd3 Fix web interface breaking when disabling plugins
Fix incorrect logic to set up plugins in the web interface causing an
emptry plugin to be atempted to be loaded when a plugin was disabled.
2022-06-08 23:26:33 +02:00
Hornwitser ddf15f9b92 Add option to turn on authentication server bans
Factorio 1.1.40 added a feature to use a centralized banlist maintained
by Wube.  For the dedicated server this needs to be turned on with the
command line flag --use-authserver-bans.
2022-06-08 22:43:58 +02:00
Hornwitser 54897c1447 Fix deserializing to a smaller inventory
Void excess slots when deserializing an inventory to a target that has
fewer slots than what was serialized.  This fixs inventory sync failing
when a stored inventory has trash slots in use but the player does not
have the slots unlocked.
2022-06-08 22:26:08 +02:00
Hornwitser 2a221456d9 Fix exported data loading in web interface
Store exported data files with a hash in the filename to allow the new
caching stategy for static files to work with it and provide a manifest
for the web interface to find the exported files with.
2022-06-08 21:01:36 +02:00
Hornwitser 48a57bf16c Use name of the cluster as web title
Set the title attribute of the web interface page to the name of the
cluster.
2022-06-08 21:01:36 +02:00
Hornwitser 8742b4127c Improve pre-loaded feedback of website
Render the web interface as early as possible with a black background
and show a message to indicate whether the interface is actually loading
or if an error occured loading the main bundle.
2022-06-08 21:01:36 +02:00
Hornwitser e32637503d Move all web assets to shared cached space
Put all web assets including plugin assets under a shared static/ route
in the web interface that's declared as immutable and cacheable for 1
year.  This allows all JavaScript bundles to be loaded from disk instead
of having to check with the web server each time and significantly
speeds up the web interface load time.  It also fixes the public path
issue when using the --dev-plugin option behind a proxy.

To prevent collisions and stale data all assets now include a hash in
the filename.  This means the entrypoint is now dynamic and specified in
a manifest.json file created from the web build.  For plugins this means
the filename attribute needs to be removed from webpack.config.js.
2022-06-08 21:01:36 +02:00
Hornwitser 2401fe25fa Remove test package
Mocha's recursive search feature finds all files and then removes paths
that match the exclution filter, except that the globstar filter doesn't
match directories with a dot in them which is created by pnpm.  Avoid
this madness by removing the test package.
2022-06-08 16:48:40 +02:00
Hornwitser f0b60119cb Add pnpm to GitHub Action
Based on https://pnpm.io/continuous-integration#github-actions
2022-06-05 17:00:21 +02:00
Hornwitser 4d5597a1da Use prepare instead of build as script
The prepare script runs before publishing a package, after installation
of a package from source, and when running npm install without
arguments in the repository.  These are the times where a build of the
web interface files are required to make the web interface work and
removes a manual step from using the package(s). The "build" script on
the other hand is never automatically invoked by npm.
2022-06-05 16:13:53 +02:00
Hornwitser 2973485321 Replace npm and lerna with pnpm
Save disk space, time and sanity by replacing npm and lerna with pnpm.
This is more performant and doesn't have developer experience ruining
bugs like npm link removing all previous links before creating the
requested one.
2022-06-05 16:13:53 +02:00
Hornwitser 1e4292876c v2.0.0-alpha.12 2022-04-22 12:36:00 +02:00
Hornwitser ca6464baeb Merge pull request #456 from Hornwitser/save-transfer
Save transfer
2022-03-30 22:51:04 +02:00
Hornwitser c2cb58d088 Comment out command logging in tests
Don't log commands executed while running tests as these spam the output
more than they are useful right now.
2022-03-29 22:55:13 +02:00
Hornwitser 38c249ec70 Implement save transfer
Add ability to move or copy saves between instances.  For instances on
the same slave the saves are transferred with filesystem operations, for
instances on two different slaves the proxy stream interface is used to
send the save via the master server.
2022-03-28 20:42:08 +02:00
Hornwitser 2e1c1fd3c8 Remove duplicated instance check
Deduplicate instance existence check for requests in master.
2022-03-28 20:42:08 +02:00
Hornwitser 4edc7a2949 Implement rename and copy save
Add ability to rename and copy existing saves on an instance.
2022-03-27 19:59:29 +02:00
Hornwitser 9b5c6f8554 Remove duplicated request checks
Move duplicated checking of save filenames and instance existing from
requests into separate functions.
2022-03-27 18:50:15 +02:00
Hornwitser 0de964ec02 Reorganise instance save commands
Move create, list, upload and dalete save management commands to its own
'instance save' namespace in the command line interface.
2022-03-27 15:50:42 +02:00
Hornwitser 7db091d427 Simplify wording of save permissions
Use shorter and more consise titles for the save management related
permissions.
2022-03-27 15:39:40 +02:00
Hornwitser 9cf7dea579 Add mods for subspace_storage in GitHub Actions (#455)
The GitHub Actions workflow was missing the mods needed to do the tests
for subspace_storage, causing coverage to be lower than it should be.
2022-03-27 13:16:12 +02:00
Hornwitser 9465a488cc Migrate coverage reporting to GitHub Actions (#454)
Travis seems to be going away from supporting Open Source Software for
free and their proceedure to gaining free tokens is getting harder and
hander to find for each year that passes.  Remove Travis CI integration
and migrate the coverage reporting previously provided by the Travis
build over to the GitHub Actions.
2022-03-26 21:23:19 +01:00
Hornwitser 5e94198216 Merge pull request #453 from Hornwitser/small-things-7
Small things 7
2022-03-26 21:22:35 +01:00
Hornwitser 854d2b3c79 Suggested grammar fix 2022-03-26 20:27:59 +01:00
Hornwitser 946847c95d Send save list update after manual save
Trigger a save list update in the event an admin manually saves the game
with the /server-save command.
2022-03-25 23:15:07 +01:00
Hornwitser ce95f868fd Fix curently typo 2022-03-25 23:15:07 +01:00
Hornwitser e5a63a3508 Add start-stop button to instance list 2022-03-25 23:15:07 +01:00
Hornwitser 1e4d709a06 Implement kill instance
Add ability to kill running instances in case it is desirable to stop
the server without saving over the current save file.
2022-03-25 20:52:31 +01:00
Hornwitser a93f048814 Add support for sidebar groups
Allow sidebar entries to be grouped together by specifiying a name under
which they will be grouped.  To avoid confusion groups are rendered at
the bottom of the list.
2022-02-10 14:51:51 +01:00
Hornwitser 5319ff3907 Add hook to add extra content to Web UI pages
Allow plugins to extend Web UI pages provided by Clusterio by
adding components in the componentExtra property of the plugin.
2022-02-10 14:51:51 +01:00
Hornwitser 68b8ac6b8c Add onMasterConfigFieldChanged hook
Add hook for master plugin code to react to the master config changing.
2022-02-10 10:29:47 +01:00
Arvids Godjuks a979c0cfac Merge pull request #446 from jake-87/ctl-docs
Added more documentation for managing-a-cluster.md
2022-01-19 13:39:45 +02:00
J87 3da8c30a64 Remove unnecessary information 2022-01-11 19:55:18 +11:00
J87 f8c72a1e0a Fixed wording issue 2022-01-10 12:37:02 +11:00
Hornwitser 4a059efe18 Merge pull request #447 from Hornwitser/lgtm-alerts
Fix issues detected by lgtm.com
2022-01-09 19:06:38 +01:00
Hornwitser dec5467538 Use anonymous master metrics
Convert the master metrics to void instead of storing them in a variable
as they are completely self contained, and this makes it more clear that
there are no use of references to the metrics.
2022-01-05 19:45:24 +01:00
Hornwitser 61cd5bd76c Fix issues detected by lgtm.com 2022-01-03 19:45:46 +01:00
jake-87 1b1524e787 Fix poor wording 2022-01-02 19:23:54 +11:00
jake-87 488d81f424 Updated wording in docs
Also added more information on the banned-bulk-import option.
2022-01-02 19:16:23 +11:00
Hornwitser 1ff1e3e5f8 v2.0.0-alpha.11 2021-12-19 15:57:08 +01:00
Hornwitser decee0ecc2 Merge pull request #445 from Hornwitser/log-fix
Fix logs
2021-12-19 15:14:52 +01:00
Hornwitser 170a3d3cf7 Use stream.finished for detecting end of streams
There are some corner cases with streams that cause them to not emit
finish close or end events which in turn means that code liseting for
these events directly may end up hanging forever.  Use stream.finished
to detect end of streams which will handle these cases correctly.
2021-12-19 03:21:00 +01:00
Hornwitser 601ebba919 Replace log query implementation
The interface provided by Winston is unusably slow for querying large
logs.  Reimplement the log querying with both forwards and backwards
line reading and a simple file based index to skip files which do not
contain any lines matching the filter.
2021-12-19 02:06:59 +01:00
Hornwitser fd1f3329bd Split logs by date
Move the default location of the log files into a configurable logs
folder and split the written logs up by UTC date to more easily allow
deleting of old logs to be performed.  The log rotation is implemented
by winston-daily-rotate-file.

The old log format will automatically be migrated to the new format and
location if the target folder for the new logs don't yet exists.
2021-12-19 01:42:34 +01:00
Hornwitser fd72cd4fd5 Fix serialization of burner equipment (#443)
The burner equipment serialization code was never tested and
consequently never worked.  Wasn't noticed before now because burner
equipment isn't in the vanilla game and few mods use it.

Fixes #442.
2021-12-15 16:22:19 +01:00
Hornwitser 200ccf8e06 Fix LogConsole re-requesting logs
Turns out objects in the second argument to useEffect is compared by
Object.is(), this means if the array containing the instances or slaves
to show logs for get re-created during an update the LogConsole
component would send a new request for the logs.

Fix by converting the slave_ids and instance_ids arrays into strings
before passing them on to useEffect.
2021-12-15 00:19:47 +01:00
Hornwitser 285d71e13d Add ReverseLineSplitter
Add splitter based on LineSplitter which splits lines in reverse from a
reverse stream created from the added createReverseReadStream function.
Enables line processing of files starting from the last line and going
towards the first.
2021-12-14 18:44:19 +01:00
Hornwitser 8de76b5cfe Refactor LineSplitter
Pull the LineSplitter class out of lib/factorio to its own stream module
and refactor it to be based on stream.Transform.  This allows composing
with other streams by piping it around.
2021-12-11 21:31:15 +01:00
jake-87 8ecc6d1c83 Added instance and user documentation 2021-12-10 13:01:03 +11:00
Hornwitser 6da7b84757 v2.0.0-alpha.10.c hotfix for installer 2021-12-09 00:14:19 +01:00
Arvids Godjuks 072e95046a Merge pull request #436 from jake-87/patch-1
Wrong capitalisation on `process.env`
2021-12-09 01:04:43 +02:00
Hornwitser 5aa4d99ea3 Fix installer ignoring --plugins argument (#440)
Fix issue passing --plugins to installer which was discovered in #438.
2021-12-09 00:03:45 +01:00
Hornwitser f1ac22881c Use strict parsing in installer (#439)
Bail out if arguments that aren't understood are passed to the
installer script instead of silently ignoring them.
2021-12-08 23:59:40 +01:00
J87 fb85869d09 Fix spelling mistake 2021-12-07 22:13:28 +11:00
J87 c69672ec60 Variable misspelling 2021-12-07 21:42:58 +11:00
J87 3e1a41393a Fix incorrrent spelling of variable
Honestly not sure how this one slipped through
2021-12-07 21:42:10 +11:00
J87 e9225a4b2c Wrong capitalisation on process.env 2021-12-07 21:38:46 +11:00
Arvids Godjuks df80effbdf Merge pull request #434 from Danielv123/small-changes-for-plugins
Small changes for plugins
2021-11-25 01:37:28 +02:00
Arvids Godjuks 35a60e798e Merge pull request #431 from jake-87/user-editable-config
Implementing user editable config for CLI editors
2021-11-25 01:35:14 +02:00
J87 dfd0cb2d36 Fix linter issue 2021-11-25 07:45:56 +11:00
danielv da941a299c Start all and stop all instance buttons 2021-11-24 21:33:55 +01:00
J87 81e3c9e19b Removed unnecessary double negation 2021-11-24 14:31:42 +11:00
J87 b9e8ffa3d1 Fix linting error 2021-11-24 09:33:08 +11:00
J87 62efc63830 Fixed variable definition 2021-11-23 22:03:36 +11:00
J87 e6e5805a49 Refactored serializedConfigToString into function
I'm unable to test right now, so this is currently not yet tested. Will test asap.
2021-11-23 13:12:49 +11:00
Danielv123 a3d98595e2 Fix linter errors 2021-11-22 13:28:29 +01:00
Danielv123 6af34dc3a6 Update BaseConfigTree when props.id changes 2021-11-22 13:17:39 +01:00
Danielv123 1cf3e39d72 Avoid savesProvider throwing errors on missing instanceId 2021-11-22 12:44:48 +01:00
Danielv123 b192c82dcc Export induvidual components from the web interface for plugin use 2021-11-22 10:20:44 +01:00
Danielv123 9138efca6f Clear log console when changing props 2021-11-22 10:20:06 +01:00
Danielv123 52238de9b3 Expose react-router-dom to allow plugins to use useHistory()
See https://githubmemory.com/repo/module-federation/module-federation-examples/issues/716
2021-11-22 10:19:43 +01:00
J87 cc064c2fd1 Fix mistake with Instance descriptions
Also added warning for description not found in master.
2021-11-22 09:36:20 +11:00
J87 baf7dbfe0d Fixed linter issue 2021-11-21 20:26:17 +11:00
J87 0bc6cf1178 Minified getEditor function. 2021-11-21 20:25:33 +11:00
Daniel 8638632600 Merge pull request #425 from Danielv123/fix-invokeHook
Add timeout to invokeHook on plugins
2021-11-21 10:03:30 +01:00
jake87 6d5d9ced47 Refactored editor selection to function 2021-11-21 13:39:23 +11:00
jake87 e825f643e2 Refactored string processing to function
Previously string processing was done the exact same way for both master
and instance; this has now been refactored into a function.
2021-11-21 13:31:12 +11:00
jake87 c4f656996e Fixed linting issues 2021-11-21 12:24:00 +11:00
J87 103c0e3820 Fix missing brace from conflict merge 2021-11-21 12:15:46 +11:00
J87 6da349c888 Fix commented out catch bracket 2021-11-21 12:14:49 +11:00
J87 15c3bb81a6 Fix "emitter" spelling & rename to be more descriptive 2021-11-21 12:12:54 +11:00
J87 4a628df108 Remove unneeded backslash 2021-11-21 12:11:18 +11:00
J87 25ae9b9a0d Add example to "no editor" error 2021-11-21 12:10:24 +11:00
J87 6b10f2ab7d Resolve function style 2021-11-21 12:07:30 +11:00
danielv 770e6a41d4 Add timeout to invokeHook on plugins 2021-11-20 12:44:46 +01:00
J87 375f591fe5 Remove outdated comments 2021-11-20 22:28:21 +11:00
Daniel fcdbf59f7a Merge pull request #433 from Danielv123/fix-node12-support
Support node12 by removing optional chaining
2021-11-20 12:17:34 +01:00
Danielv123 6c80227103 Support node12 by removing optional chaining 2021-11-20 12:03:46 +01:00
Daniel 0016c6271a Merge pull request #427 from Danielv123/gh-actions
Gh actions
2021-11-20 11:50:24 +01:00
Daniel c557b7d6b2 Merge pull request #428 from Hornwitser/fix-localhost-resolve
Fix error when localhost resolves to ::1
2021-11-20 11:49:56 +01:00
J87 64c328c53d Merge branch 'master' into user-editable-config 2021-11-20 16:37:49 +11:00
J87 e5994287fb Remove outdated comments 2021-11-20 16:33:47 +11:00
jake87 7126014619 Added editing for instances
Instances require a number of modifications leading to it not being
able to be converted into a function. The main one is that there is a
list of config elements that cannot be set via the edit command and
either have to be set via different means or cannot be set. To stop
these fields constantly sending error messages, we disregard adding them
to the file in the first place.
2021-11-20 16:23:21 +11:00
Arvids Godjuks 3b8e89ea00 Merge pull request #424 from bbassie/slave-instance-ui-updates
Slave instance UI updates
2021-11-19 21:48:52 +02:00
Danielv123 e72ee3ecaf Added linter to CI action 2021-11-19 17:58:54 +01:00
jake87 31cfc45e58 Fix null processing ( again ) 2021-11-17 21:16:22 +11:00
jake87 834fc8cf98 Fixed null value parsing 2021-11-17 18:16:24 +11:00
jake87 eb3b7df390 Fix to be more consistant with the rest of clustorio
Also made sure "null" is entered as empty as to be consistant with the
rest of clustorio.
2021-11-17 17:45:54 +11:00
jake87 76882ad1c4 Fix styling to be more consistant with the rest of clustorio 2021-11-17 17:42:39 +11:00
bbassie 15381ac260 Final running status color update 2021-11-16 23:50:13 +01:00
bbassie b7e6acb24e Added instance deleted alert and updated not found 2021-11-16 23:44:07 +01:00
bbassie b86305db8c Updated colors of the instance tag 2021-11-16 23:42:02 +01:00
bbassie fe803a99ca Instance status tag color changes 2021-11-16 21:50:21 +01:00
jake87 4612826664 spelling mistake 2021-11-16 21:43:59 +11:00
Hornwitser a5a99e2ab1 Fix error when localhost resolves to ::1
Some systems may resolve localhost to ::1 while Factorio doesn't respond
to RCON connection over IPv6 loopback, this causes instances startup to
fail.  Fix by using 127.0.0.1 instead of localhost.
2021-11-15 21:29:51 +01:00
Danielv123 56584a25c8 Remove old pipeline 2021-11-15 10:26:32 +01:00
Danielv123 03ba7cb68c Added new testing pipeline 2021-11-15 10:20:18 +01:00
jake87 cc605250db removed final debugging helpers 2021-11-14 20:55:16 +11:00
jake87 ae34a86cbf Removed debugging prints, and added removing of tmpFile 2021-11-14 20:54:27 +11:00
jake87 86de5ae151 Fixed config editing, fixed child spawning, fixed config setting
Using an event emmiter, the fields can now be set correctly in the
master config. Null values are now handled correctly, and numbers are also set
correctly.
2021-11-14 20:51:06 +11:00
jake87 bcc25047f2 Readded config file editing, working on config file value setting 2021-11-13 22:35:15 +11:00
bbassie f5ce0a8a8c Missing import 2021-11-07 22:00:27 +01:00
bbassie 55bbc10986 Added slave status indicator tag to slave details page
Co-authored-by: Danielv123 <danielv@live.no>
2021-11-07 21:54:10 +01:00
bbassie 4fef2d8143 Fix message null issues 2021-11-07 21:35:48 +01:00
bbassie 4432a689bb Fix instance list 2021-11-07 21:31:56 +01:00
jake87 6013fcd528 Started rework of file loading system 2021-11-06 17:04:34 +11:00
jake87 71fb8541d5 Added more comments, added quotes around strings 2021-11-06 14:32:13 +11:00
jake87 4398d33fea Fixed definitions to be consistant with bullet points. 2021-11-05 23:32:18 +11:00
jake87 92814aa9b3 Reworked to use config list's grabber system
Also now using the system described by Hornwitser, in using
libConfig.MasterConfig.groups to get definitions. Now all that's needed
is to re-read the updated data back into clustorio.
2021-11-05 23:24:13 +11:00
bbassie 69d4a883ad Add copy public address button 2021-11-04 22:25:53 +01:00
bbassie 2e5617c4b5 Fix instance port and status update
Master was not recieving instance status after restart. Instead only a status from the slave but the slave doesn't store the full instance status.
2021-11-04 22:25:36 +01:00
bbassie f930ed36a0 Changed to using the real factorio port 2021-11-04 21:29:53 +01:00
bbassie 83b720eade Added public_address field to lists
- InstanceList
- SlavesPage
Added public_address field to messages
- slaveProperties
- instanceProperties
2021-11-04 01:39:28 +01:00
bbassie c3a1b420ea Added InstanceStatusTag react component
- Modified InstanceList & InstanceViewPage to use it
2021-11-04 01:36:56 +01:00
Hornwitser c889d8f325 Ignore SessionLost from updateInstances (#422)
Don't shutdown the slave if the connection session is lost during
updateInstances.  Resolves #418.
2021-11-03 16:27:21 +01:00
Hornwitser e23d07e965 Use safe file writing pattern (#421)
Write new file contents to a temporary file and then rename over the
target file to avoid data loss in the unlikely event there's an error
writing the new file contents or the process is interrupted.

Resolves #416.
2021-11-03 16:14:18 +01:00
jake87 08a8182173 Reworked to use throw error, added system for getting config groups
Now using `throw error` to exit out, instead of exit(1). Reworked config
get system to use the map of configs in libConfig.MasterConfig.groups .
2021-11-01 20:40:44 +11:00
Hornwitser 9e7a7a602f Merge pull request #420 from Hornwitser/slave-page
Slave page
2021-10-31 16:53:34 +01:00
Hornwitser 6e5e5f0aeb Show log console for master and slaves in web UI
Rename InstanceConsole to LogConsole and use it to show logs for the
master server and slaves as well.
2021-10-31 10:41:25 +01:00
Hornwitser 7769c1f89e Add SlaveViewPage
Add slave overview page displaying instances assigned and some basic
properties.
2021-10-31 10:41:25 +01:00
Hornwitser a2a5cf6d4c Warn on instances with duplicated ids
It's possible that someone copies an instance folder and don't change
the instance id in it.  Emit a warning in case this happens.
2021-10-31 07:26:56 +01:00
jake87 77e9b929dc Testing why config not being returned correctly 2021-10-30 12:31:20 +11:00
jake87 b6306031dc Reworked some of editor logic to work with async functions
Now async functions can be used after the editor returns, as before they
couldnt be used due to .on being non-async.
2021-10-29 22:21:46 +11:00
jake87 0564eee400 Experementing with the master config
no important work, commit for my own sake
2021-10-29 22:09:01 +11:00
jake87 f548ac2ecc Added more logic into ctl.js
Now prints out a basic approximation of the final look of the config
file; with comments on the function of each part.
2021-10-29 20:42:32 +11:00
jake87 c9fd99589b Added more selection options for the edit
ctl master config edit [editor]
is the new structure; it can take an optional editor, and will prio this
editor over any other editor, such as one set by env vars. Generally,
the prio goes like this: argv supplied editor > EDITOR env var > VISUAL
env var. Things needing to be done are marked with a TODO.
2021-10-27 20:16:37 +11:00
jake87 23d8a819af Added basic framework for edit command
Implimented framework for edit command, including ability to get a temp
file, open an editor on the file, then resume code execution once the
editor exits.
2021-10-26 21:23:23 +11:00
jake87 14b3331c8e Added basic logic for config edit command
Added groundwork for the ctl master config edit command,
including temporary file grabbing and JSON config fetching.
2021-10-26 20:46:24 +11:00
jake87 f266c441db Fixed async logic within lib/file_ops.js 2021-10-26 20:46:24 +11:00
jake87 ed73e3ad1e Fixed variable naming issue with file_ops.getTempFile 2021-10-26 20:46:05 +11:00
jake87 4f9a8fb45c Rewrote temp function to be safer, using findUnusedName 2021-10-26 17:50:39 +11:00
jake87 6910737374 Added function in lib/file_ops.js to return a temp filename with salt 2021-10-25 21:58:56 +11:00
Hornwitser bdfe8e630e v2.0.0-alpha.10.b hotfix for installer 2021-10-05 20:19:58 +02:00
Hornwitser 66719375af Fix installer not setting address and ctl config (#417)
Somehow the step of automatically obtaining the right address to apply
to slave.public_address, and asking for the master url and token for ctl
installations was implemented but the code to actually set it in the
relevant config was forgotten.
2021-10-05 20:05:44 +02:00
Hornwitser ef3d5209eb v2.0.0-alpha.10 2021-10-01 17:59:16 +02:00
Hornwitser e4eac3f9ad Merge pull request #415 from Hornwitser/small-things-6
Small things 6
2021-10-01 17:55:07 +02:00
Hornwitser 107284e796 Fix &gt; and &lt; being present in docs
< and > must apparently not be escaped inside backticks.
2021-10-01 01:52:18 +02:00
Hornwitser bad86a5ff2 Fix inventory_sync uploading editor mode
The serialization and deserialization of editor mode is not supported by
the current code, and while the download correctly checked for the
player being in editor mode, the upload did not check for the player
being in editor mode causing players in editor mode to break. Fix by
adding check in upload_inventory.
2021-10-01 01:52:18 +02:00
Hornwitser 034ca4c250 Fix research_sync sending commands before instance start
Fix commands syncing researces being sent before the instance is started
up, and potential desync of research progress as a result of out of
order execution of RCON commands sent.
2021-10-01 01:52:18 +02:00
Hornwitser 2b101d594a Fix statistics_exporter sending commands before start
Fix statistics_exporter potentially sending commands before an instance
is started when metrics are collected.
2021-10-01 01:52:18 +02:00
Hornwitser c40362fa7f Add sendOrderedRcon for plugins
Add BaseInstancePlugin method for sending RCON commands in order to
simplify logic in plugins that depend on commands being delivered in
order.
2021-10-01 01:52:18 +02:00
Hornwitser e8b515c733 Update missing docs from connector refactor
Fix documentation of max_reconnect_delay, heartbeat_interval and
session_timeout which where changed with the connector refactor.
2021-09-28 23:04:30 +02:00
Hornwitser e5582b5558 Skip gatherng metrics from dropped slaves
Avoid stacking up huge amounts of metrics requests while a slave
connection is in the dropped state.
2021-09-28 21:36:28 +02:00
Hornwitser 3b6ebeb653 Handle errors gathering metrics from slaves
If the session to a slave is lost while gathering metrics the error
would propagate out to the metrics route handler and break collection of
statistics.  Fix by handling errors thrown from sending the getMetrics
request to slaves.
2021-09-28 21:32:29 +02:00
Hornwitser bfec83996a Expect close in WsServer connection disconnect
Don't bother doing the prepareDisconnect dance if the connection is
already broken. This avoids having to wait until the connection times
out when a slave gives up on a current connection and starts a new one.
2021-09-28 21:28:38 +02:00
Hornwitser 4a72c14b74 Terminate connection on timeout
If a connection has timed out the most likely cause is the connection
going stale, in which case calling close could take up to 30 seconds
before the ws lib gives up on waiting for the close message.  Terminate
the connection instead on the Node.js side.  Note that Firefox does not
permit applications from closing connections with a code other than
1000, or in the range 3000-4999.
2021-09-28 21:24:42 +02:00
Hornwitser f529457f96 Replace ini packages with homebrew module
The INI format Factorio use differs from what the ini package parse and
produce.  Most notably no escape mechanism exists in Factorio's parser
which causes instance paths containing among other thing the # character
to completely break.  Fix by replacing the ini package with a Factorio
compatible implementation of INI parsing and serialization.

Resolves the problem in #414.
2021-09-27 03:45:52 +02:00
Hornwitser 642504874b Fix error on slave reconnect of stale connection
Remove call to removed function setTimeout on connector that was left
over from the connector refactor.
2021-09-27 03:45:52 +02:00
Hornwitser d99e160a27 Remove unused node-factorio-api dependency 2021-09-27 03:45:52 +02:00
Hornwitser 714e590496 Add sorting to web interface tables
Add sort options to the slave, instance, user, role and plugins tables.
2021-09-27 03:45:52 +02:00
Hornwitser bfcb57002d Merge pull request #413 from Hornwitser/hide-unauthorized-controls
Hide unauthorized controls
2021-09-22 22:14:15 +02:00
Hornwitser 8b59c89322 Link account menu name to user account
Take the user to the user view page for their own account when clicking
on the name in the top right account menu.
2021-09-20 00:57:38 +02:00
Hornwitser 31e9f59d8d Move save list subscription permission
Move the save list subscription permission to be after the save list
permission as these two permissions are logically related to each other
and both need to be granted to view the save list in the web interface.
2021-09-20 00:56:47 +02:00
Hornwitser 0c74aef68e Hide unauthorized web interface controls
Hide or disable controls in the web interface that the user for the
control connection it's connected as don't have the requisite
permissions to use.
2021-09-20 00:54:43 +02:00
Hornwitser 18e9fc8e7c Provide roles and permission for the control user
Add the roles and their permissions for the user the control connection
is connected as along with updates when these change.  This enables the
web interface to know which permissions it has available to it.
2021-09-20 00:52:59 +02:00
Hornwitser 1035389c9e Hotfix new player sync logic
Fix has_data not being checked by sync_player, dialog being stuck open if
no data is received by the master and master server refusing to server
empty data.
2021-09-12 00:27:46 +02:00
Hornwitser 8c62213a52 v2.0.0-alpha.9 2021-09-11 23:37:28 +02:00
Hornwitser e7248bfd98 Merge pull request #412 from Hornwitser/small-things-5
Small things 5
2021-09-11 21:50:07 +02:00
Hornwitser 8c0e883b23 Add Inventory Sync to the installer plugins 2021-09-11 19:23:23 +02:00
Hornwitser e3d8aa1a9b Add limit and order to query log request
Add optional limit to the number of log records to return in the
log query request to avoid having the web interface instance console
download the entire log for only to show a few hundred lines from it.

To make it possible to query the end of the log instead of just the
start add an order option as well.
2021-09-11 19:11:30 +02:00
Hornwitser 9aebeeac02 Fix offSlaveUpdate and offSaveListUpdate
Fix unreachable code and use of slice instead splice turning
offSlaveUpdate and offSaveListUpdate into no-ops.
2021-09-11 17:42:26 +02:00
Hornwitser 049ccee94e Disable save buttons when slave is offline
With the instance status unknown now meaning assigned slave is offline
the save list buttons can now easily be disabled using this status.
Disable the upload save, download save and delete save buttons when the
slave is offline or the instance is not assigned to a slave.
2021-09-11 17:40:38 +02:00
Hornwitser c59267d5ee Set status to unknown on slave disconnect
Change the instance status to unknown if the slave it's connected to
is disconnected rather than leaving them at their previous state
indefinitely.
2021-09-11 17:12:06 +02:00
Hornwitser c9ae964eee Correct filetype of headless server
The Factorio headless archive is actually an xz compressed tar, not gz.
2021-09-11 04:19:51 +02:00
Hornwitser 89cf89670f Add rate limiting to storage updates
Limit the rate of storage updates broadcasted by subspace_storage to at
most one per second.
2021-09-11 04:18:17 +02:00
Hornwitser 3b659116db Don't ping subspace_storage mod if master connection is down
The system for preventing items being sent without a connection to the
master and (and thus voiding the items) is to only do so if a ping from
the plugin has been received in the last 5 seconds of in-game time.
Don't send pings without being fully connected to the master to make
sure this system actually activates during connection downtime.
2021-09-11 04:18:17 +02:00
Hornwitser ddd1bde1b4 Fix bad command and request sending in subspace_storage
Ignore inventory updates and orders while the instance is not in the
running state to avoid sending commands before the server has started
up, and also ignore orders if the connection to the master server isn't
isn't online to avoid piling up requests in the buffer during session
resume attempts.
2021-09-11 04:18:17 +02:00
Hornwitser 715ab343bf Explicitly drop events events to the master if no session exists
Since the connector refactor the action of sending a message on a
connector that is in the process of connecting changed from queuing up
the message to send it to throwing an error.  To avoid events forwarded
by the slave to to the master when the connection is down from being
logged as an error forwardEventToMaster has to explicitly drop them.
2021-09-11 04:18:17 +02:00
Hornwitser 7430212ac8 Send save list explictitly on stop
Prevent ordering issues with save notifications on stop by making the
save list update part of the stop action instead of some unspecificed
amount of time later.

This also fixes the issue of the connection being closed before the last
instance sends it save list on slave shutdown.
2021-09-11 01:02:24 +02:00
Hornwitser 33efb32aa9 Add connected property to Slave and BaseConnection
Provide a convenience method for checking if the Master-Slave link is
fully online via a .connected property.  This is true if the link has
been establised, is not in the dropped state, and not in the process of
disconnecting.
2021-09-11 01:02:24 +02:00
Hornwitser 679eec05bc Fix copy-paste mistake 2021-09-11 01:02:24 +02:00
Hornwitser b019d44d0b Fix active connectors leaking after refactor
The activeConnectors mapping is from session id to connector, but the
master refactor in 67219ed accidentally changed it to try deleting the
entry by the connector reference instead.  Possibly mistaking it for a
Set instead of a Map.
2021-09-11 01:02:24 +02:00
Hornwitser efc71580ea Fix Lua global leaks
Fix unintended leaked globals in the clusterio and player_auth modules.
2021-09-11 01:02:24 +02:00
Hornwitser 90dacd98b2 Rework inventory_sync (#411)
Complete rework of the inventory synchronization to reduce the number of
times the inventory has to be downloaded and the possibilities for item
loss/duplication.

The basic concept is to acquire exclusive access to the player data
before downloading the data.  This prevents the player from logging in
to two servers at the same time and getting the synced inventory twice.
The acquisition will in most cases execute faster than the player can
provide input to the game on login, and if the player data on the master
isn't what was previously uploaded then the player inventory is deleted
and the player is turned into a spectator while the updated inventory is
being transferred into the game.  This model allows for the downloading
of the synced inventory to be skipped in the case the player joins the
same server they left from.

The synced data is expanded to include more than just the character and
inventory to properly handle sync of dead players.  If the player leaves
dead on one server this state will be saved and when the player joins
another server they continue off as dead.  The ghost, god, and spectator
controllers are supported, while the cutscene and editor controllers are
not.  For players in a cutscene when joining the acquisition will be
attempted immediately, but the scenario waits until the player is out of
the cutscene before acting upon the response to the acquisition.  For
players in the editor mode the scenario toggles them out of the mode
before syncing data.

Since uploads to the master can fail handling of this is also added.
The scenario will retry uploading the player data at exponentially
increasing intervals until it gets the message back that the upload
succeeded.  Since it's possible for servers to go down and stay down for
a long time the exclusive access held by these servers are released after
a timeout.  This means that a player could be playing and or have
uploaded newer data by the time the instance comes back online and tries
to upload the inventory, in this case the master will report the upload
succeeded but discard the data.  Instances may also upload old data if
they load back to an earlier save, which will also be discarded by the
master.

Other small improvements made by this rework:
- Add a retry button to the failed download dialog
- Update the existing progress dialog instead of destroying and
  recreating it every time.
- Automatically stuff the items from the temporary inventory into the
  synced inventory if they fit.
- Move player back to the position they started the download from if
  they were turned into a spectator for the download.
- Ensure the player has a character if the stored data is wiped.
- Add error checking of the RCON commands sent by the instance.
2021-09-07 15:49:53 +02:00
justchen1369 f9a1e28074 Add option to download and unpack latest Linux headless in installer (#409) 2021-09-07 03:12:53 +02:00
Hornwitser de3f5da9d4 Merge pull request #407 from Hornwitser/refactor-connectors
Refactor connectors
2021-08-31 20:18:14 +02:00
antt1995 abe2acd0dc Add description for running via Systemd (#408) 2021-08-31 20:14:35 +02:00
Hornwitser de48d807f1 Refactor connector logic
Refine the state flow used in the connector abstractions to better
handle close while connecting and resuming.  The new logic is to close
the connection immediately in these two states instead of continuing
trying to connect and then giving up some point later.

For the connectors the handshake state is split into connecting
and resuming, new is replaced by closed, and the closing state is turned
into a boolean flag as it's possible that closing is desired while
connecting, connected or resuming.  The connecting state is not present
in the server connector because there's nothing for the server to do in
that connector state.

The timeout logic is completely removed, once a connection attempt is
started for the client it will continue to attempt to connect, attempt
to resume dropped connections and attempt reconnects until either an
authentication error occurs or the client explicitly requests to close
the connection.

A new session timeout is added to control how long to attempt to resume
the connection until it's considered dead and invalidated.  This allows
stale connections to give up earlier, and swallow up less data while in
the dropped but not yet invalidated state.

For the client side sending messages while it's connecting has been
changed to throw an error instead of queuing up the message and
(possibly) sending it after the connection is established.  Code
shouldn't try to send messages without knowing if the connection it's
trying to send them over is active or not.
2021-08-30 20:20:49 +02:00
Hornwitser 22032ae4f8 Describe how to set up Subspace Storage (#404)
Add descriptions to the README detailing how to set up mods, and how to
set up Subspace Storage which requires manually setting up mods to work.
2021-08-19 19:10:51 +02:00
Hornwitser 3e617a81cf Add exponential backoff helper
Add helper class for implementing exponential backoff delay.
2021-08-17 05:07:46 +02:00
Hornwitser 9005b6cef9 Merge pull request #403 from Hornwitser/single-web-lib-entrypoint
Single web lib entrypoint
2021-08-17 03:27:54 +02:00
Hornwitser f4635a79f9 Remove ajv from plugin webpack config
The ajv module was made shared to reduce package size, but it turned out
the package size was large because of a missunderstanding in how imports
to sub-paths works with module federation.  Since that was fixed the ajv
module was no longer shared, but plugins incorrectly still specified it.
2021-08-17 01:42:45 +02:00
Hornwitser 8a0c1e8a4b Use single entrypoint for lib in web interface
In order to import a path inside packages/lib/ in the webpack shared
module environment it has to be declared as shared in both the web_ui
webpack config and the plugin's webpack config.  This is rather error
prone and easy to miss.  Move the burden of specifying which entrypoints
are valid in lib to lib itself by changing all imports in the web
interface code to import from @clusterio/lib instead sub paths.

For web code this means imports is done like this from now on:

import { libConfig, libLink } from "@clusterio/lib";

For info.js which has to be loadable by both Node.js and web code the
imports are done as follows:

const { libConfig, libLink } = require("@clusterio/lib");

While the Node.js only code can either continue to import sub paths
directly or use the info.js style.
2021-08-17 01:42:45 +02:00
Hornwitser e3b662717b Merge pull request #391 from Danielv123/inventory_sync
Inventory sync
2021-08-14 19:23:08 +02:00
Hornwitser cbfc0adf3d Remove extra whitespace in inventory_sync plugin 2021-08-13 06:22:45 +02:00
Hornwitser 4a29c13acb Fix bad reference in inventory_sync stat request 2021-08-13 06:21:32 +02:00
Hornwitser 60e434e4ac Fix inventory_sync player created/removed
Create entries for players that are present in the save on server
startup to preserving their inventory and fix attempting to reference the
player object after it had been removed in on_player_removed.
2021-08-13 06:17:59 +02:00
Hornwitser d126e7f25a Validate GUI element in on_gui_click
Make sure the GUI element in the on_gui_click event is valid before
attempting to access this, as another module may have destroyed it
during its event handler.
2021-08-10 18:19:18 +02:00
Hornwitser ecbce16c99 Be more defensive with global table initialization
When upgrading or downgrading the inventory_sync plugin it's possible
an different structure of the global.inventory_sync table is left behind
which could cause the code to crash when certain sub tables are not
present.

Be more defensive in the initialization of the table to make sure all
sub-tables are present on startup.
2021-08-10 17:41:40 +02:00
Hornwitser 0576308187 Fix unable to clear number fields in web UI (#402)
Fix setting a nullable numeric field to blank in the web UI return error
that the value cannot be a string due to the field returning null and
this being converted to the string "null".
2021-08-03 16:09:17 +02:00
Hornwitser f2c9cd0c62 Don't try stopping if instance is already stopped (#401)
Ignore stop if instance status is already stopped. Fixes instance status
getting stuck at stopping if Factorio exits during startup due to the
stopped status having already been sent at the time stop is invoked.

Fixes #400
2021-08-03 16:08:16 +02:00
Daniel f72995ec8b Remove control plugin 2021-08-01 21:54:46 +02:00
Daniel 2ffb848fe6 Replace spaces with tabs 2021-07-29 23:59:37 +02:00
Daniel e63f1246da Offer the player a temporary inventory while waiting for master 2021-07-29 23:55:48 +02:00
Hornwitser dbe623c9a7 Replace spaces with tabs for Lua, and more (#399)
Convert code indents to tabs to be consistent with the rest of the
JavaScript code.  This commits covers all of the lua code, some of the
hand written JSON code and the lone HTML and CSS files that were not
using tabs.
2021-07-13 17:18:44 +02:00
Hornwitser dbe01c6c23 Decode master secret from base64 (#398)
The jsonwebtoken accept strings as input for secrets which is
nonsensical from a cryptographic standpoint as the algorithms all
operate on byte sequences.  Worse still, strings are encoded into bytes
using UTF-8, which disallows certain sequences of bytes from appearing
and potentially weakening the cryptography.

For legacy reasons the master secret was encoded into base64 _and passed
as a string to jsonwebtoken_.  This further weakened the crypto as the
bytes that can appear in base 64 is only 1/4 of the 256 possible values
for bytes.  This could significantly weaken the cryptographic strength
of the tokens.

I have known of this issue for quite some time but has put off actually
fixing it for too long now.  Since the master secret is already
documented as being in base 64, decode it as base 64 and pass it as a
byte Buffer to jsonwebtoken.  This will ensure every byte value is
possible in the byte sequence passed to the jsonwebtoken crypto.

The impact of this change is unfortunately that all tokens will become
invalid and will have to be reissued.
2021-07-13 17:16:10 +02:00
Daniel 29917fb85a Maintain nodejs v12 support 2021-07-12 23:30:41 +02:00
Daniel 1a1b4fb0d2 Fix invincible ghost character creation on first join
Inventory sync will now wait intil the player has a character.
This means sync won't happen ex during respawn animations
or freeplay intro cutscenes.
2021-07-12 23:28:16 +02:00
danielv 4027a4f310 Cleanup, sync more player properties 2021-07-12 22:05:54 +02:00
danielv 37052e1f68 Changed inventory storage to use Map 2021-07-12 22:05:54 +02:00
danielv 98f801e244 Changed usage of globals, worked around edge cases 2021-07-12 22:05:54 +02:00
Daniel f9ef2ebbe1 Microoptimizations after code review 2021-07-12 22:05:54 +02:00
Daniel a939e11641 Fixed bug with import of logistics slots
Slots were shifted 1 slot to the right every time due to off by one issue in the deserializer
2021-07-12 22:05:54 +02:00
Daniel a4a667ef06 Reimplement core method for crafting_queue sync
* New method avoids all dataloss and prefectly replicates the queue
* Made proper description in readme
* Added packages to make lerna build happy
* Added reference to main readme
2021-07-12 22:05:54 +02:00
Daniel 87218e4803 Support for transferring crafting queues 2021-07-12 22:05:54 +02:00
Daniel 40301f59b7 Transfer all misc properties as well 2021-07-12 22:05:54 +02:00
Daniel 5db9cad7e5 Removed unused settings 2021-07-12 22:05:54 +02:00
Daniel e4bdf996f7 Serialize hotbar&logistics&data, show download progess 2021-07-12 22:05:54 +02:00
Daniel c1d0404f23 Made inventories download in chunks to prevent blocking rcon 2021-07-12 22:05:54 +02:00
Daniel 6bb2c50e5c Implemented changes suggested by linter 2021-07-12 22:05:54 +02:00
Daniel 8a85cfc8da Added small web interface with the most vital stats 2021-07-12 22:05:54 +02:00
Daniel 86bd07666f First working draft of inventory sync 2021-07-12 22:05:53 +02:00
Hornwitser a578719fc5 v2.0.0-alpha.8 2021-06-28 16:29:34 +02:00
Hornwitser 72e0b6a3b1 Merge pull request #397 from Hornwitser/fix-export
Fix export
2021-06-28 16:17:50 +02:00
Hornwitser 90a400b782 Handle mod not found in data export
Handle the case of the mod file not being found from the mod version.
This could happen if the naming scheme is weird or the mod is unpacked.
2021-06-26 17:59:48 +02:00
Hornwitser 80eae94fe1 Handle leading zeros in mod filenames
It's possible to put leading zeroes in the names of mod zip files which
cause the existing algorithm to find the mod zip from the mod version to
fail.  Fix by listing the mods directory and fixing the version numbers
for any of the mods with leading zeros in them.
2021-06-26 17:59:48 +02:00
Hornwitser 5daa492f68 Emitt more useful error on parse error
Rethrow parse errors with the JSON content that failed to parse for JSON
content sent to Clusterio from Factorio.
2021-06-26 17:59:48 +02:00
Hornwitser b43a181bf4 Fix JSON serialization in export mod
Fix incorrect serialization of control characters to \xHH escapes since
JSON only support \uHHHH escapes, and serialization of infinite and NaN
numbers.
2021-06-26 17:59:48 +02:00
Hornwitser a31ba31567 Fix Docker build referencing the old mod branch 2021-06-26 17:42:34 +02:00
Hornwitser b03b201c20 Merge pull request #395 from Hornwitser/rename-repository
Rename repository
2021-06-22 18:46:24 +02:00
Hornwitser af995428bc Rename clusterio/factorioClusterioMod repository
The mod was rebranded to subspace_storage quite some time ago.
2021-06-22 18:36:02 +02:00
Hornwitser d79e30f9be Rename clusterio/factorioClusterio repository
Drop the factorio part of the Clusterio repository name.
2021-06-22 18:36:02 +02:00
Hornwitser f5228e6c2e Merge pull request #396 from Hornwitser/fix-upload
Fix upload
2021-06-21 17:44:57 +02:00
Hornwitser ea77e09058 Show upload progress
Add progress bar for file uploads in progress and notify user if the
upload fails.
2021-06-21 16:59:36 +02:00
Hornwitser 5ab4b95e3c Support non-standard zip mime types in save upload
Browser like Chrome doesn't use the application/zip mime type when
uploading .zip files.  Add support for application/x-zip-compressed to
the save upload endpoint.
2021-06-21 16:59:36 +02:00
Hornwitser 8dc1415404 Skip slaveUpdateEventHandler test on Windows
Due to the lack of a simple way to automate graceful shutdown of a slave
on Windows causing the test to be unreliable, skip it on Windows.
2021-06-21 16:59:04 +02:00
Hornwitser 122bab303d Remove suggestion to use the 1.2.x branch (#394)
Development on 1.2.x has effectively been abandoned.  Discourage
installing or working on 1.2.x.
2021-06-18 14:00:21 +02:00
Hornwitser 160051fc3c Merge pull request #392 from Hornwitser/installer
Add Nodej.s based installer
2021-06-17 15:13:11 +02:00
Hornwitser 500baffb2b Use installer in installation guide
Switch over to referencing the installer as the method for installing
Clusterio in the readme and contributing docs.
2021-06-16 21:04:58 +02:00
Hornwitser 4c77f5ac31 Add installation script
Provide a create package for the clusterio npm org which gathers the
necessary details to install a working cluster either as standalone or
as separate nodes.
2021-06-16 18:56:37 +02:00
Hornwitser 8ad518e7b6 v2.0.0-alpha.7 2021-06-16 13:08:58 +02:00
Hornwitser 1faf6df5a3 Merge pull request #390 from Hornwitser/alpha-7
Alpha 7 preparations
2021-06-16 13:06:30 +02:00
Hornwitser fc08438164 Add generate slave token button
Add a generate slave token button to the web interface and modify the
generateSlaveToken message and corresponding ctl command to have the
slave id be optional.  The random id is the desired behavior when adding
a slave with the installer, as it'll ask for the token for the new slave
and deduce the id from the token.
2021-06-15 14:46:44 +02:00
Hornwitser cc562a451e Fix create instance modal
Fix copy and paste bug in the name check for the create instance moddal.
2021-06-15 14:25:45 +02:00
Hornwitser 782e93f4a1 Add changelog entry for #380
Add entry in CHANGELOG.md for the save management feature.
2021-06-15 14:24:35 +02:00
Hornwitser cf1e333191 Add bootstrap command to generate slave token (#389)
To simplify the standalone installation add command to create a slave
token without having to start up the master server.
2021-06-14 12:08:24 +02:00
Hornwitser 31f1099dc0 Expect error loading configs (#388)
Rethrow error loading configs as StartupError as these are most likely a
result of user error.
2021-06-14 12:07:59 +02:00
Hornwitser ddaaf15b2e Merge pull request #383 from Danielv123/documentation-updates
Documentation updates
2021-06-12 15:15:19 +02:00
Hornwitser a6cafeb5c6 Merge pull request #386 from Hornwitser/instances-page
Instances page rewrite
2021-06-12 15:15:04 +02:00
Hornwitser 9b34b949a6 Accept invalid IDs in useSlave and useInstance
Gracefully handle invalid IDs in useSlave and useInstance as this is
necessary when when the ID isn't known by the component using the hook,
since it can't conditionally call the hook.
2021-06-10 10:26:40 +02:00
Hornwitser 82c3d27bff Rewrite instances page
Render the table in the instances page using an antd Table component
directly and feed it the data via an automatically updating list of
instances.
2021-06-10 10:26:40 +02:00
Hornwitser 0e9893fcb6 Fix create save modal
Fix the wrong variable being passed as mapSettings and clear the form
after successfully creating the save.
2021-06-10 10:26:40 +02:00
Hornwitser f37c387d52 Fix instance deleted status not being sent out 2021-06-10 10:26:40 +02:00
Daniel 7a49e8aa15 Merge pull request #385 from Hornwitser/fix-command-after-stop
Fix subspace storage sending commands during stop
2021-06-10 06:58:20 +02:00
Daniel c80be802d1 Merge pull request #384 from Hornwitser/test-fixes
Fix tests on Windows and remove internet requirement to pass
2021-06-10 06:54:52 +02:00
Daniel ff9b05fc85 Merge pull request #382 from Hornwitser/slave-updates
Add slave update subscriptions
2021-06-10 06:50:03 +02:00
Hornwitser 55f3dac9b6 Fix integration tests requiring internet to work
When factorio starts up with with the require_user_verification setting
turn on it tries to obtain a server padlock from the matchmaking server
and will fail if this does not succeed.  Turn off user verification and
public visibility which require user verification both of which are on
by default for the integration tests so that they work without internet.
2021-06-09 14:53:33 +02:00
Hornwitser 1b082c6a25 Fix subspace storage sending commands during stop
Add logic to stop sending commands and wait for any pending commands to
complete during instance stop in subspace_storage.  This should fix the
RCON connection lost errors printed in the console during stop.
2021-06-09 14:47:52 +02:00
Hornwitser 5b78a86166 Fix HttpCloser test on Windows
Double timeout for the refuse connnection test as it's taking 2 seconds
on Windows for some reason.
2021-06-09 13:28:18 +02:00
Daniel 7d40b086e4 Fix norwegian text in web interface 2021-06-09 09:48:47 +02:00
Daniel 59b2338891 Updated guide with commands to start from source 2021-06-08 08:20:40 +02:00
Daniel af525c01ad Added syntax highlighting to docs/writing-plugins 2021-06-07 14:00:21 +02:00
Hornwitser 2be91cbd3a Add slave update subscriptions
Allow the web interface to listen for updates to the slave list and
apply it live to the places where slaves are shown in the web interface.
2021-06-02 16:19:18 +02:00
Hornwitser cc438574ff Merge pull request #380 from Hornwitser/save-management
Save management
2021-06-01 20:38:50 +02:00
Hornwitser 09e4256c18 Fix hang on missing/wrong Factorio version
Fix the start command hanging if the configured Factorio version for the
instance started is not available on the slave.
2021-06-01 16:38:58 +02:00
Hornwitser a0f5864324 Copy save if the save to start is an autosave
Prevent potential data loss when starting an autosave by copying it
first.  This avoids overwriting a random autosave slot when the server
shuts down after having loaded an autosave.
2021-06-01 16:38:58 +02:00
Hornwitser 12db0d5a84 Add download save feature
Add command and web interface button to download saves from instances.
2021-06-01 16:38:56 +02:00
Hornwitser 35f54a0ee8 Add delete save feature
Add command and web interface button to delete saves.
2021-06-01 16:32:58 +02:00
Hornwitser 2e314ff098 Add upload save feature
Add /api/upload-save endpoint that streams the data via the slave
assigned to the target instance to the instance saves directory, along
with a ctl command and web interface button to do the upload.
2021-06-01 16:27:02 +02:00
Hornwitser 0066a06107 Add findUnusedName to file_ops.js
Add function to find an unused name in a directory to lib/file_ops and
use it for finding an unused instance directory.
2021-06-01 11:22:22 +02:00
Hornwitser 544d8deb81 Add load save button to web interface 2021-06-01 11:22:22 +02:00
Hornwitser e730718671 Update antd 2021-06-01 11:22:22 +02:00
Hornwitser a96fae5ffd Add save listing and subscriptions
Add link messages for listing saves and listening for changes to the
list of saves for instances along with a table in the instance view page
showing the save list for the instance and a list-saves command to ctl.
2021-06-01 11:22:22 +02:00
Hornwitser b53ed7f6ca Add Export data button to instance page
Add missing button to do the export data action on an instance.
2021-05-21 18:39:24 +02:00
Hornwitser dd216318b0 Add option to pass map settings to Factorio
Add options to pass map seed, map exchange string, map gen settings
and/or map settings when creating a save either through the create save
request or the load scenario request.
2021-05-21 18:39:19 +02:00
Hornwitser 4a8a4c8227 Check if test control exists before closing it
Fix after action in integration test throwing in certain error cases.
2021-05-21 17:30:06 +02:00
Hornwitser 177278fee3 Ignore instance status changes that didn't change
Check if the instance status in the instanceStatusChanged event actually
changed and ignore the event if it didn't.  This prevents bogus updates
from being pushed to control clients and plugins.
2021-05-21 17:27:25 +02:00
Hornwitser 6ffcb8c451 Add instance update subscription
Provide the control link with the ability to subscribe to updates when
instance status and properties changes.  This allows the web interface
to respond in real time to instance starts and stops that are triggered
from somewhere else.
2021-05-21 17:27:01 +02:00
Hornwitser bda103d7a0 Fix --dev mode broken by master refactor 2021-05-20 21:51:51 +02:00
Hornwitser 2eadc3ea10 Add Map Exchange String parser
Add readMapExchangeString to lib/factorio which parses and decodes the
data in Map Exchange into a format suitable for giving to the Factorio
server command line.
2021-05-20 21:51:51 +02:00
Hornwitser b352e89b48 Merge pull request #379 from Hornwitser/small-things-4
Small things 4
2021-05-17 22:02:40 +02:00
Hornwitser 19ebee4697 Remove PluginsContext
The plugins are available as a property on the Control provided by the
ControlContext which makes the PluginsContext redundant.  Remove it and
use ControlContext to retreive the plugin list in the app.
2021-05-16 20:36:09 +02:00
Hornwitser 4014eb4ec2 Fix log query broken after refactor
Add missing levels import from the logging library in
ControlConnection.js.
2021-05-15 23:22:37 +02:00
Hornwitser e94662350a Support symbolic links to modules
Ignore files instead checking if the entry is a directory looking for
modules to patch in.  This is to support symbolic link to modules.
2021-05-15 23:22:37 +02:00
Hornwitser 0ae4fa0d2e Create equipment grid when deserializing it
Create equipment grid for items that support it and doesn't currently
have a grid.  Fixes deserialization of spidertron equipment grids.
2021-05-15 23:22:37 +02:00
Hornwitser b0067eccc6 Use node plugin for Eslint
The Node.js specific rules for Eslint were deprecated in favour of the
node plugin for Eslint.  Add node plugin for eslint and switch over to
using its rules, fixing any issues detected.
2021-05-15 23:22:37 +02:00
Hornwitser 95b2262cc3 Remove log rotation
Remove log rotation as Factorio implemented its own log rotation an
eternety ago and the cluster log already contains all the log entries.
2021-05-15 23:22:32 +02:00
Hornwitser 7cef5528b3 Check for 0.16 startup string
Adjust the regex for detecting startup to also work with 0.16 as
updateTick was called mapTick before 0.17.  Note: This commit does not
imply 0.16 is going to be supported by Cluestio.
2021-05-15 20:53:16 +02:00
Hornwitser a7d728c3ac v2.0.0-alpha.6 2021-05-12 23:50:26 +02:00
Hornwitser 4e61c62965 Add changelog entry for previous commit 2021-05-12 22:06:22 +02:00
Hornwitser 1c4010a744 Drop support for Node.js v10
Version 10 of Node.js is no longer supported upstream, so stop
supporting running Clusterio with it.
2021-05-12 21:43:40 +02:00
Hornwitser cbfe6fbc11 Merge pull request #376 from Hornwitser/master-refactor
Refactor master
2021-05-12 21:42:16 +02:00
Hornwitser e9c3c38a53 Add SlaveConnection.id
Add .id property to access the id of the slave this SlaveConnection is
for.
2021-05-12 20:42:52 +02:00
Hornwitser 322570f298 Add instance config event for master plugins
Add onInstanceConfigFieldChanged hook for master plugin code which
triggers when instance configs are changed on the master server.
2021-05-12 20:42:52 +02:00
Hornwitser 8b857224dc Move app routes into separate function
Reduce the _startupInternal complexity by moving out the express app
route logic into a separate function.
2021-05-12 20:42:52 +02:00
Hornwitser d3ea85d5ac Add stopping instance status
Notify when an instance is in the process of stopping with a separate
status for it.  This makes handling shutdown gracefully a bit simpler in
plugin code.
2021-05-12 20:42:52 +02:00
Hornwitser f6ab825692 Add status events for create/delete instance
Notify plugins of status changes when instances are created and deleted.
2021-05-12 20:42:52 +02:00
Hornwitser 67219ede6b Refactor master code
Move implementation of master systems into dedicated classes for better
organization.
2021-05-12 20:42:40 +02:00
Hornwitser 8b32267858 Merge pull request #377 from Hornwitser/test-package
Move test dependencies into separate package
2021-05-12 20:39:51 +02:00
Hornwitser 0a469a0524 Add missing test dependencies
The usage of lerna bootstrap --hoist meant these dependencies being
missing went unnoticed.
2021-05-12 19:57:19 +02:00
Hornwitser 65c6fd592c Log stderr instead of writing to test log
During Travis integration tests a BrokenPipeError is emitted on stderr
after the Factorio server has quit.  I have no idea what's going on here
so for now just log it and ignore it.
2021-05-07 20:07:40 +02:00
Hornwitser a6ce755743 Update development tools 2021-05-07 18:04:13 +02:00
Hornwitser c2e7a02e5a Use --hoist
Save time installing dependencies by recommending the usage of --hoist.
2021-05-07 17:39:49 +02:00
Hornwitser ef0218ddfe Move test dependencies to separate package
Move the depenedncies needed to run the tests into test/package.json and
manage it using lerna.  This should make it easier to selectively
install only what's needed to run the software.
2021-05-07 17:39:21 +02:00
Hornwitser ea645da3cb Fix chalk dependency being in the wrong package 2021-05-07 17:39:21 +02:00
Alyssa Smith 9a42f784ff Make community plugin list clearer (#374)
Include npm package names to be copied by users.
2021-03-29 00:45:42 +02:00
Hornwitser 67002e4b71 v2.0.0-alpha.5 2021-03-23 21:23:22 +01:00
Hornwitser 166416339a Merge pull request #373 from Hornwitser/alpha-5
Alpha 5 preparations
2021-03-23 21:18:11 +01:00
Hornwitser ead2c6d2c1 Add release checklist
Add instructions for doing releases to npm to the contributing docs.
2021-03-23 20:37:32 +01:00
Hornwitser bfea6cb28e Don't log print permission denied stack traces
Use PermissionError to signal when permission is denied for an action
and prevent them being logged as stack traces in the master log.
2021-03-23 20:09:09 +01:00
Hornwitser 7ecac539f9 Fix path stripping for relative and Windows paths
Fix path stripping logic to take into account the possibility of the
writePath being relative, and that Windows uses \ as a path separator
for some paths but not all.
2021-03-23 20:09:09 +01:00
Hornwitser e73599a800 Merge pull request #372 from Hornwitser/storage-page
Storage page
2021-03-23 20:02:31 +01:00
Arvids Godjuks f46f40ad4a Merge pull request #363 from drouarb/master
MacOS compatibility
2021-03-23 20:59:44 +02:00
Benjamin DROUARD 058197dc99 Using factorio.app/Contents for MacOS 2021-03-21 16:43:32 +01:00
Hornwitser 8fb6784964 Load plugin infos in integration tests
Make sure the permisisons defined by plugins are included in the
integration test environment by loading the plugin infos.
2021-03-20 17:03:23 +01:00
Hornwitser c16dc74ad9 Reimplement the Storage page
Reimplement the functionality of the Storage page which shows which
items are stored in the shared subspace on the master server.
2021-03-20 03:35:05 +01:00
Hornwitser cebe83542f Add access to icons and locale from export
Add models for accessing and using the icons and locale that's exported
when an instance export is runned.
2021-03-20 03:35:05 +01:00
Hornwitser 6c19145734 Add control reference to web plugins
Add reference to the Control instance in BaseWebPlugin for web plugin
code to use in a similar manner to the instance reference in instance
plugin code.
2021-03-20 03:35:05 +01:00
Hornwitser 0a6e26bb2d Add connection events for control connections
Add hooks for plugins to be able to handle connection state changes for
control connections on the master server and in the web interface.
2021-03-20 03:35:05 +01:00
Hornwitser de629d9416 Pass link to plugin message handlers
For master plugins a message can be received from any of the clients
corrently connected to the master.  In order for plugins to be able to
handle link specific requests they need a reference to the link messages
was received from.  Add link as the third argument to plugin message
handlers.
2021-03-20 03:35:05 +01:00
Hornwitser 48786f1e59 Fix --dev-plugin breaking after bd53339
The automatic determination of the public path for plugins works
correctly when they are build for production and loaded from their
respective paths.  But when the dev middelware compiles it, it doesn't
know the correct path to host it at withouth the publicPath being set.

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

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

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

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

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

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

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

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

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

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

With that issue fixed there are no longer any connections or timers
keeping the event loop alive and --exit can thus be removed.
2021-03-06 18:40:07 +01:00
Hornwitser d6f09f4c1a Fix close during reconnect wait period
If the connect close method is called while the connection is waiting
for a reconnection attempt then the attempt is cancelled but the close
method then erroneously waited for a close event that would never
happen.  Fix by resetting the connector and rasing the close event in
the close method if it's in the waiting for reconnect state.
2021-03-06 18:38:30 +01:00
Hornwitser 85a984734b Fix node-forge dependency
The removal of the automatic TLS certificate generation removed the use
of node-forge in the master server, while the addition of the HttpCloser
added it as dependency of the tests.  Fix node-forge dependency being
listed in lib and master instead of as a dev dependency in package.json.
2021-03-06 18:38:30 +01:00
Benjamin DROUARD b78eafaedb MacOS compatibility 2021-03-05 17:31:14 +01:00
Hornwitser 641d8e9fc5 Merge pull request #362 from Hornwitser/plugin-list
Add plugin list
2021-03-05 16:58:15 +01:00
Hornwitser bdd70e53dc Fix errors detected by linter
I meant to fix these changes before pushing the PR, but forgot about it.
2021-03-03 14:41:55 +01:00
Hornwitser 7797ee708a Add ctl master plugin list command
Add command to list the plugins enabled on the master server and their
versions to clusterioctl.
2021-03-01 15:03:25 +01:00
Hornwitser 6c9d3d8893 Add Plugins page to web interface
Add page listing the plugins installed on the master along with a
detailed page for each plugin with fields from their package.json.
Failure to load the module and version missmatch is also reported.
This adds ./packages.json as required part of the webpack module.
2021-03-01 15:03:25 +01:00
Hornwitser 3a3a7597bf Add PluginsContext to web_ui
Add a React context to access the plugins loaded in the web interface.
The info, /api/plugins metadata and webpack container is provided for
each plugin.
2021-03-01 15:03:25 +01:00
Hornwitser d0befe5944 Add error boundnary for sidebar paths too
b538960 added an error boundnary for pages not directly linked on the
sidebar, but forgot to add it for pages on the sidebar.  This add the
missing error boundnary for those pages.
2021-03-01 15:03:25 +01:00
Hornwitser a3729504e5 Log error stack on bootstrap failure
Log the stack of the error caught during failure to load the web
interface.  This ensure the error is printed to the console instead of
"[fatal] undefined".
2021-03-01 15:03:25 +01:00
Hornwitser 4efd09de31 Expose computed tls_ca config
Provide the loaded file of the tls_ca config available to slave,
instance and ctl code.  This ensures they can easily use it when
connecting to the master server.
2021-03-01 15:03:25 +01:00
Hornwitser 6a29cd9715 Resolve webpack modules from web_ui
Remove the need for installing babel and the webpack loaders used in the
webpack.common.js file separately by resolving them from the web_ui
package.  This allows future changes to done to the modules used without
having to update them in each plugin that uses the web_ui common config.
2021-03-01 14:59:59 +01:00
Hornwitser 9869d92ead v2.0.0-alpha.4 2021-02-26 15:52:18 +01:00
Hornwitser a248a67d10 Merge pull request #360 from Hornwitser/edit-master-config
Remote master config management
2021-02-26 15:40:24 +01:00
Hornwitser bec30c9927 Add remote editing of master config
Add commands and web UI for editing the master configuration remotely.
2021-02-24 22:16:03 +01:00
Hornwitser 38b7d0f600 Fix web config trying to set the new prop value as field
Fix the BaseConfigTree treating the nex property name and value boxes as
fields to be applied to the config when pressing Apply.
2021-02-24 20:18:41 +01:00
Hornwitser 0366334e7c Generalize InstanceConfigTree
Pull out the instance specific logic from InstanceConfigTree and turn it
into a general BaseConfigTree component that can be used for master and
slave configs.
2021-02-24 20:17:29 +01:00
Hornwitser b538960592 Add ErrorBoundary to page content
Prevent the page from turning into a black screen when page rendering
fails by adding an error boundary to the display of the page content in
SiteLayout.jsx.
2021-02-24 19:48:43 +01:00
Hornwitser ac17b8497a Split InstanceViewPage into separate components
Pull out the components in InstanceViewPage.jsx into separate source
files.
2021-02-24 18:47:03 +01:00
Hornwitser 2c74a67738 Add location access control to config class
Add a per field access parameter to the config class which specifies
which of master, slave and control can access the given field.  A
location is added to the config class to specify which party owns it and
fields which do not have an access parameter are not present in the
config and will throw if attempted to be read from or writen to.

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

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

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

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

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

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

To close down the active connections they need to be tracked and closed
when the server is closed, add the HttpCloser class which encapsulates
the tracking and closure process behind a simple interface and use this
to close down the HTTP and HTTPS server on the master.
2020-12-28 07:05:04 +01:00
Hornwitser e8405cbf77 Fix message validation for request responses
Fix the validation loging for sending responses to requests not
functioning due to the message data property being passed incorrectly to
the validator.
2020-12-28 07:03:32 +01:00
Hornwitser 6eef4bd300 Make metrics gathering timeout configurable
When reducing the collection interval in Prometheus it may be necessary
to to reduce the metrics gathering timeout to ensure it's less than the
interval.  Add config options to adjust the timeouts used for metrics
gathering so that these can be set accordingly.
2020-12-28 07:03:32 +01:00
Hornwitser 699e3994df Add missing plugin error printing in slave.js
Special case PluginError printing during startup in slave.js in the same
way it's done in master.js.  This makes it so that the stack of the
error thrown by a plugin is printed instead of the stack trace of the
place it was wrapped into a PluginError.
2020-12-28 07:03:32 +01:00
Hornwitser b681ea7243 Add RateLimiter helper
Add helper class for implementing rate limited operations.
2020-12-28 07:03:32 +01:00
Hornwitser 29a3c26b44 Fix usage of logger in FactorioServer
Fix incorrect usage of the global logger, and typo'd use of the _logger
property in FactorioServer.
2020-12-28 07:03:32 +01:00
tovernaar123 35cb9f8698 Add bind_address option (#349)
Add option to set the IP address the master server binds its HTTP and
HTTPS ports to.
2020-12-28 03:40:28 +01:00
Daniel 6204c73ef2 Merge pull request #346 from Danielv123/github-actions
Added automated build and test infrastructure
2020-12-23 17:15:16 +01:00
Danielv123 95e1b3e349 Run actions for all branches 2020-12-23 14:26:56 +00:00
Danielv123 30f345be55 Added automated build and test infrastructure
Based on docker and github actions
Builds a docker image for each commit as well as the lua lib mod
Builds the master branch of subspace_storage for use with the tests
Tests are based on latest stable factorio branch
2020-12-23 14:13:02 +00:00
Arvids Godjuks 231505c6fe Merge pull request #345 from BlizzardBlue/patch-1
Fix typo in README.md
2020-12-22 12:08:21 +02:00
BlizzardBlue d87252d52f Fix typo in README.md 2020-12-22 12:07:45 +09:00
Hornwitser b3e518d570 v2.0.0-alpha.3 2020-12-14 07:11:34 +01:00
Hornwitser 3b13dfdcb5 Merge pull request #344 from Hornwitser/alpha-3
Alpha 3 preparations
2020-12-07 21:04:24 +01:00
Hornwitser 2e5333af29 Fix banlist sync being broken
Fix the logic for determining which entries in the banlist changed being
incorrect and causing the banlist sync to be broken.  Add unit tests for
the syncUserListsEventHandler to ensure it functions as exxpected.
2020-12-07 18:59:32 +01:00
Hornwitser 450e27e001 Add metric mapping slave and instance id to name
Add exported metrics that map instance id and slave id to their
respective names.  This allows getting the instance name of a metric by
doing a left join using query of the form 'metric * on(instance_id)
group_left(instance_name) clusterio_master_instance_mapping'.
2020-12-07 17:22:17 +01:00
Hornwitser 71164faa14 Fix docs and usage of Collector callback
Fix callback typo in Collector constructor jsdocs, add notice that
the collector is passed as the first argument, and use this first
arugument in the built-in collector implementations.
2020-12-07 17:15:25 +01:00
Hornwitser a89106f4a3 Add changelog entry for logging system 2020-12-07 17:12:29 +01:00
Hornwitser d768019e1b Merge pull request #343 from Hornwitser/factorio-1.1
Add support for Factorio 1.1
2020-12-07 17:05:49 +01:00
Hornwitser 30b7fd8767 Add support for Factorio 1.1
Add variant to clusterio_lib supporting 1.1 and update travis config to
use the 1.1 compatible version of Subspace Storage.
2020-12-06 22:48:47 +01:00
Hornwitser faae6ccf97 Rework mod build system to be multi-version capable
Add a variants array which can specify overriden properties of the
info.json for building multiple versions of the mod at the same time and
fix the additonal_files to allow specifying under what path the file is
put.

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

Resolve this issue by separating discovered instances with assigned
instances.  Upon startup discovered instances are put into the
discoveredInstanceInfos mapping on the Slave object and all of these are
sent to the master server.  The master server in turn sends assignment
requests for all the instances which are assigned to the slave.  These
are put into the instanceInfos mapping of the Slave object which can
then be relied up to include only assigned instances.
2020-11-17 04:34:49 +01:00
Hornwitser c78bf02ce4 Fix event forwarding to master
Fix trying to use non-existent method on Slave class for forwading
events to the master.  Also update request forward to be consistent with
event forwarding.
2020-11-17 04:22:08 +01:00
Hornwitser 2f7419cf3b Add max_concurrent_commands option
Add option to set the the maximum amount of commands transmitted in
parallel to the game.  Increasing this value allows a greater rate of
commands to be executed.
2020-11-17 04:22:07 +01:00
Hornwitser 10303f979d Add timeout to statistics_exporter metrics
When statistics_exporter handles getMetrics it may take a long time for
the result of command it sends to be returned if the instance's command
stream is overloaded, this in turn would cause the slave to miss its
window to reply on the getMetrics request and no metrics for the slave
would be available.  Ensure statistics_exporter does not use more than 5
seconds to respond by adding a timeout to the metrics gathering.
2020-11-17 04:19:05 +01:00
Hornwitser b742291131 Add timeout to slave metric request
When the slave metrics request takes more than 10 seconds the response
to the /metrics endpoint ends up being discarded by Prometheus which
defaults to a timeout of 10 seconds.  Fix by ignoring slaves that take
longer than 8 seconds to respond to the metrics request.
2020-11-17 04:19:05 +01:00
Hornwitser a06781ebc3 Merge pull request #338 from Hornwitser/assorted-improvements
Assorted improvements
2020-11-15 20:03:35 +01:00
Hornwitser f5e28f6815 Fix errors detected by linter 2020-11-12 04:35:22 +01:00
Hornwitser 770a6664d4 Add missing instance request forwarding
Add missing forwardRequestToInstance default handler for Instance
connections for handling request forwarding from one instance to
another.
2020-11-12 04:22:13 +01:00
Hornwitser f89e66e3cb Rename getMetrics request type
Remove statistics_exporter: prefix to the getMetrics request which was
left over from when this event was a part of statistics exporter.
2020-11-12 04:19:57 +01:00
Hornwitser 65a25e2510 Add more context to missing handler error
Amend the error message shown when a message handler is missing from a
plugin with the expected name of the handler and the name of the plugin
it's missing from.
2020-11-12 04:17:52 +01:00
Hornwitser 5b74432ee3 Improve web interface console performance
Calculate the coloring for instance console messages once instead of
every time the instance console is updated.
2020-11-12 04:07:08 +01:00
Hornwitser 49eb966315 Respond to unhandled requests
Give an error response back to unhandled requests, this fixes requests
hanging forever when the target does not support the given request.
2020-11-12 04:05:24 +01:00
Hornwitser 42e680f09d Fix typos in documentation
Fix typos spotted by T-A-R#8121 in the documentation.
2020-11-12 03:53:40 +01:00
Hornwitser 31c0925354 Fix error on dash in instance name
Fix stripPaths logic breaking when the instance name contains RegExp
meta characters due to a lack of escaping.
2020-11-10 22:13:07 +01:00
Hornwitser 4c9b91ab57 Lower default heartbeat interval
Use 45 seconds as the default heartbeat interval instead of 60.  Using
60 causes connections to drop when proxied through Nginx as the default
timeout there is also 60 seconds.
2020-11-09 20:19:22 +01:00
Hornwitser a92ae95ab1 Fix updating config breaking instance status
Fix broken assign instance code in slave sending of the wrong instance
status to the master server.
2020-11-09 18:36:03 +01:00
Hornwitser fc8edeba5f Reduce log spam on connection error
Print only the error message in the console when a connection error is
encountered on the link connector.  This reduces log spam on reconnect
for slaves.
2020-11-09 17:26:56 +01:00
Hornwitser db1f239a0f Add formatting of Script and Verbose levels
Detect and colour the Script and Verbose levels.  The format of the
source file for the Script level is slightly different and needs an
update to the output parsing.
2020-11-09 15:57:46 +01:00
Hornwitser c8c65d2e92 Add verbose logging option
Add option to pass the --verbose flag to the Factorio server.
2020-11-09 15:57:46 +01:00
Hornwitser 67654fdf85 Add stripping of instance paths in Factorio log
Add option defaulting to true to strip instance directory paths printed
into the Factorio log down to be relative to the instance.
2020-11-09 15:57:46 +01:00
Hornwitser 63a656cd61 Rename default Admin role to Cluster Admin
Clarify the purpose and distinquesh the Admin role a bit better from the
in-game admin status by renaming the role to Cluster Admin.
2020-11-09 15:57:46 +01:00
277 changed files with 28205 additions and 9360 deletions

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# No need to invalidate cache just because we try speeding up the build
.dockerignore
Dockerfile
docker-compose*
# Install dependencies during the build
node_modules
packages/**/node_modules
plugins/**/node_modules
# Download factorio during the build
factorio
factorio.tar.gz
# We build internal mods during build, mods from local test env should not be applied
sharedMods
sharedModsDisabled
# These files should never be required during the build
.git
.github
dist
temp
+1 -2
View File
@@ -1,2 +1 @@
packages/master/static
plugins/*/static
dist/
+105 -35
View File
@@ -1,12 +1,12 @@
"use strict";
module.exports = {
"plugins": [
"node",
],
"env": {
"node": true,
"commonjs": true,
"es6": true,
},
"parserOptions": {
"ecmaVersion": 11,
"es2022": true,
},
"overrides": [
@@ -18,10 +18,12 @@ module.exports = {
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
"files": ["packages/web_ui/src/**/*.jsx", "plugins/*/web/**/*.jsx"],
"files": ["packages/web_ui/src/**/*.jsx", "packages/lib/browser.js", "plugins/*/web/**/*.jsx"],
"env": {
"browser": true,
},
@@ -31,6 +33,44 @@ module.exports = {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
],
@@ -45,8 +85,7 @@ module.exports = {
"arrow-spacing": ["error", { "after": true, "before": true }],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
"callback-return": "off",
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
@@ -60,18 +99,18 @@ module.exports = {
"functions": "only-multiline",
},
],
"comma-spacing": "warn",
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": "warn",
"complexity": "error",
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
"curly": "warn",
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"dot-location": ["error", "property"],
"dot-notation": ["warn", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
@@ -81,10 +120,8 @@ module.exports = {
"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",
@@ -94,22 +131,22 @@ module.exports = {
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": "warn",
"keyword-spacing": "error",
"line-comment-position": "off",
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always"],
"max-classes-per-file": "off",
"max-depth": "warn",
"max-len": ["warn", { "code": 120 }],
"max-depth": "error",
"max-len": ["error", { "code": 120 }],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "off",
"multiline-comment-style": ["warn", "separate-lines"],
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": "error",
"new-parens": "error",
@@ -124,13 +161,13 @@ module.exports = {
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "off",
"no-console": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-constructor-return": "error",
"no-continue": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "warn",
"no-else-return": "error",
"no-empty-function": "off",
"no-eq-null": "error",
"no-eval": "error",
@@ -143,7 +180,7 @@ module.exports = {
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "warn",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-lone-blocks": "error",
@@ -160,7 +197,6 @@ module.exports = {
],
},
],
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -172,19 +208,14 @@ module.exports = {
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "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",
@@ -194,7 +225,6 @@ module.exports = {
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["yargs"] }],
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
"no-ternary": "off",
@@ -205,7 +235,7 @@ module.exports = {
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "warn",
"no-use-before-define": "error",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
@@ -213,8 +243,8 @@ module.exports = {
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "warn",
"no-void": "error",
"no-var": "error",
"no-void": ["error", { "allowAsStatement": true }],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
@@ -234,7 +264,7 @@ module.exports = {
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "warn",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-exponentiation-operator": "error",
@@ -246,7 +276,7 @@ module.exports = {
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", { "avoidEscape": true }],
"radix": ["error", "always"],
@@ -261,12 +291,12 @@ module.exports = {
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"space-before-blocks": "warn",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["warn", "always"],
"spaced-comment": ["error", "always"],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
@@ -274,10 +304,50 @@ module.exports = {
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", { "requireReturn": false }],
"vars-on-top": "warn",
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "error",
"node/handle-callback-err": "error",
"node/no-callback-literal": "error",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-extraneous-import": "error",
"node/no-extraneous-require": "error",
"node/no-missing-import": "error",
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unsupported-features/es-builtins": "error",
"node/no-unsupported-features/es-syntax": "error",
"node/no-unsupported-features/node-builtins": "error",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": "error",
},
};
+1
View File
@@ -0,0 +1 @@
package-lock.json -diff
+54
View File
@@ -0,0 +1,54 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
with:
version: 6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- run: wget -q -O temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- name: Run tests
if: ${{ matrix.node-version != '12.x' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '12.x' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '12.x' }}
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
with:
version: 6
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm run lint
+3 -8
View File
@@ -1,7 +1,6 @@
# Logs
logs
*.log
npm-debug.log*
*debug.log*
# Runtime data
pids
@@ -39,16 +38,12 @@ Factorio*
/config-master.json
/config-control.json
/config-slave.json
package-lock.json
pnpm-lock.yaml
mod_cache.json
static/export
/static/
saves
/temp/
# Web build
/packages/master/static
/plugins/*/static
# IDE
.idea/
.vs/
-15
View File
@@ -1,15 +0,0 @@
language: node_js
node_js:
- 10
before_install:
- curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
- tar -xf factorio.tar.gz
- npm install -g codecov
script:
- npx lerna bootstrap --no-ci
- 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
- npx lerna run build
- npm run-script ci-cover
- codecov -f coverage/lcov.info
- npm run-script lint
+119 -169
View File
@@ -1,243 +1,193 @@
Changelog
=========
# Changelog
Version 2.0.0
-------------
## Version 2.0.0
### Major Features
- Replaced Hotpatch with save patching. Removed the Hotpatch scenario and
the depency on it for getting code into the game. Added a save patcher than
runs before starting up Factorio that patches in lua modules based on the
event_loader lib into the savegame. Regular freeplay games can now be
used with Clusterio and will be compatible without having to convert them
to Hotpatch.
- Daemonized slaves. Slaves now have the ability to run multiple Factorio
instances, starting and stopping them individually. To manage this the
local command line interface has been replaced with a remote interface on
the master server that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between slaves and the master from scratch. The
new system is based on a WebSocket connection between the slaves and the
master server and provides efficient validated bi-directonal communication.
- Rewritten the plugin system from scratch. Plugins now inherit from
a base class and use the same WebSocket connection Clusterio uses to
communicate.
- New configuration system with support for initializing configs when needed,
modifying config entries using built-in commands, and updating instance
configuration remotely.
- Packaged and uploaded to npm again, greatly simplifying distribution and
installation of both Clusterio and plugins.
- Replaced Hotpatch with save patching.
Removed the Hotpatch scenario and the depency on it for getting code into the game.
Added a save patcher than runs before starting up Factorio that patches in lua modules based on the event_loader lib into the savegame.
Regular freeplay games can now be used with Clusterio and will be compatible without having to convert them to Hotpatch.
- Daemonized slaves.
Slaves now have the ability to run multiple Factorio instances, starting and stopping them individually.
To manage this the local command line interface has been replaced with a remote interface on the master server that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between slaves and the master from scratch.
The new system is based on a WebSocket connection between the slaves and the master server and provides efficient validated bi-directonal communication.
- Rewritten the plugin system from scratch.
Plugins now inherit from a base class and use the same WebSocket connection Clusterio uses to communicate.
- New configuration system with support for initializing configs when needed, modifying config entries using built-in commands, and updating instance configuration remotely.
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
- Added centralized logging for the cluster.
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
### Features
- Added export of pollution statitics.
- Reconnection logic that esures no data is dropped talking to the master
server provided the session can be resumed.
- Added support for having multiple Factorio installs and selecting which
version to use on a per instance basis.
- Added config to specify which path the master interface is accessed under,
allowing it to be proxied behind web-server.
- Reconnection logic that esures no data is dropped talking to the master server provided the session can be resumed.
- Added support for having multiple Factorio installs and selecting which version to use on a per instance basis.
- Added config to specify which path the master interface is accessed under, allowing it to be proxied behind web-server.
- Added WebSocket usage statistics.
- Added commands sent statistic.
- Added Factorio server CPU and memory usage statistic.
- Added Factorio autosave size statistic.
- Added support for extracting locale and item icons from Factorio and mods.
- Added integrated user management for controlling access and storing per
player data.
- Added synchronization of in-game adminlist, banlist and whitelist to
Clusterio core. Previously this was handled by the playerManager plugin.
- Added integrated user management for controlling access and storing per player data.
- Added synchronization of in-game adminlist, banlist and whitelist to Clusterio core.
Previously this was handled by the playerManager plugin.
- Added stripping of long paths in the Factorio server log.
- Added option to configure maximum number of commands to send in parallel.
- Added option to auto start instances on slave startup.
- Added metric mapping of slave and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
- Added option to configure timeout for exported Prometheus metrics.
- Added option to set address to bind HTTP(S) port to.
- Added an overview of the installed plugins to the web interface.
- Added clusterioctl command to list plugins on the master server.
- Added Player Auth plugin for logging in to the web interface by proving the ability to log in to a server as a given user.
- Added Node.js based installer to simplify setting up a cluster.
### Changes
- Fixed sslCert and sslPrivKey entries being ignored in the config.
- Changed ssl key creation to be done at startup instead of on npm install.
- Added error handling during master startup.
- Factorio game and rcon port now defaults to a random port above 49151.
- Removed unimplemented mods update command.
- Fixed rcon password being generated with Math.random().
- Added plugins directory to the views path. This makes it possible for
plugins to render their own ejs views or pages in their own folders by
using paths of the format "pluginName/path/to/page-or-view".
- Replaced the per instance copy of the shared Factorio mods with
symlinks. On Windows hard links are used instead due to the
privileges requirements of symlinks.
- Changed the per instance scenario folder to be linked instead of
copied on instance creation.
- Instance id is no longer derived from the rcon password, instead it's
generated upon instance creation and stored in the instance config.
- The game port, RCON port and RCON password instance config entries are are
now null by default, indicating that a random one will be generated every
time the instance is started.
- Removed the broken client download command as this would download
pre-releases.
- Moved the item database and HTTP definitions for /api/place, /api/remove,
/api/inventory, /api/inventoryAsObject and the web interface view for the
storage page into the clusterioMod plugin. If you disable this plugin
then these things will not be available.
- Added plugins directory to the views path.
This makes it possible for plugins to render their own ejs views or pages in their own folders by using paths of the format "pluginName/path/to/page-or-view".
- Replaced the per instance copy of the shared Factorio mods with symlinks.
On Windows hard links are used instead due to the privileges requirements of symlinks.
- Changed the per instance scenario folder to be linked instead of copied on instance creation.
- Instance id is no longer derived from the rcon password, instead it's generated upon instance creation and stored in the instance config.
- The game port, RCON port and RCON password instance config entries are are now null by default, indicating that a random one will be generated every time the instance is started.
- Removed the broken client download command as this would download pre-releases.
- Moved the item database and HTTP definitions for /api/place, /api/remove, /api/inventory, /api/inventoryAsObject and the web interface view for the storage page into the clusterioMod plugin.
If you disable this plugin then these things will not be available.
- Removed undocumented --port and --rcon-port arguments to client.
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable
handling in client.
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable handling in client.
- Removed redundant call to /api/slaves in globalChat plugin.
- Removed broken serverManager plugin.
- Removed factorio_version from config. The version installed is auto
detected and used instead.
- Removed factorio_version from config.
The version installed is auto detected and used instead.
- Removed the playerManager specific command CLI tools/delete_player.js.
- Creating an instance, assigning it to a slave, creating a save for an
instance and starting an instance is now four separate commands.
- Creating an instance, assigning it to a slave, creating a save for an instance and starting an instance is now four separate commands.
- Removed oddball limits to slaves.json size.
- Moved slave specific and instance specific configuration into their own
configuration files.
- Moved slave specific and instance specific configuration into their own configuration files.
- Removed unused binary option from plugin config.
- Removed info and shout command from globalChat plugin
- Removed mirrorAllChat and enableCrossServerShout configuration options for
globalChat plugin.
- Removed UPSdisplay plugin. UPS statistics is exported by the statistics
exporter plugin.
- Removed info and shout command from globalChat plugin - Removed mirrorAllChat and enableCrossServerShout configuration options for globalChat plugin.
- Removed UPSdisplay plugin.
UPS statistics is exported by the statistics exporter plugin.
- Master server now defaults to hosting on https on port 8443.
- Renamed client to slave.
- Removed rotation of factorio-current.log files.
### Breaking Changes
- Removed lib/authenticate. Breaks playerManager.
- Removed lib/authenticate.
Breaks playerManager.
- The masterIP and masterPort config entries has been merged into masterURL.
Breaks discordChat, playerManager, serverSelect, trainTeleports and
clusterioModel.
- Instance names can no longer be invalid Windows file names.
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
- Removed config management from the command line and the server manager.
- Moved ejs templates into views folder and changed their extension to
.ejs. Breaks playerManager.
- Moved ejs templates into views folder and changed their extension to .ejs.
Breaks playerManager.
- Mods are no longer copied from the per instance instanceMods directory.
If you need per instance mods you can now place them directly in the mods
directory inside the instance folder.
If you need per instance mods you can now place them directly in the mods directory inside the instance folder.
- Hotpatch scenarios and code loading is no longer compatible with Clusterio.
Breaks playerManager, serverSelect, and tranTeleports.
- Removed getLua and getCommand from lib/clusterTools. If you need to run
more than the most trivial of code in commands use the save patcher and
add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface. Breaks
the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface. Breaks
playerManager and external tools sending commands.
- Removed getLua and getCommand from lib/clusterTools.
If you need to run more than the most trivial of code in commands use the save patcher and add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface.
Breaks the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface.
Breaks playerManager and external tools sending commands.
- Removed broken serverManager plugin.
- Removed fields info, time, rconPort, rconPassword, serverPort, unique,
mods, instanceName, playerCount, mac, and meta from the slaves in the
slave database.
- Removed getInstanceName and lib/clusterTools. Breaks playerManager,
and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints. Breaks web
interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints. Breaks
researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake. Breaks
playerManager, trainTeleports, serverSelect, and discordChat
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the slaves in the slave database.
- Removed getInstanceName and lib/clusterTools.
Breaks playerManager, and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints.
Breaks web interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints.
Breaks researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake.
Breaks playerManager, trainTeleports, serverSelect, and discordChat
- Changed the format of database/slaves.json.
- Removed the output file subscription system. Breaks inventoryImports,
playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins. The onOutput
hook provides parsed output instead.
- Removed the output file subscription system.
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins.
The onOutput hook provides parsed output instead.
- Removed the onLoadFinish hook from master plugins.
- Moved plugins from the sharedPlugins directory to plugins directory.
- Implemented a new plugin system that replaces the old. Breaks all plugins.
- Implemented a new plugin system that replaces the old.
Breaks all plugins.
- Removed express metric http_request_duration_milliseconds metric.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total,
socket_io_disconnect_total, socket_io_events_received_total,
socket_io_events_sent_total, socket_io_recieve_bytes, and
socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added
clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to
clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added
clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total, socket_io_disconnect_total, socket_io_events_received_total, socket_io_events_sent_total, socket_io_recieve_bytes, and socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Renamed clusterio_endpoint_hit_gauge to clusterio_http_enpoint_hits_total
- Renamed clusterio_statistics_gauge to
clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_statistics_gauge to clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_nn_dole_gauge to clusterio_subspace_storage_nn_dole_gauge.
- Renamed clusterio_dole_factor_gauge to
clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_dole_factor_gauge to clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_import_gauge to clusterio_subspace_storage_import_total.
- Renamed clusterio_export_gauge to clusterio_subspace_storage_export_total.
- Renamed clusterio_master_inventory_gauge to
clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers,
disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and
disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option. The RCON is instead limited to 5
concurrent commands.
- Removed allowRemoteCommandExecution config option. Remote commands are
always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line
arguments from the master server.
- Implemented a new config system that replaces the old. Breaks all plugins.
- Renamed clusterio_master_inventory_gauge to clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers, disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option.
The RCON is instead limited to 5 concurrent commands.
- Removed allowRemoteCommandExecution config option.
Remote commands are always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server.
- Implemented a new config system that replaces the old.
Breaks all plugins.
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
- Renamed clusterioMod plugin to subspace_storage.
- Master no longer creates secret-api-token.txt.
- Removed automatic creation of self-signed TLS certificate.
- Node.js versions below 12 are no longer supported.
Version 1.2.4
-------------
## Version 1.2.4
- Removed broken remote combinator signaling.
- Fixed research sync endlessly updating already researched technologies
- Removed obsolete item/fluid statistics from clusterioMod
- Removed per mod upload logging when config.uploadModsToMaster disabled
- Fixed bcrypt failing to install on windows due to the new version not
having Windows binaries.
- Fixed bcrypt failing to install on windows due to the new version not having Windows binaries.
Version 1.2.3
-------------
## Version 1.2.3
- Disabled uploading mods to the master server by default as this is mostly
just a waste of bandwidth with the mod portal being integrated into the
game now.
- Fixed researchSync breaking with heavily modded games due to the tech tree
exceeding the default 100kb limit on JSON payloads.
- Fixed the command inventoryImport sends referencing player instead of
character, and trying to count the non-existant quick bar slots.
- Fixed inventoryImport never receiving the script output after the default
mode for reading script output changed to tail mode.
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting
out of sync when plugins update their scenario mod code. To fix existing
games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua
over the existing mod-tools.lua in the save's hotpatch folder.
- Disabled uploading mods to the master server by default as this is mostly just a waste of bandwidth with the mod portal being integrated into the game now.
- Fixed researchSync breaking with heavily modded games due to the tech tree exceeding the default 100kb limit on JSON payloads.
- Fixed the command inventoryImport sends referencing player instead of character, and trying to count the non-existant quick bar slots.
- Fixed inventoryImport never receiving the script output after the default mode for reading script output changed to tail mode.
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting out of sync when plugins update their scenario mod code.
To fix existing games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua over the existing mod-tools.lua in the save's hotpatch folder.
- Updated the Windows install instructions.
Version 1.2.2
-------------
## Version 1.2.2
- Fixed possible crash with modded technologies named the same as a built-in
Object prototype property in researchSync.
- Fixed progress of a current infinite tech carrying over to the next one
when researching it and another node completes it in researchSync.
- Fixed progress of a previous infinite tech from another node being applied
to the current one in researchSync.
- Fixed crash in researchSync when modded technologies are present only on some
nodes.
- Fixed install failing due to bcrypt version less than 3 not being supported
on node v10.
- Reordered install instructions to avoid problem with npm creating files owned
by root in the home directory.
- Swapped curl out with wget in the install instructions as the latter comes
pre-installed on Ubuntu.
- Fixed possible crash with modded technologies named the same as a built-in Object prototype property in researchSync.
- Fixed progress of a current infinite tech carrying over to the next one when researching it and another node completes it in researchSync.
- Fixed progress of a previous infinite tech from another node being applied to the current one in researchSync.
- Fixed crash in researchSync when modded technologies are present only on some nodes.
- Fixed install failing due to bcrypt version less than 3 not being supported on node v10.
- Reordered install instructions to avoid problem with npm creating files owned by root in the home directory.
- Swapped curl out with wget in the install instructions as the latter comes pre-installed on Ubuntu.
### Breaking Changes
- Node.js versions below 10 are no longer supported.
Version 1.2.1
-------------
## Version 1.2.1
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking
([#229][#229].)
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C
on Linux ([#217][#217].)
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking ([#229][#229]).
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C on Linux ([#217][#217]).
- Fixed package.json incorrectly reporting the license as ISC.
[#217]: https://github.com/clusterio/factorioClusterio/issues/217
[#229]: https://github.com/clusterio/factorioClusterio/issues/229
[#217]: https://github.com/clusterio/clusterio/issues/217
[#229]: https://github.com/clusterio/clusterio/issues/229
### Breaking Changes
+52 -17
View File
@@ -1,23 +1,58 @@
# Warning: This is currently broken as a result of
# changes done in the 2.0 refactoring.
FROM node:12
RUN apt-get update && apt install git curl tar -y
RUN mkdir factorioClusterio
FROM node:12 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/subspace_storage.git
WORKDIR /subspace_storage
RUN npm install \
&& node build
RUN git clone -b master https://github.com/clusterio/factorioClusterio.git && cd factorioClusterio && npm install --only=production
RUN cd factorioClusterio && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz
FROM node:12 as clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
WORKDIR /clusterio
RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
# Copy project files into the container
COPY . .
WORKDIR factorioClusterio
RUN mkdir instances sharedMods
RUN cp config.json.dist config.json
RUN pnpm install
RUN node client manage shared mods add clusterio
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
# If you want a different set of plugins, consider using this as the base image for your own.
#RUN pnpm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
LABEL maintainer "Sir3lit@gmail.com"
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
EXPOSE 8080 34167
VOLUME /factorioClusterio/instances
VOLUME /factorioClusterio/sharedMods
VOLUME /factorioClusterio/sharedPlugins
# Build Lua library mod
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
&& mv dist/* sharedMods/ \
&& mkdir temp \
&& mkdir temp/test \
&& cp sharedMods/ temp/test/ -r \
&& ls sharedMods
CMD MODE="$MODE" node $MODE start $INSTANCE
# Remove node_modules
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
FROM frolvlad/alpine-glibc AS clusterio_final
RUN apk add --update bash nodejs npm
COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN pnpm install --production
LABEL maintainer "danielv@danielv.no"
FROM frolvlad/alpine-glibc AS clusterio_testing
RUN apk add --update bash nodejs npm
COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN pnpm install
RUN pnpm install chalk semver
+156 -233
View File
@@ -4,27 +4,22 @@
<br/>
<br/>
# factorioClusterio
# Clusterio
Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy
restructuring and refactoring. Expect plugins and existing installations to frequently break when
using this branch. If you don't want to be an alpha tester for 2.0 please use the stable
[1.2.x branch][1.2.x] or
[latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
If you are starting a new cluster it's highly recommended to use the 2.0 alpha.
Installation instructions below are for the unstable master branch. Go to the page for
the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
[1.2.x]: https://github.com/clusterio/factorioClusterio/tree/1.2.x
### Ways to support me/the project:
* Contribute with code/documentation. See [Contributing](docs/contributing.md) for how to make pull
requests. Always nice to move the project forward.
* Contribute with code/documentation.
See [Contributing](docs/contributing.md) for how to make pull requests.
Always nice to move the project forward.
* Support me monetarily on [patreon](https://www.patreon.com/danielv123) or paypal: danielv@live.no
@@ -33,158 +28,146 @@ the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
* [Introduction](#introduction)
* [Features](#features)
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [Installing Plugins](#installing-plugins)
* [Installation](#installation)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Hosting Behind Proxy](#hosting-behind-proxy)
* [Setting up an admin account](#setting-up-an-admin-account)
* [Managing Factorio mods](#managing-factorio-mods)
* [Setting up shared storage](#setting-up-shared-storage)
* [Running Clusterio](#running-clusterio)
* [Master Server](#master-server)
* [Slaves](#slaves)
* [Instances](#instances)
* [Setting up remote slaves](#setting-up-remote-slaves)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Common problems](#Common-problems)
## Introduction
Clusterio is a clustered Factorio server manager that provides the
tooling for implementing cross server interactions in Factorio. It
was previously best known for implementing cross server transfer and
cloud storage of items via teleporter chests. But this functionality
has been pulled out of Clusterio into its own plugin for Clusterio named
[Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
Clusterio is a clustered Factorio server manager that provides the tooling for implementing cross server interactions in Factorio.
It was previously best known for implementing cross server transfer and cloud storage of items via teleporter chests.
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/subspace_storage).
By itself Clusterio doesn't change the gameplay in any way, you could
even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see
the [Plugins section](#plugins) for ready made plugins you can install
into a Clusterio cluster.
By itself Clusterio doesn't change the gameplay in any way, you could even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready made plugins you can install into a Clusterio cluster.
## Features
- Clustered Factorio server management allowing you manage the running
of Factorio servers across a fleet of physical servers from both a web
interface and a command line interface.
- Clustered Factorio server management allowing you manage the running of Factorio servers across a fleet of physical servers from both a web interface and a command line interface.
- User list management for synchronizing in-game admins, whitelisted
users, and bans to all the servers in the cluster.
- User list management for synchronizing in-game admins, whitelisted users, and bans to all the servers in the cluster.
- Integrated support for exporting statistics for the whole cluster to
Prometheus via a single metrics endpoint.
- Integrated support for exporting statistics for the whole cluster to Prometheus via a single metrics endpoint.
- Extensive plugin support for adding your own cross server features to
Factorio using Clusterio's communication backbone.
- Extensive plugin support for adding your own cross server features to Factorio using Clusterio's communication backbone.
## Plugins
The heart of Clusterio is its plugin syste. Plugins add functionality
to Factorio servers, Clusterio itself or both. These are the plugins
supported and maintained by the Clusterio developers:
The heart of Clusterio is its plugin system.
Plugins add functionality to Factorio servers, Clusterio itself or both.
These are the plugins supported and maintained by the Clusterio developers:
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat
between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize
research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect
in-game statistics from all the servers and makes it available to the
Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/factorioClusterioMod):
Provide shared storage that can transport items between servers via
teleport chests.
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
Want to make your own plugin? Check out the documentation on [Writing
Plugins](/docs/writing-plugins.md) for where to start.
There's also plugins developed and maintained by the community:
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge) (@hornwitser/discord_bridge): Bridges chat between instances and Discord.
By Hornwitser.
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
Originally by Godmave, ported to 2.0 by Hornwitser.
Want to make your own plugin?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
## Ubuntu setup
## Installation
**Warning**: These instructions are for the unstable master version and is not
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
Clusterio runs on Node.js v12 and up, it's distributed via npm and comes with a guided install script.
If you alread have a recent Node.js installed you can set it up in a new directory with.
Clusterio runs on Node.js v12 and up, and v10.16.0+. Node.js itself is not
supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
npm init "@clusterio"
Master and all slaves:
Otherwise see below for OS specific instructions.
wget -qO - https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt install -y nodejs
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
downloads and installs nodejs, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
### Ubuntu setup
1. Install Node.js v12 or higher.
For 20.04 LTS or below the version of Node.js provided by the Ubuntu repos are too old and you will have to use the nodesource PPA, otherwise you may skip the first line.
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt install nodejs
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
mkdir clusterio
cd clusterio
npm init "@clusterio"
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
**Ubuntu with Docker**
The Docker support for Clusterio is curently broken. If you need it
open an issue about it.
The Docker support for Clusterio is currently broken.
If you need it open an issue about it.
<!--
Clusterio has *very* limited support for using docker.
sudo docker build -t clusterio --no-cache --force-rm factorioClusterio
sudo docker build -t clusterio --no-cache --force-rm clusterio
sudo docker run --name master -e MODE=master -p 1234:8080 -d -it --restart=unless-stopped danielvestol/clusterio
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/factorioClusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/clusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
The -v flag is used to specify the instance directory. Your instances (save files etc) will be stored there.
The -v flag is used to specify the instance directory.
Your instances (save files etc) will be stored there.
-->
## Windows setup
### Windows setup
**Warning**: These instructions are for the unstable master version and is not
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
1. Download and install the latest LTS release from http://nodejs.org.
**Requirements**
2. Create a new empty directory for the installation and navigate into it.
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
up, and v10.16.0+.
npm init "@clusterio"
**Master**
Make sure to note down the admin authentication token it provides at the end as you will need it later.
1. Open PowerShell or Command prompt in the directory you want to install to and run the following commands.
3. If you chose to use local factorio directory for the Factorio installation then download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
npm init "@clusterio"
2. Obtain Factorio by either of these two methods:
Make sure to note down the admin authentication token it provides at the end as you will need it later.
- Via the stand alone version on from their website
1. Create a new folder named "factorio" in the the factorioClusterio
folder.
2. Download the MS Windows (64-bit zip package) from
https://www.factorio.com/download .
3. Open the zip file and drag the folder called "Factorio_x.y.z" into
the factorio folder created in step 1.
- Via steam installation
1. Create a new folder named "factorio" in the the factorioClusterio
folder.
2. Locate the game files by right clicking the game in steam,
selecting properties, then Local Files, then Browse local files.
3. Go to the parent folder of the folder that Steam opened and copy
the Factorio folder into the factorio folder created in step 1.
3. If you chose to use local factorio directory for the Factorio installation you will need to obtain and copy a mac version of Factorio and unpack it to to the `factorio` folder in your clusterio installation folder.
## Installing Plugins
### Installing Plugins
Installing plugins to make them work with Clusterio consists of two
steps. First install the package via npm, for example
For well known plugins you can select them during installation and no further steps are necessary to make them work.
Installing plugins that are not offered by the installer consists of two steps.
First install the package the plugin is provided by via npm, for example
npm install @clusterio/plugin-subspace_storage
@@ -192,166 +175,102 @@ Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared
folder setup is loaded by master, slave and ctl. If you have slaves or
ctl installed on separate computers (or directories) then you need to
repeat the plugin install process for all of them. The clusteriomaster,
clusterioslave and clusterioctl commands has the plugin sub-command
so you do not need to install clusteriomaster to add plugins.
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by master, slave and ctl.
If you have slaves or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins, instead use the clusterio command you have available.
For development purposes the `plugin add` command supports adding
plugins by the absolute path to them, or a relative path that must start
with either . or .. (which will then be resolved to an absolute path).
For development purposes the `plugin add` command supports adding plugins by the absolute path to them, or a relative path that must start with either . or .. (which will then be resolved to an absolute path).
## Configure Master Server
By default the master server will create a self signed TLS certificate
and listen on HTTPS on port 8443. You can change the port used with the
command
By default the master server will listen for HTTP on port 8080.
You can change the port used with the command
npx clusteriomaster config set master.https_port 1234
npx clusteriomaster config set master.http_port 1234
When changing the port you will also need to change the address slaves connect with.
For the standalone installation mode you can use
npx clusterioslave config set slave.master_url http://localhost:1234/
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriomaster config set master.external_address https://192.0.2.4:8443/
npx clusteriomaster config set master.external_address http://203.0.113.4:1234/
Change the url to reflect the IP, protocol, and port the master server
is accessible under, dns names are also supported. If proxyed under a
sub-path include the sub path as well and make sure the URL ends with a
slash.
Change the url to reflect the IP, protocol, and port the master server is accessible under, dns names are also supported.
If you're planning on making the master server accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
You can list the config of the master server with the `npx
clusteriomaster config list` command. See the [readme for
@clusterio/master](/packages/master/README.md) for more
information.
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
## Managing Factorio mods
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.
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a slave installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that slave if the file doesn't already exist in the `mods` folder for that instance.
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
npx clusteriomaster config set master.https_port # unsets https_port
npx clusteriomaster config set master.http_port 8080
For managing the mod settings the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
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.
Better remote management of mods is a planned feature.
#### Apache Config
## Setting up shared storage
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.
The chests teleporting items to and from the shared storage on the master server has been moved into the Subspace Storage mod and plugin.
To get this to work you will need to install the Subspace Storage plugin and copy the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) into the `sharedMods` folder on each slave installation.
```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.
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
## Running Clusterio
After following the installation and master configuration instructions
you can use the following commands to run Clusterio.
After completing the installation start up the master server and at least one slave separately.
The installer provides the `run-master` and `run-slave` scripts to make this simple.
Once the master process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a slave and then click start.
### Running via Systemd
The install script creates systemd service scripts for clusteriomaster and clusterioslave (if applicable) to start up as the user than ran the installer.
If you have copied these files over to `/etc/systemd/system/` then you can startup clusterio as a background service using:
#### To start as a service run:
sudo systemctl start clusteriomaster # for the master server
sudo systemctl start clusterioslave # for each server hosting a slave
#### To automatically get it to start on boot:
sudo systemctl enable clusteriomaster # for the master server
sudo systemctl enable clusterioslave # for each server hosting a slave
### Master Server
## Setting up remote slaves
It's necessary to run the master server in order for anything to work.
Once you've completed the setup run the following command to start it
up:
npx clusteriomaster run
Run the installer as described in the installation section and choose "Slave only" as the operating mode to install, it'll ask for a master URL and an authentication token.
The URL is the same as what is needed to connect to the web interface, and the athentication token can be generated on the Slaves page in the web interface.
Once you start up the slave it should show up in the Slaves list and be available for assigning and running instances on.
### Slaves
## Setting up clusterioctl
Slaves connect to the master server and are managed remotely from the
master server. In order for slaves to connect to the master server they
need a valid authentication token, you can create a slave config with
a valid token with the following command.
There's a command line interface available for Clusterio which is installed separately with the same installer as for the master and slave.
Run the installer as described in the installation section and choose "Ctl only" as the operating mode to install, you can do this in the same directory as you have installed other clusterio component(s) to.
The installer will ask for a master URL and an authentication token, these are the same as you would use to connect to the web interface.
If you want to use a different user for the command line interface you can generate an authentication token for an existing user with
npx clusterioctl slave create-config --name Local --generate-token
This will write a new `config-slave.json` file in the current directory
(you can change the location with the `--output` option) with the name,
token and url to connect to the master server with. If you are making
the config for a remote slave you will need to have set the
`master.external_address` option to the URL the master server can be
reached on.
You can list the config of a slave on the slave itself with the `npx
clusterioslave config list` command. See the [readme for
@clusterio/slave](/packages/slave/README.md) for more information.
Once the config is set up run the slave with
npx clusterioslave run
### Instances
Instances are created, managed and started from the master server. For
now the only interface available is the `clusterioctl` command line tool
included in Clusterio. You can run this tool from any slave, or the
master server without having to set up a config, if you want to manage
the cluster from somewhere else you will need to set the
`control.master_url` and `control.master_token` options with the `node
clusterioctl control-config set` command:
The basic operations to start a new instance is the following
npx clusterioctl instance create "My instance"
npx clusterioctl instance assign "My instance" "Local"
npx clusterioctl instance start "My instance"
The first line creates the instance configuration on the master server.
The second assigns the instance to a slave which creates the instance
directory and files needed to run the instance on the given slave. The
third line starts the instance, which creates a new save if there are no
save games present.
There are many more commands available with clusterioctl. See the
[Managing a Cluster](/docs/managing-a-cluster.md) document or
`npx clusterioctl --help` for a full list of them.
npx clusteriomaster bootstrap generate-user-token <username>
## Common problems
### EACCESS [...] LISTEN 443
Some systems don't let non root processes listen to ports below 1000. Either run with `sudo` or change config.json to use higher port numbers.
Some systems don't let non root processes listen to ports below 1000.
Either run with `sudo` or change config.json to use higher port numbers.
According to [this link](https://askubuntu.com/questions/839520/open-port-443-for-a-node-web-app) if you manually installed node.js following the above instructions, you may need to run the following command to fix this issue:
@@ -359,7 +278,11 @@ According to [this link](https://askubuntu.com/questions/839520/open-port-443-fo
### Portforwarding doesn't work on the master server when running under WSL
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip. This is also true when you correctly port-forwarded the correct ports. Even when routing this server through nginx in WSL, the issue persists. Then, on a hunch, I tried to run nginx from windows itself and found that this DID work. It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip.
This is also true when you correctly port-forwarded the correct ports.
Even when routing this server through nginx in WSL, the issue persists.
Then, on a hunch, I tried to run nginx from windows itself and found that this DID work.
It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
TLDR: the tested fix is:
+7
View File
@@ -0,0 +1,7 @@
version: '3'
services:
unit_test:
image: clusterio
build: .
command: npm run test
+9
View File
@@ -0,0 +1,9 @@
version: '3'
services:
master:
build: .
command: npx clusteriomaster run
slave:
build: .
command: npx clusterioslave run
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1000px" height="840px" viewBox="-0.5 -0.5 1000 840" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="550" width="320" height="250" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 547px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="547" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="780" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 300 750 L 300 730 L 440 730 L 440 140 L 376.37 140" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 371.12 140 L 378.12 136.5 L 376.37 140 L 378.12 143.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 723px; margin-left: 346px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="346" y="726" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="520" y="490" width="440" height="310" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 438px; height: 1px; padding-top: 487px; margin-left: 522px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="522" y="487" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><rect x="40" y="210" width="380" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 378px; height: 1px; padding-top: 207px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connecting</div></div></div></foreignObject><text x="42" y="207" fill="#000000" font-family="Helvetica" font-size="12px">connecting</text></switch></g><path d="M 120 150 L 120 223.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 228.88 L 116.5 221.88 L 120 223.63 L 123.5 221.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 191px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; coLine truncated

After

Width:  |  Height:  |  Size: 78 KiB

+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="919px" height="620px" viewBox="-0.5 -0.5 919 620" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="260" width="320" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 257px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="257" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="700" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 280 390 L 280 370 L 380 370 L 380 130 L 346.37 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 341.12 130 L 348.12 126.5 L 346.37 130 L 348.12 133.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 360px; margin-left: 291px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="291" y="363" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="420" y="190" width="160" height="160" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 158px; height: 1px; padding-top: 187px; margin-left: 422px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="422" y="187" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><path d="M 120 140 L 120 273.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 278.88 L 116.5 271.88 L 120 273.63 L 123.5 271.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 194px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">ready()</div></div></div></foreignObject><text x="151" y="197" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">ready()</text></switch></g><rect x="40" y="80" width="320" height="80" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 77px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #00Line truncated

After

Width:  |  Height:  |  Size: 45 KiB

-20
View File
@@ -1,20 +0,0 @@
digraph "Client Connection Flow" {
node [shape=box];
graph [newrank=true];
start [label="Start"];
wait_hello [label="Wait for hello"];
new_session [label="New session"];
wait_ready [label="Wait for ready"];
connected [label="Connected"];
abort [label="Abort"];
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile connecting" style="dotted"];
lost_connection -> start [label="Wait random delay"];
start -> wait_hello [label="Connect to /api/socket"];
wait_hello -> new_session [label="Receive hello"];
new_session -> wait_ready [label="Send register_*"];
wait_ready -> connected [label="Receive ready"];
wait_ready -> abort [label="Connection closed with 4003"];
}
+321
View File
@@ -0,0 +1,321 @@
<mxfile host="Electron" modified="2021-08-30T18:05:06.237Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.9.6 Chrome/89.0.4389.128 Electron/12.0.16 Safari/537.36" etag="uzcLt7Oh4qtDsMY9WSdT" compressed="false" version="14.9.6" type="device">
<diagram id="GSbHU_PhJe-l3NQpc0X3" name="Connection Flow">
<mxGraphModel dx="710" dy="734" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="760" pageHeight="630" background="#EEF3FA" math="0" shadow="0">
<root>
<mxCell id="GTJXd52-XhRZD3rAr2nm-0" />
<mxCell id="GTJXd52-XhRZD3rAr2nm-1" parent="GTJXd52-XhRZD3rAr2nm-0" />
<mxCell id="2r3fOvGVNr9_K5ZBoRis-18" value="Client" style="text;html=1;align=left;verticalAlign=middle;resizable=1;points=[];autosize=0;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
<mxGeometry x="440" y="80" width="145" height="20" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-0">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="260" y="320.02" />
<mxPoint x="260" y="320.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-5" value="receive&lt;br&gt;resume" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-1">
<mxGeometry x="0.086" relative="1" as="geometry">
<mxPoint x="-20" y="-6" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-2">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="180" y="320.02" />
<mxPoint x="180" y="320.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-26" value="receive&lt;br&gt;register" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-3">
<mxGeometry x="-0.2268" relative="1" as="geometry">
<mxPoint x="-20" y="2" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-13">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="40" y="285.02" />
<mxPoint x="40" y="580.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-27" value="bad handshake&lt;br&gt;timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-14">
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
<mxPoint x="28" y="17" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="2r3fOvGVNr9_K5ZBoRis-28" value="Wait for handshake" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
<mxGeometry x="160" y="270.02000000000004" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="HutjLCx0ygpoBEJLSh69-0" value="Server" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
<mxGeometry x="40" y="80" width="50" height="20" as="geometry" />
</mxCell>
<mxCell id="_PUvpjEMIo1z1dIbIUzw-0" value="&lt;p&gt;&lt;span&gt;Connection Flow&lt;/span&gt;&lt;br&gt;&lt;/p&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontStyle=0;fontSize=20;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
<mxGeometry x="40" y="30" width="230" height="20" as="geometry" />
</mxCell>
<mxCell id="fGsjKHM69yN9R0Hf5M05-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="fGsjKHM69yN9R0Hf5M05-0" target="IKjVovVhkWe_WipwIJL3-7">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="220" y="180" />
<mxPoint x="180" y="180" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="fGsjKHM69yN9R0Hf5M05-0" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="160" y="119.98" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-9">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="300" y="300.02" />
<mxPoint x="300" y="300.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-6" value="bad session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingLeft=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-4">
<mxGeometry x="-0.429" y="-1" relative="1" as="geometry">
<mxPoint x="29" y="-4" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-19">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-29" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-20">
<mxGeometry x="-0.1944" y="2" relative="1" as="geometry">
<mxPoint x="18" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-0" value="Validate resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="230" y="340.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-18">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-28" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-21">
<mxGeometry x="-0.2778" relative="1" as="geometry">
<mxPoint x="20" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-13">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="60" y="355.02" />
<mxPoint x="60" y="560.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-30" value="bad token" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-24">
<mxGeometry x="-0.8093" y="1" relative="1" as="geometry">
<mxPoint x="29" y="23" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-2" value="Validate register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="90" y="340.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-7" target="2r3fOvGVNr9_K5ZBoRis-28">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="180" y="260.02" />
<mxPoint x="180" y="260.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-7" value="Send hello" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="90" y="199.96" width="120" height="30.02" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-9" target="2r3fOvGVNr9_K5ZBoRis-28">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="260" y="260.02" />
<mxPoint x="260" y="260.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-9" value="Send invalidate" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="230" y="199.96" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-13" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="160" y="550.02" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-15" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="160" y="480.02" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-18" target="IKjVovVhkWe_WipwIJL3-15">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="180" y="470" />
<mxPoint x="180" y="470" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-18" value="Send ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="90" y="410.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-19" target="IKjVovVhkWe_WipwIJL3-15">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="260" y="470" />
<mxPoint x="260" y="470" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-19" value="Send continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="230" y="410.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-44">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="630" y="240.02" />
<mxPoint x="630" y="240.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-32" value="previous&lt;br&gt;session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-31">
<mxGeometry x="0.086" relative="1" as="geometry">
<mxPoint x="-20" y="-2" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-49">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="550" y="250.02" />
<mxPoint x="550" y="250.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-34" value="no&lt;br&gt;session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-33">
<mxGeometry x="-0.2268" relative="1" as="geometry">
<mxPoint x="-20" y="5" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-54">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="440" y="215.02" />
<mxPoint x="440" y="570.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-36" value="bad handshake&lt;br&gt;timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-35">
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
<mxPoint x="35" y="-23" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-37" value="Wait for hello" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="530" y="199.96000000000006" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-60" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-39" target="IKjVovVhkWe_WipwIJL3-37">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-39" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="530" y="119.98" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-65" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-44" target="IKjVovVhkWe_WipwIJL3-61">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="660" y="360.02" />
<mxPoint x="660" y="360.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-44" value="Send resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="600" y="270.0199999999999" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-68" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-49" target="IKjVovVhkWe_WipwIJL3-67">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-49" value="Send register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="460" y="270.0199999999999" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-54" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="530" y="550.02" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-55" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="530" y="480.0199999999999" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-62">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="630" y="390.02" />
<mxPoint x="630" y="390.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-73" value="receive&lt;br&gt;invalidate" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-64">
<mxGeometry x="0.2133" relative="1" as="geometry">
<mxPoint x="-30" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-54">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="700" y="570.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-74" value="bad handshake&lt;br&gt;timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;rotation=90;spacingTop=7;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-70">
<mxGeometry x="-0.7644" y="2" relative="1" as="geometry">
<mxPoint x="-12" y="29" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-55">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="630" y="450.02" />
<mxPoint x="630" y="450.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-75" value="receive&lt;br&gt;continue" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];spacingRight=4;labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-71">
<mxGeometry x="-0.3167" y="-1" relative="1" as="geometry">
<mxPoint x="-19" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-61" value="Wait for continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="600" y="410.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-63" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-62" target="IKjVovVhkWe_WipwIJL3-49">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="550" y="320.02" />
<mxPoint x="550" y="320.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-62" value="Invalidate session" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="530" y="340.02" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-54">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="470" y="560.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-77" value="bad handshake&lt;br&gt;timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rotation=90;labelBackgroundColor=none;spacingBottom=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-69">
<mxGeometry x="-0.8148" y="1" relative="1" as="geometry">
<mxPoint x="12" y="33" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-55">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="550" y="470.02" />
<mxPoint x="550" y="470.02" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-76" value="receive&lt;br&gt;ready" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-72">
<mxGeometry x="-0.2389" y="2" relative="1" as="geometry">
<mxPoint x="-22" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="IKjVovVhkWe_WipwIJL3-67" value="Wait for ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
<mxGeometry x="460" y="410.02" width="120" height="30" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
+3 -99
View File
@@ -1,99 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.3 (0)
-->
<!-- Title: Client Connection Flow Pages: 1 -->
<svg width="371pt" height="496pt"
viewBox="0.00 0.00 371.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
<title>Client Connection Flow</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 367,-492 367,4 -4,4"/>
<!-- start -->
<g id="node1" class="node">
<title>start</title>
<polygon fill="none" stroke="black" points="135,-384 81,-384 81,-348 135,-348 135,-384"/>
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Start</text>
</g>
<!-- wait_hello -->
<g id="node2" class="node">
<title>wait_hello</title>
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
</g>
<!-- start&#45;&gt;wait_hello -->
<g id="edge2" class="edge">
<title>start&#45;&gt;wait_hello</title>
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
</g>
<!-- new_session -->
<g id="node3" class="node">
<title>new_session</title>
<polygon fill="none" stroke="black" points="161,-210 55,-210 55,-174 161,-174 161,-210"/>
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">New session</text>
</g>
<!-- wait_hello&#45;&gt;new_session -->
<g id="edge3" class="edge">
<title>wait_hello&#45;&gt;new_session</title>
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
</g>
<!-- wait_ready -->
<g id="node4" class="node">
<title>wait_ready</title>
<polygon fill="none" stroke="black" points="167.5,-123 48.5,-123 48.5,-87 167.5,-87 167.5,-123"/>
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for ready</text>
</g>
<!-- new_session&#45;&gt;wait_ready -->
<g id="edge4" class="edge">
<title>new_session&#45;&gt;wait_ready</title>
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
<text text-anchor="middle" x="164" y="-144.8" font-family="Times,serif" font-size="14.00">Send register_*</text>
</g>
<!-- connected -->
<g id="node5" class="node">
<title>connected</title>
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
</g>
<!-- wait_ready&#45;&gt;connected -->
<g id="edge5" class="edge">
<title>wait_ready&#45;&gt;connected</title>
<path fill="none" stroke="black" d="M64.78,-86.86C57.71,-82.13 51.29,-76.25 47,-69 43.04,-62.32 41.73,-54.22 41.69,-46.49"/>
<polygon fill="black" stroke="black" points="45.19,-46.55 42.31,-36.36 38.21,-46.13 45.19,-46.55"/>
<text text-anchor="middle" x="97.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
</g>
<!-- abort -->
<g id="node6" class="node">
<title>abort</title>
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
</g>
<!-- wait_ready&#45;&gt;abort -->
<g id="edge6" class="edge">
<title>wait_ready&#45;&gt;abort</title>
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
<text text-anchor="middle" x="259.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
</g>
<!-- lost_connection -->
<g id="node7" class="node">
<title>lost_connection</title>
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while connecting</text>
</g>
<!-- lost_connection&#45;&gt;start -->
<g id="edge1" class="edge">
<title>lost_connection&#45;&gt;start</title>
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="760px" height="630px" viewBox="-0.5 -0.5 760 630" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="440" y="80" width="145" height="20" fill="none" stroke="none" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 90px; margin-left: 442px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; font-weight: bold; white-space: nowrap; ">Client</div></div></div></foreignObject><text x="442" y="94" fill="#000000" font-family="Helvetica" font-size="12px" font-weight="bold">Client</text></switch></g><path d="M 260 300.02 L 260 320 L 260 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 260 338.9 L 256.5 331.9 L 260 333.65 L 263.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 316px; margin-left: 238px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />resume</div></div></div></foreignObject><text x="238" y="319" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 180 300.02 L 180 320 L 180 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 338.9 L 176.5 331.9 L 180 333.65 L 183.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 318px; margin-left: 158px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />register</div></div></div></foreignObject><text x="158" y="322" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 160 285 L 40 285 L 40 580 L 153.63 580" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 158.88 580 L 151.88 583.5 L 153.63 580 L 151.88 576.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 300px; margin-left: 91px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">bad handshake<br />timeout</div></div></div></foreignObject><text x="91" y="304" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">bad handshake...</text></switch></g><rect x="160" y="270.02" width="120" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 285px; margin-left: 161px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "Line truncated

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="800px" height="440px" viewBox="-0.5 -0.5 800 440" style="background-color: rgb(238, 243, 250);"><defs/><g><path d="M 170 365.02 L 170 385 L 290 385 L 290 371.39" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 290 366.14 L 293.5 373.14 L 290 371.39 L 286.5 373.14 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 110 350 L 40 350 L 40 130 L 103.63 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 108.88 130 L 101.88 133.5 L 103.63 130 L 101.88 126.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="110" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 111px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="160" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">connected</text></switch></g><path d="M 290 335.02 L 290 315 L 180 315 L 180 328.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 333.9 L 176.5 326.9 L 180 328.65 L 183.5 326.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 350 335.02 L 350 135 L 216.37 135" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 135 L 218.12 131.5 L 216.37 135 L 218.12 138.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 320 335.02 L 320 235 L 216.37 235" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 235 L 218.12 231.5 L 216.37 235 L 218.12 238.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="270" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 271px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="320" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">resuming</text></switch></g><path d="M 500 149.96 L 500 223.59" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 500 228.84 L 496.5 221.84 L 500 223.59 L 503.5 221.84 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="460" y="119.96" width="80" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 135px; margin-left: 461px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">closed</div></div></div></foreignObject><text x="500" y="139" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">closed</text></switch></g><path d="M 520 259.96 L 520 280 L 630 280 L 630 266.33" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 630 261.08 L 633.5 268.08 L 630 266.33 L 626.5 268.08 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 480 229.96 L 480 190 L 480 156.33" fill="noneLine truncated

After

Width:  |  Height:  |  Size: 24 KiB

-4
View File
@@ -1,4 +0,0 @@
for x in $(dirname $0)/*.dot; do
echo $x
dot $x -Tsvg -o ${x/.dot/.svg}
done
-23
View File
@@ -1,23 +0,0 @@
digraph "Client Reconnection Flow" {
node [shape=box];
graph [newrank=true];
reconnect [label="Reconnect"];
wait_hello [label="Wait for hello"];
resume_session [label="Resume session"];
new_session [label="New session" style="dashed"];
wait_continue [label="Wait for continue"];
connected [label="Connected"];
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile reconnecting" style="dotted"];
abort [label="Abort"];
{ rank="same"; wait_continue; new_session };
lost_connection -> reconnect [label="Wait random delay"];
reconnect -> wait_hello [label="Connect to /api/socket"];
wait_hello -> resume_session [label="Receive hello"];
resume_session -> wait_continue [label="Send resume"];
wait_continue -> abort [label="Connection closed with 4003"];
wait_continue -> new_session [label="Receive invalidate"];
wait_continue -> connected [label="Receive ready"];
}
-112
View File
@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.3 (0)
-->
<!-- Title: Client Reconnection Flow Pages: 1 -->
<svg width="441pt" height="496pt"
viewBox="0.00 0.00 441.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
<title>Client Reconnection Flow</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 437,-492 437,4 -4,4"/>
<!-- reconnect -->
<g id="node1" class="node">
<title>reconnect</title>
<polygon fill="none" stroke="black" points="153.5,-384 62.5,-384 62.5,-348 153.5,-348 153.5,-384"/>
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Reconnect</text>
</g>
<!-- wait_hello -->
<g id="node2" class="node">
<title>wait_hello</title>
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
</g>
<!-- reconnect&#45;&gt;wait_hello -->
<g id="edge2" class="edge">
<title>reconnect&#45;&gt;wait_hello</title>
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
</g>
<!-- resume_session -->
<g id="node3" class="node">
<title>resume_session</title>
<polygon fill="none" stroke="black" points="173.5,-210 42.5,-210 42.5,-174 173.5,-174 173.5,-210"/>
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">Resume session</text>
</g>
<!-- wait_hello&#45;&gt;resume_session -->
<g id="edge3" class="edge">
<title>wait_hello&#45;&gt;resume_session</title>
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
</g>
<!-- wait_continue -->
<g id="node5" class="node">
<title>wait_continue</title>
<polygon fill="none" stroke="black" points="178,-123 38,-123 38,-87 178,-87 178,-123"/>
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for continue</text>
</g>
<!-- resume_session&#45;&gt;wait_continue -->
<g id="edge4" class="edge">
<title>resume_session&#45;&gt;wait_continue</title>
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
<text text-anchor="middle" x="155" y="-144.8" font-family="Times,serif" font-size="14.00">Send resume</text>
</g>
<!-- new_session -->
<g id="node4" class="node">
<title>new_session</title>
<polygon fill="none" stroke="black" stroke-dasharray="5,2" points="433,-123 327,-123 327,-87 433,-87 433,-123"/>
<text text-anchor="middle" x="380" y="-101.3" font-family="Times,serif" font-size="14.00">New session</text>
</g>
<!-- wait_continue&#45;&gt;new_session -->
<g id="edge6" class="edge">
<title>wait_continue&#45;&gt;new_session</title>
<path fill="none" stroke="black" d="M178.05,-105C220.94,-105 275.39,-105 316.94,-105"/>
<polygon fill="black" stroke="black" points="316.94,-108.5 326.94,-105 316.94,-101.5 316.94,-108.5"/>
<text text-anchor="middle" x="252.5" y="-111.8" font-family="Times,serif" font-size="14.00">Receive invalidate</text>
</g>
<!-- connected -->
<g id="node6" class="node">
<title>connected</title>
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
</g>
<!-- wait_continue&#45;&gt;connected -->
<g id="edge7" class="edge">
<title>wait_continue&#45;&gt;connected</title>
<path fill="none" stroke="black" d="M61.23,-86.99C53.95,-82.28 47.4,-76.37 43,-69 39.01,-62.33 38.18,-54.22 38.78,-46.49"/>
<polygon fill="black" stroke="black" points="42.27,-46.77 40.32,-36.36 35.35,-45.72 42.27,-46.77"/>
<text text-anchor="middle" x="93.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
</g>
<!-- abort -->
<g id="node8" class="node">
<title>abort</title>
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
</g>
<!-- wait_continue&#45;&gt;abort -->
<g id="edge5" class="edge">
<title>wait_continue&#45;&gt;abort</title>
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
<text text-anchor="middle" x="260.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
</g>
<!-- lost_connection -->
<g id="node7" class="node">
<title>lost_connection</title>
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while reconnecting</text>
</g>
<!-- lost_connection&#45;&gt;reconnect -->
<g id="edge1" class="edge">
<title>lost_connection&#45;&gt;reconnect</title>
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

-21
View File
@@ -1,21 +0,0 @@
digraph "Server Connection Flow" {
node [shape=box];
graph [newrank=true];
new_connection [label="New connection"];
wait_handshake [label="Wait for handshake"];
validate_register [label="Validate register"];
validate_resume [label="Validate resume"];
connected [label="Connected"];
close [label="Close connection"];
new_connection -> wait_handshake [label="Send hello"];
wait_handshake -> validate_register [label="Receive register_*"];
wait_handshake -> validate_resume [label="Receive resume"];
wait_handshake -> close [label="Bad handshake\nTimeout"];
validate_register -> close [label="Bad token\nBad register"];
validate_register -> connected [label="Valid register:\nSend ready"];
validate_resume -> close [label="Bad token\nBad resume"];
validate_resume -> wait_handshake [label="Bad session:\nsend invalidate"];
validate_resume -> connected [label="Valid resume:\nSend continue"];
}
-118
View File
@@ -1,118 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.3 (0)
-->
<!-- Title: Server Connection Flow Pages: 1 -->
<svg width="603pt" height="335pt"
viewBox="0.00 0.00 603.00 335.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 331)">
<title>Server Connection Flow</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-331 599,-331 599,4 -4,4"/>
<!-- new_connection -->
<g id="node1" class="node">
<title>new_connection</title>
<polygon fill="none" stroke="black" points="355.5,-327 224.5,-327 224.5,-291 355.5,-291 355.5,-327"/>
<text text-anchor="middle" x="290" y="-305.3" font-family="Times,serif" font-size="14.00">New connection</text>
</g>
<!-- wait_handshake -->
<g id="node2" class="node">
<title>wait_handshake</title>
<polygon fill="none" stroke="black" points="367.5,-240 212.5,-240 212.5,-204 367.5,-204 367.5,-240"/>
<text text-anchor="middle" x="290" y="-218.3" font-family="Times,serif" font-size="14.00">Wait for handshake</text>
</g>
<!-- new_connection&#45;&gt;wait_handshake -->
<g id="edge1" class="edge">
<title>new_connection&#45;&gt;wait_handshake</title>
<path fill="none" stroke="black" d="M290,-290.8C290,-279.16 290,-263.55 290,-250.24"/>
<polygon fill="black" stroke="black" points="293.5,-250.18 290,-240.18 286.5,-250.18 293.5,-250.18"/>
<text text-anchor="middle" x="328" y="-261.8" font-family="Times,serif" font-size="14.00">Send hello</text>
</g>
<!-- validate_register -->
<g id="node3" class="node">
<title>validate_register</title>
<polygon fill="none" stroke="black" points="136,-138 0,-138 0,-102 136,-102 136,-138"/>
<text text-anchor="middle" x="68" y="-116.3" font-family="Times,serif" font-size="14.00">Validate register</text>
</g>
<!-- wait_handshake&#45;&gt;validate_register -->
<g id="edge2" class="edge">
<title>wait_handshake&#45;&gt;validate_register</title>
<path fill="none" stroke="black" d="M212.45,-216.73C161.73,-212.32 101.57,-203.5 83,-186 72.82,-176.41 68.91,-161.41 67.62,-148.23"/>
<polygon fill="black" stroke="black" points="71.11,-147.99 67.07,-138.2 64.12,-148.37 71.11,-147.99"/>
<text text-anchor="middle" x="148.5" y="-167.3" font-family="Times,serif" font-size="14.00">Receive register_*</text>
</g>
<!-- validate_resume -->
<g id="node4" class="node">
<title>validate_resume</title>
<polygon fill="none" stroke="black" points="356,-138 224,-138 224,-102 356,-102 356,-138"/>
<text text-anchor="middle" x="290" y="-116.3" font-family="Times,serif" font-size="14.00">Validate resume</text>
</g>
<!-- wait_handshake&#45;&gt;validate_resume -->
<g id="edge3" class="edge">
<title>wait_handshake&#45;&gt;validate_resume</title>
<path fill="none" stroke="black" d="M241.15,-203.81C233.91,-199.14 227.42,-193.3 223,-186 216.09,-174.6 216.09,-167.4 223,-156 225.69,-151.55 229.16,-147.65 233.09,-144.22"/>
<polygon fill="black" stroke="black" points="235.23,-146.98 241.15,-138.19 231.04,-141.38 235.23,-146.98"/>
<text text-anchor="middle" x="280" y="-167.3" font-family="Times,serif" font-size="14.00">Receive resume</text>
</g>
<!-- close -->
<g id="node6" class="node">
<title>close</title>
<polygon fill="none" stroke="black" points="450.5,-36 313.5,-36 313.5,0 450.5,0 450.5,-36"/>
<text text-anchor="middle" x="382" y="-14.3" font-family="Times,serif" font-size="14.00">Close connection</text>
</g>
<!-- wait_handshake&#45;&gt;close -->
<g id="edge4" class="edge">
<title>wait_handshake&#45;&gt;close</title>
<path fill="none" stroke="black" d="M367.54,-213.17C403.99,-207.82 442.06,-199.24 454,-186 493.43,-142.29 497.55,-103.05 465,-54 461.92,-49.36 458.09,-45.33 453.78,-41.84"/>
<polygon fill="black" stroke="black" points="455.76,-38.95 445.55,-36.1 451.76,-44.69 455.76,-38.95"/>
<text text-anchor="middle" x="540.5" y="-123.8" font-family="Times,serif" font-size="14.00">Bad handshake</text>
<text text-anchor="middle" x="540.5" y="-108.8" font-family="Times,serif" font-size="14.00">Timeout</text>
</g>
<!-- connected -->
<g id="node5" class="node">
<title>connected</title>
<polygon fill="none" stroke="black" points="127,-36 35,-36 35,0 127,0 127,-36"/>
<text text-anchor="middle" x="81" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
</g>
<!-- validate_register&#45;&gt;connected -->
<g id="edge6" class="edge">
<title>validate_register&#45;&gt;connected</title>
<path fill="none" stroke="black" d="M34.78,-101.9C28.43,-97.02 22.64,-91.06 19,-84 12.9,-72.14 12.28,-65.51 19,-54 21.52,-49.69 24.78,-45.87 28.5,-42.49"/>
<polygon fill="black" stroke="black" points="30.9,-45.05 36.66,-36.16 26.61,-39.52 30.9,-45.05"/>
<text text-anchor="middle" x="70" y="-72.8" font-family="Times,serif" font-size="14.00">Valid register:</text>
<text text-anchor="middle" x="70" y="-57.8" font-family="Times,serif" font-size="14.00">Send ready</text>
</g>
<!-- validate_register&#45;&gt;close -->
<g id="edge5" class="edge">
<title>validate_register&#45;&gt;close</title>
<path fill="none" stroke="black" d="M136,-111.34C178.72,-105.47 229.03,-96.29 247,-84 260.03,-75.09 253.43,-63.55 266,-54 277.12,-45.55 290.27,-39.13 303.66,-34.26"/>
<polygon fill="black" stroke="black" points="305.06,-37.48 313.42,-30.97 302.83,-30.84 305.06,-37.48"/>
<text text-anchor="middle" x="311" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
<text text-anchor="middle" x="311" y="-57.8" font-family="Times,serif" font-size="14.00">Bad register</text>
</g>
<!-- validate_resume&#45;&gt;wait_handshake -->
<g id="edge8" class="edge">
<title>validate_resume&#45;&gt;wait_handshake</title>
<path fill="none" stroke="black" d="M321.89,-138.33C327.98,-143.2 333.53,-149.09 337,-156 342.98,-167.92 342.98,-174.08 337,-186 335.05,-189.89 332.44,-193.45 329.44,-196.69"/>
<polygon fill="black" stroke="black" points="326.86,-194.32 321.89,-203.67 331.61,-199.46 326.86,-194.32"/>
<text text-anchor="middle" x="395.5" y="-174.8" font-family="Times,serif" font-size="14.00">Bad session:</text>
<text text-anchor="middle" x="395.5" y="-159.8" font-family="Times,serif" font-size="14.00">send invalidate</text>
</g>
<!-- validate_resume&#45;&gt;connected -->
<g id="edge9" class="edge">
<title>validate_resume&#45;&gt;connected</title>
<path fill="none" stroke="black" d="M223.7,-112.81C197.28,-107.96 167.61,-99.33 144,-84 129.91,-74.85 132.49,-66.26 121,-54 117.46,-50.23 113.56,-46.43 109.61,-42.79"/>
<polygon fill="black" stroke="black" points="111.86,-40.11 102.07,-36.06 107.2,-45.33 111.86,-40.11"/>
<text text-anchor="middle" x="195.5" y="-72.8" font-family="Times,serif" font-size="14.00">Valid resume:</text>
<text text-anchor="middle" x="195.5" y="-57.8" font-family="Times,serif" font-size="14.00">Send continue</text>
</g>
<!-- validate_resume&#45;&gt;close -->
<g id="edge7" class="edge">
<title>validate_resume&#45;&gt;close</title>
<path fill="none" stroke="black" d="M336.9,-101.98C345.46,-97.2 353.66,-91.27 360,-84 369.17,-73.47 374.56,-58.96 377.7,-46.32"/>
<polygon fill="black" stroke="black" points="381.18,-46.78 379.85,-36.27 374.33,-45.32 381.18,-46.78"/>
<text text-anchor="middle" x="418" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
<text text-anchor="middle" x="418" y="-57.8" font-family="Times,serif" font-size="14.00">Bad resume</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

+21 -44
View File
@@ -1,59 +1,36 @@
Communication Layer
===================
# Communication Layer
<sub>This document describes the communication layer in Clusterio and
unless you're developing Clusterio it's probably not going to be very
useful for you.</sub>
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Communication is facilitated by the lib/link library and is based on
three core abstractions: links, connectors and messages. Links
represents one side of a two way communication pipe, connectors deal
with connecting and establishing communication, and messages are the
content sent over the link.
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
Links represents one side of a two way communication pipe, connectors deal with connecting and establishing communication, and messages are the content sent over the link.
Link
----
## Link
The Link class represents one side of a two way communication channel
where the actual communication is done by a connector associated with
the link. The link also knows what the endpoint types it's connected to
are via the source and target attributes.
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
The link also knows what the endpoint types it's connected to are via the source and target attributes.
The endpoint types currently implemented are master, slave, instance,
and control. Although slave and instances run in the same Node.js
program they use virtual links to communicate between them in order to
simplify the communication architecture.
The endpoint types currently implemented are master, slave, instance, and control.
Although slave and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
Upon creation the link will register with the connector in order to
receive and process messages from it. The messages received are
validated using the validator registered for the type of the message. A
message for which there is no validator for is considered an error.
Upon creation the link will register with the connector in order to receive and process messages from it.
The messages received are validated using the validator registered for the type of the message.
A message for which there is no validator for is considered an error.
Connector
---------
## Connector
The connector used with a link is responsible for establishing and/or
maintain the connection that messages are sent over. Reconnect logic is
expected to be implemented in the connector, though this has not yet
been done.
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
There's also the virtual connector which lets two links in the same
program be connected to each other without having to establish a
network connection.
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
Message
-------
## Message
There's two types of messages currently implemented apart from the
Link builtin close message and that's Request and Event. The builtin
messages are defined in
[packages/lib/link/messages.js](/packages/lib/link/messages.js) and are
attached to links via the attachAllMessages function, which is usally
called in the Link subclass constructor.
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The messages define which links they are valid on and only attaches
handlers for those links. There's also a forwarding mechanism for when
a request or event is to be forwarded another hop.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+38 -62
View File
@@ -1,45 +1,34 @@
Configuration System
====================
# Configuration System
<sub>This document describes the implementation and inner working of the
configuration system in Clusterio, and unless you're developing
Clusterio it's probably not going to be very useful for you.</sub>
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Clusterio uses a group based configuration system to manage settings for
the master server, instances, slaves, and the clusterioctl utility.
Plugins get their own config group for the master server and instances
which they can add their own fields to. The fields are defined early in
the startup, and the groups and their fields for disabled plugins will
still be created.
Clusterio uses a group based configuration system to manage settings for the master server, instances, slaves, and the clusterioctl utility.
Plugins get their own config group for the master server and instances which they can add their own fields to.
The fields are defined early in the startup, and the groups and their fields for disabled plugins will still be created.
The built-in config groups and their fields are defined in
[packages/lib/config/definitions.js](/packages/lib/config/definitions.js)
while plugins can add their own config groups to the `MasterConfigGroup`
and `InstanceConfigGroup` properties of the `info.js` export using
subclasses of `PluginConfigGroup`. The plugin group has the enabled
field pre-defined on it, and it's used to enable/disable plugins.
The built-in config groups and their fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config groups to the `MasterConfigGroup` and `InstanceConfigGroup` properties of the `info.js` export using subclasses of `PluginConfigGroup`.
The plugin group has the enabled field pre-defined on it, and it's used to enable/disable plugins.
Defing Fields
-------------
## Defing Fields
Fields are defined using the `define` class method on group classes
after the groupName have been set and before the class has been
finalized. The `define` method takes an object as argument with the
following properties:
Fields are defined using the `define` class method on group classes after the groupName have been set and before the class has been finalized.
The `define` method takes an object as argument with the following properties:
**access**:
The locations this config field can be read and modified from.
This is an array of strings which can contain the values `"master"`, `"slave"` and `"control"`.
If a party is missing in this array then that party will not receive the value of this field when the config is shared, and will be unable to modify the field.
Takes the value of `defaultAccess` set on the config group class if not passed.
Note that instance config groups should have both `"master"` and `"slave"` for all of its fields or unexpected behaviour may occur.
**type**:
The type of config value this field will support. The supported
values are boolean, string, number, and object, and if an attept is
made to set the field to a value that is not of the right type it
will be reject unless it's a string that can be converted to the
right type.
The type of config value this field will support.
The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type.
**name**:
Name of the configuration field, this is the primary way the field
is accessed, both in code and on the command line. As such it
should follow the lower\_case\_underscore style and not contain any
spaces or dots.
Name of the configuration field, this is the primary way the field is accessed, both in code and on the command line.
As such it should follow the lower\_case\_underscore style and not contain any spaces or dots.
**title** (optional):
Text used to identify the config field in user interfaces.
@@ -48,27 +37,24 @@ following properties:
Text used to describe the config field in user interfaces.
**enum** (optional):
Array of acceptable values this field can have. Attempts to set a
value not in this array will be rejected, which makes it useful for
making enumareted list of choices.
Array of acceptable values this field can have.
Attempts to set a value not in this array will be rejected, which makes it useful for making enumareted list of choices.
**optional** (optional):
True if this field can be set to null. Defaults to false.
True if this field can be set to null.
Defaults to false.
**initial_value** (optional if optional is true):
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
If the value for a field stored in on disk somehow ends up being invalid
the field will take on the initial\_value instead when the config is
loaded.
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
Accessing Fields
----------------
## Accessing Fields
On the Config instance there's two ways to access fields: through the
set and get methods using dot notation. E.g.:
On the Config instance there's two ways to access fields: through the set and get methods using dot notation.
E.g.:
let value = configInstance.get("group.field");
@@ -77,26 +63,16 @@ E.g.:
let value = configInstance.group("group").get("field")
The set method takes an extra argument, which is the value to set the
field to. If the value is not valid this will throw an InvalidValue
exception, if the group or field doesn't exist it will throw an
InvalidField exception.
The set method takes an extra argument, which is the value to set the field to.
If the value is not valid this will throw an InvalidValue exception, if the group or field doesn't exist it will throw an InvalidField exception.
Config Lifecycle
----------------
## Config Lifecycle
Values for all config fields are created when the config is initialized,
as well as for any fields with missing values when a config is loaded.
Once a field has a value it is never automatically removed, even if the
field or group it was part of no longer exists. This ensures that
plugin configuration is not lost should the server be started up without
the plugins that defined the configuration installed.
Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded.
Once a field has a value it is never automatically removed, even if the field or group it was part of no longer exists.
This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed.
For instance configs, they are kept on the master server and serialized
and sent over to the relevant slaves on startup. This makes it possible
to edit instance configs when the slave the instance is on is offline.
A copy is also stored in the instance directory on the slave, and this
config will be deliverd to the master server on slave startup, which
enables instances to be copied between clusters as long as their IDs are
unique.
For instance configs, they are kept on the master server and serialized and sent over to the relevant slaves on startup.
This makes it possible to edit instance configs when the slave the instance is on is offline.
A copy is also stored in the instance directory on the slave, and this config will be deliverd to the master server on slave startup, which enables instances to be copied between clusters as long as their IDs are unique.
+238 -140
View File
@@ -1,155 +1,168 @@
Configuration
=============
# Configuration
Master Configuration
--------------------
## Master Configuration
### master.name
Name of the cluster.
Used to distinguish it from other clusters and is prepended to the name of servers in the server list.
Defaluts to "Your Cluster".
### master.database_directory
Directory used to store cluster data to. Needs to be writeable by the
master server. This is also used by plugins to store its data.
Directory used to store cluster data to.
Needs to be writeable by the master server.
This is also used by plugins to store its data.
Defaults to "database".
### master.http_port
Port to host HTTP server on. If set to null no HTTP server will be
exposed. At least one of this and master.https_port needs to be set to
a port.
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and master.https_port needs to be set to a port.
Defaults to null.
Defaults to 8080.
### master.https_port
Port to host HTTPS server on. Uses master.tls_certificate and
master.tls_private_key as the certificate and private key for the HTTPS
server. If set to null no HTTPS server will be exposed. At least one
of this and master.http_port needs to be set to a port.
Port to host HTTPS server on.
Uses master.tls_certificate and master.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and master.http_port needs to be set to a port.
Defaults to 8443.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
Defaults to null.
### master.bind_address
IP address to bind the HTTP and HTTPS ports on.
Useful to limit which interface to accept connections from.
If set to null the unspecified IPv6 address will be used, which on most systems means it will also listen on the unspecified IPv4 address and accept connections from all
interfaces.
Defaults to null.
### master.external_address
External address the master server is accessible on. Currently only
used for `clusteriomaster bootstrap create-ctl-config` in order to give
the right url to connect to. This should be a full URL ending with a /.
External address the master server is accessible on.
Currently only used for `clusteriomaster bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### master.tls_certificate
TLS certificate to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_private_key
exists a self signed certificate is automatically created.
Path to TLS certificate to use for the HTTPS server when master.https_port is configured.
Defaults to "database/certificates/cert.crt".
### master.tls_private_key
TLS private key to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_certificate
exists a self signed certificate is automatically created.
Path to TLS private key to use for the HTTPS server when master.https_port is configured.
Defaults to "database/certificates/cert.key".
### master.tls_bits
Number of bits to use when creating a self signed certificate.
Defaults to 2048.
### master.auth_secret
Base64 encoded authentication secret used to verify tokens issued to
slaves, users, and sessions. This should contain 256 bytes of random
data, and if changed will cause all tokens become invalid. Keep this
secret if leaked an attacker could easily compromise the cluster.
Base64 encoded authentication secret used to verify tokens issued to slaves, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket
connections. If a connection hasn't received a heartbeat in 2 times the
heartbeat interval it will be considered stale and closed. A lower
value means less time between connections going stale and them being
closed.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Defaults to 60
Defaults to 15.
### master.connector_shutdown_timeout
### master.session_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.
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
In the case of the master or client crashing the data loss is unavoidable.
Defaults to 30.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `master.heartbeat_interval`.
Defaults to 60.
### master.metrics_timeout
Timeout in seconds before a call to gather metrics from a slave times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
Defaults to 8.
### master.stream_timeout
Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Defaults to 15.
### master.default_role_id
Role to automatically grant to new users. If null no role is granted.
Role to automatically grant to new users.
If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### <plugin_name>.enabled
### <plugin_name>.load_plugin
Whether to enable the given plugin on the master server. Plugins
disabled on the master server will not be loaded on instances.
Whether to load the plugin on the master server.
Plugins not loaded on the master server will not be loaded on instances.
Defaults to true.
Slave Configuration
-------------------
## Slave Configuration
### slave.name
Name of the slave. Shows up in slave lists and is used to reference
this slave in the clusterioctl command line interface.
Name of the slave.
Shows up in slave lists and is used to reference this slave in the clusterioctl command line interface.
Defaults to "New Slave".
### slave.id
Imutable numeric id of the slave which uniquely identifies this slave in
the cluster.
Immutable numeric id of the slave which uniquely identifies this slave in the cluster.
Defaults to a random 31 bit number.
### slave.factorio_directory
Directory to look for the Factorio server(s) in. This can either point
to a Factorio server directory, or to a directory containing multiple
versions of the Factorio server.
Directory to look for the Factorio server(s) in.
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
Defaults to "factorio".
### slave.instances_directory
Directory to store instances in. One sub-directory will be created in
it for each instance assigned to this slave and these sub-directories
will contain logs, saves, and various auto generated configuration files
for the instances.
Directory to store instances in.
One sub-directory will be created in it for each instance assigned to this slave and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
Defaults to "instances".
@@ -158,166 +171,251 @@ Defaults to "instances".
URL to connect to the master server to.
Defaults to "https://localhost:8443/".
Defaults to "http://localhost:8080/".
### slave.master_token
Access token used for authenticating with the master server. You can
generate an access token with `clusterioctl slave generate-token
--id <slave-id>`, or use the `clusterioctl slave create-config` to
create a new slave config with the correct url and token in it.
Access token used for authenticating with the master server.
You can generate an access token with `clusterioctl slave generate-token --id <slave-id>`, or use the `clusterioctl slave create-config` to create a new slave config with the correct url and token in it.
Defaults to "enter token here".
### slave.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the master server.
If you have a self signed certificate on the master server you will need to copy the certificate to your slaves and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### slave.public_address
External address instances hosted on this slave can be accessed on.
This is used by plugins like Server Select to give the correct address
to switch between instances in-game.
This is used by plugins like Server Select to give the correct address to switch between instances in-game.
Defaults to "localhost".
### slave.reconnect_delay
### slave.max_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.
Maximum 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 clients trying to reconnect at the same time.
Defaults to 5.
Defaults to 60.
Instance Configuration
======================
## Instance Configuration
### instance.name
Name of the instance, shown in instance lists and used to reference to this
instance in the clusterioctl command line interface.
Name of the instance, shown in instance lists and used to reference to this instance in the clusterioctl command line interface.
Defaults to "New Instance"
Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this
instance in the cluster.
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
Defaluts to a random 31 bit integer.
### instance.assigned_slave
Slave this instance is assigned to. To change this you need to use the
separate assign instance interface. After being assigned to a slave the
directory for the instance is created on the slave.
Slave this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a slave the directory for the instance is created on the slave.
Defaults to null meaning not assigned.
### instance.auto_start
If enabled start this instance when the slave it is assigned to is started up.
Does not affect start or stop of the instance while the slave is running.
Defaults to false.
### factorio.version
Version of Factorio to use for this instance. Can be a version like
"1.0.0" or special string "latest" meaning the latest version of
Factorio found on the slave.
Version of Factorio to use for this instance.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the slave.
Defaults to "latest"
Defaults to "latest".
### factorio.game_port
UDP port to run the Factorio server on. If null a random port in the
dynamyc range assigned each time the instance starts.
UDP port to run the Factorio server on.
If null a random port in the dynamyc range assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on. If null
a random port in the dynamic range is assigned each time the instance
starts.
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_password
Password to use for the RCON interafec. If null a random secure
password is generated and used each time the instance starts.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
Defaults to null
Defaults to null.
### player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
Defaults to 5.
### factorio.enable_save_patching
Whether to use save patching or not. When enabled lua code is patched
into the game save before starting it. Most plugins require the use of
save patching to function. Turning it off makes it possible to use
Clusterio to run and manage regular vanilla games and scenarios not
compatible with Clusterio.
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Defaults to true
Defaults to true.
### factorio.enable_whitelist
Turn on the whitelist on the server.
Defaults to false
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the
Factorio server. The settings in `data/server-settings.example.json` of
the Factorio installation is used as the base and properties specified
here overrides the properties there.
Object with the settings to put into `server-settings.json` for the Factorio server.
The settings in `data/server-settings.example.json` of the Factorio installation is used as the base and properties specified here overrides the properties there.
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### factorio.verbose_logging
Pass the `--verbose` flag to Factorio when starting the server.
This prints more messages to the log.
Defaults to false.
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
This improves the signal to noise ratio of the log.
Sample output when disabled:
0.487 Loading map /srv/clusterio/instances/test/saves/world.zip: 2615888 bytes.
0.614 Checksum for script /srv/clusterio/instances/test/temp/currently-playing/control.lua: 2390553941
1.277 Script @/srv/clusterio/instances/test/temp/currently-playing/modules/example/test.lua:7: Example log line.
Sample output when enabled:
0.487 Loading map saves/world.zip: 2615888 bytes.
0.614 Checksum for script temp/currently-playing/control.lua: 2390553941
1.277 Script @modules/example/test.lua:7: Example log line.
Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in
cluster. If enabled Clusterio will generate a new
`server-adminlist.json` on instance startup based on the users in the
cluster with admin status set to true, and keep the admins in sync with
the users that have admin status while the instance is running.
Synchronize in-game admin list with admin status of the users in cluster.
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
Defaults to true
Defaults to true.
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users
in the cluster. If enabled Clusterio will generate a new
`server-whitelist.json` on instance startup based on the users in the
cluster with whitelisted status set to true, and keep the whitelisted
users in sync with the users that have whitelisted status while the
instance in running.
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
Defaults to true
Defaults to true.
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the
cluster. If enabled Clusterio will generate a new `server-banlist.json`
on instance startup based on users in the cluster with banned status set
to true, and keep the banned users in sync with the users that have the
banned status while the instance is running.
Synchronize in-game banlist with the banned status of users in the cluster.
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
Defaults to true
Defaults to true.
### <plugin_name>.enabled
### factorio.max_concurrent_commands
Whether to enable the given plugin on the instance. Note that plugins
need to be enabled on the master server in order for them to be loaded
on instances.
Maximum number of commands being transmitted into the game in parallel.
Since the rate at which long commands are streamed into the game is by default limeted to 100 bytes/tick and scales down to 25 bytes/tick when there's 20 players connected, long commands can hold up the command interface and cause large latencies in getting data pushed into the game.
(The rates are controlled by the `segment_size` options in the server settings.)
To counteract this Clusterio sends up to this configured value number of commands in parallel over the RCON interface.
This causes the game updates sent to clients to have this many command streams in parallel contained in them, drastically increasing the maxiumum size they can become.
Since commands are only processed so ofter the maximum rate of commands that can be sustained is roughly 30 times this configured value per second, though note that large commands will lower this value.
Defaults to true
The game updates sent by the server is split into roughly 500 byte packets, and if any one of those parts are lost the entire update is resent.
This means a high number of concurrent commands can amplify resends due to packet loss and degrade the player experience up to the point of becomming completely unplayable.
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded on the master server in order for them to be loaded on instances.
Defaults to true.
## Control Configuration
### control.master_url
URL to connect to the master server to.
Defaults to null meaning complain about it not being set and exit.
### control.master_token
Access token used for authenticating with the master server.
You can generate an access token with `clusteriomaster bootstrap generate-user-token <username>`, or use the `clusteriomaster bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the master server.
If you have a self signed certificate on the master server you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 60.
+175 -134
View File
@@ -1,13 +1,11 @@
Contributing to Clusterio
=========================
# Contributing to Clusterio
You are probably reading this because you want to contribute code to
Clusterio. Great! We need people like you. Though we have some
conventions and workflows that we kindly ask you to follow.
You are probably reading this because you want to contribute code to Clusterio.
Great! We need people like you.
Though we have some conventions and workflows that we kindly ask you to follow.
Contents
--------
## Contents
- [Workflow](#workflow)
- [Starting a Feature Branch](#starting-a-feature-branch)
@@ -23,208 +21,195 @@ Contents
- [Naming Style](#naming-style)
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Markdown](#markdown)
- [Pushing releases to npm](#pushing-releases-to-npm)
Workflow
--------
## Workflow
While we don't require you to follow this workflow, it makes things much
simpler for both you and us if you choose to do so. Especially when it
comes to making the commit history reflect what happened and not be a
giant ball of mess. GitHub's git workflow is less than ideal for
dealing with small feature-branches so you might see things being done
here a little diffrent than the usual.
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
Especially when it comes to making the commit history reflect what happened and not be a giant ball of mess.
GitHub's git workflow is less than ideal for dealing with small feature-branches so you might see things being done here a little diffrent than the usual.
It is recommended that you use the command line git version for doing
the operations described here. GUI wrappers tend to have different
ideas of what is what and you might find it difficult to make it do the
things described here. For doing the actual commits though, your
faviorit git GUI should work just fine.
It is recommended that you use the command line git version for doing the operations described here.
GUI wrappers tend to have different ideas of what is what and you might find it difficult to make it do the things described here.
For doing the actual commits though, your faviorit git GUI should work just fine.
If you pressed the fork button on GitHub and used `git clone` _on your
own fork_ you can skip to the next paragraph. Otherwise you will have
to click the fork button and clone your fork. If you already cloned the
main GitHub repository you can change your clone to point at your own
fork using the `git remote` command, but it's probably easier to delete
your local clone and clone it again.
If you pressed the fork button on GitHub and used `git clone` _on your own fork_ you can skip to the next paragraph.
Otherwise you will have to click the fork button and clone your fork.
If you already cloned the main GitHub repository you can change your clone to point at your own fork using the `git remote` command, but it's probably easier to delete your local clone and clone it again.
To keep your local repository up to to date with the main repository add
the main repository as a remote.
To keep your local repository up to to date with the main repository add the main repository as a remote.
git remote add upstream https://github.com/clusterio/factorioClusterio
git remote add upstream https://github.com/clusterio/clusterio
After this running `git fetch upstream` will get the updated branches
from the main repository. Do not run `git pull upstream` it will mess
thing up!
After this running `git fetch upstream` will get the updated branches from the main repository.
Do not run `git pull upstream` it will mess thing up!
### Setting up the Development Environment
The project is organized into several packages that are all managed
through Lerna. In order to get the development environment up and
running you will need to run the following commands:
The project is organized into several packages that are all tied together using pnpm's workspace feature.
In order to get the development environment up and running you will need to run the following command:
npm install
npx lerna bootstrap
pnpm install
This installs dependencies needed by tests and links the packages up so
they work from the git work tree.
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
node packages/create --dev
To add plugins to use in the development environment use
node packages/ctl plugin add ./path/to/plugin
It's important that the path starts with `./`.
The `plugins/` directory contains the core plugins for the project and can be added using the above mentioned command.
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
npx clusteriomaster -> node packages/master
npx clusterioslave -> node packages/slave
npx clusterioctl -> node packages/ctl
Note that to use `clusterioctl` you will have to create a control config first:
node packages/master bootstrap create-ctl-config [admin-user]
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
For web development on the master there are also the following flags:
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
These options can be combined and multiple plugins can be actived for dev mode.
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the master without these flags again.
### Starting a Feature Branch
Development is done exclusively in feature branches. This means the
first thing to do before starting any development at all is to make a
new branch starting from the master or 1.2.x branch of the main
repository. To do this check out that branch from the main repository
(substitute master for 1.2.x if you're working on that branch.)
Development is done exclusively in feature branches.
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
To do this check out that branch from the main repository.
git fetch upstream
git checkout upstream/master
This will give a warning about detached HEAD which means you did it
correctly. Now you can make a feature branch based on this with.
This will give a warning about detached HEAD which means you did it correctly.
Now you can make a feature branch based on this with.
git checkout -b my-feature-branch
The name of the branch is not important, as long as it's not a name
you've used before. But it's nice to have its name be somewhat
descriptive of what it implements.
The name of the branch is not important, as long as it's not a name you've used before.
But it's nice to have its name be somewhat descriptive of what it implements.
### Checking your code for errors
There's automated tests and linting setup for the project. Please do
add tests for any code you add and run both:
There's automated tests and linting setup for the project.
Please do add tests for any code you add and run both:
npm run test
npm run lint
pnpm run test
pnpm run lint
To check that your changes pass the integration tests as well as the
ESLint rules set up for the project.
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
### Submitting a Pull Request
Once you've commited changes to your feature branch that you want to
have included into the main repository push it to you fork with
Once you've commited changes to your feature branch that you want to have included into the main repository push it to you fork with
git push origin my-feature-branch
and make a pull request through the GitHub web interface. You can also
pass the `--set-upstream` (or `-u`) flag to make `git push` do the same
thing for this branch. After your feature branch have been merged you
should consider it final. If there are more changes you want to have
merged or mistakes you need to fix you must start a new feature branch
and submit a new pull request
and make a pull request through the GitHub web interface.
You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the same thing for this branch.
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
### Editing your Feature Branch
Until your feature branch is merged you can edit it as you see fit by
using the rebase tool. If your feature branch has been merged however
it's final and you will have to start a new feature branch and submit
that in a new pull request. The base usage of rebase assuming you are
checked out on your feature branch is the following command (substitute
master with 1.2.x if your feature branch is based on that)
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
If your feature branch has been merged however it's final and you will have to start a new feature branch and submit that in a new pull request.
The base usage of rebase assuming you are checked out on your feature branch is the following command
git rebase -i upstream/master
This will open up an editor with the commits of your feature branch and
let you reorder, edit, and remove commits as you see fit.
This will open up an editor with the commits of your feature branch and let you reorder, edit, and remove commits as you see fit.
If the main repository has been updated and you have fetched down those
updates with `git fetch upstream` this will also move your feature
branch so that it is based on the latest commit from the main
repository.
If the main repository has been updated and you have fetched down those updates with `git fetch upstream` this will also move your feature branch so that it is based on the latest commit from the main repository.
If you have already pushed your feature branch to your fork a simple
`git push origin my-feature-branch` will no longer work as the histories
are different, instead you will have to run
If you have already pushed your feature branch to your fork a simple `git push origin my-feature-branch` will no longer work as the histories are different, instead you will have to run
git push origin +my-feature-branch
The + sign signifies a force push and should be used with care. If you
have a pull request that has not yet been merged submitted for this
feature branch this will update the pull request too.
The + sign signifies a force push and should be used with care.
If you have a pull request that has not yet been merged submitted for this feature branch this will update the pull request too.
### Resolving Merge Conflicts
The best way to resolve merge conflicts in feature braches is to rebase
it on top of the updated upstream. Run the following commands
(substitute master with 1.2.x if your feature branch is based on that)
The best way to resolve merge conflicts in feature braches is to rebase it on top of the updated upstream.
Run the following commands
git fetch upstream
git rebase upstream/master
This will stop on the commit(s) that caused the merge conflict and let
you resolve them manually before continuing. Once you're done force
push your updated feature branch with
This will stop on the commit(s) that caused the merge conflict and let you resolve them manually before continuing.
Once you're done force push your updated feature branch with
git push origin +my-feature-branch
### Updating Your GitHub Fork
No. This is pointless busywork that accomplishes nothing. Fetch the
main repository directly as described above and base your feature
branches on the refs from it. GitHub has no interface or functionality
for keeping the branches in your fork up to date with the branches in
the main repository and there is no point in doing this for our feature
branch based workflow.
No.
This is pointless busywork that accomplishes nothing.
Fetch the main repository directly as described above and base your feature branches on the refs from it.
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
Changelog
---------
## Changelog
There's a [Changelog](../CHANGELOG.md) in the root of the project. If
you make changes that are visible to users of this project you should
add an entry to the changelog describing what has changed. The format
of this log should be self explanatory, please follow it carefully.
There's a [Changelog](../CHANGELOG.md) in the root of the project.
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
The format of this log should be self explanatory, please follow it carefully.
Supported Node.js and Factorio Versions
---------------------------------------
## Supported Node.js and Factorio Versions
Clusterio run on Node.js v12 and up, and v10 from v10.16.0, make sure
to check that the Node builtin API's and npm packages you use are
supported on these versions. The Travis CI tests are runned on whatever
version of Node.js v10 is the default, which at the time of writing is
v10.18.0.
Clusterio run on Node.js v12 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The GitHub Actions tests are run on whatever version of Node.js v12, v14, and v16 is the default.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up,
including the latest experimental release. It's recommended that you
use the lua API reference for 0.17.69, as there's no information on what
version Factorio API's were introduced in. The Travis CI tests are
runned against latest experimental release.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The GitHub Actions tests are run against the latest experimental release.
Code Style
----------
## Code Style
The style of the code is a bit of a mixed bag at the moment. But there
are at least a few things that have been agreed upon.
The style of the code is a bit of a mixed bag at the moment.
But there are at least a few things that have been agreed upon.
### Naming style
JavaScript variables in general uses camelCase. The exception is for
classes/constructors which use PascalCase and variables destructured
from over the wire objects.
JavaScript variables in general uses camelCase.
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
Fields in configs and messages sent over links uses
lowercase_underscore.
Fields in configs and messages sent over links uses lowercase_underscore.
JavaScript files are named using lowercase_underscore for files that
export multiple items. Files containing and exporting a single class
should be named the same as the class in PascalCase.
JavaScript files are named using lowercase_underscore for files that export multiple items.
Files containing and exporting a single class should be named the same as the class in PascalCase.
Imported modules from the lib package are prefixed with lib to make them
easier to distinguish, as the names tend to be very generic. E.g.:
Imported modules from the lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
const libLink = require("lib/link");
const libLuaTools = require("lib/lua_tools");
const libLink = require("@clusterio/lib/link");
const libLuaTools = require("@clusterio/lib/lua_tools");
For lua code lowercase_underscore is used for everything.
@@ -232,24 +217,22 @@ For lua code lowercase_underscore is used for everything.
### Strings
JavaScript and Lua code should use double quoted strings (`"string"`)
for all strings with the only exception that strings that contain double
quotes may use single quotes (i.e., `'String with "quotes"'`).
JavaScript and Lua code should use double quoted strings (`"string"`) for all strings with the only exception that strings that contain double quotes may use single quotes (i.e., `'String with "quotes"'`).
JavaScript template literals may also be used where appropriate.
### Indenting
JavaScript code should be indented with tabs.
JavaScript and Lua code should be indented with tabs.
Lua code should be indented with 4 spaces.
Other files types should prefer tabs for indents where reasonable with the exception of Markdown.
### Line Length
Lines are limited to 120 characters where tabs count as 4 characters.
If the argument list for a function or expressino for compound statement
gets very long break them out on their own line(s). For example:
If the argument list for a function or expressino for compound statement gets very long break them out on their own line(s).
For example:
function foo(
really, long, argument, list,
@@ -262,3 +245,61 @@ gets very long break them out on their own line(s). For example:
// code
}
})
### Markdown
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
For example:
```md
# A markdown File
Sentence of text which end in a period.
Each sentence is on it's own line.
And there's two spaces before the next heading.
## Subheading
### Sub-subheading
Headings use the `# heading` format.
```
## Pushing releases to npm
***Warning: This workflow is outdated due to lerna having been removed from the project.****
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
```sh
git switch master
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`, otherwise they will be auto commited!
2. Run a lerna version to create and tag a new version.
This will push a commit to the master branch.
```sh
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
```
3. Run a full build in every package to update the dist/ files.
```sh
npx lerna run build
```
4. Publish the packages with lerna.
```sh
npx lerna publish from-package
```
+105 -195
View File
@@ -1,13 +1,9 @@
Developing for Clusterio
========================
# Developing for Clusterio
This document describes the various different types of content that can
be created for Factorio and/or Clusterio and how this is made compatible
with Clusterio.
This document describes the various different types of content that can be created for Factorio and/or Clusterio and how this is made compatible with Clusterio.
Contents
--------
## Contents
- [Factorio Mods](#factorio-mods)
- [Factorio Scenarios](#factorio-scenarios)
@@ -18,35 +14,21 @@ Contents
- [Clusterio Plugins](#clusterio-plugins)
Factorio Mods
-------------
## Factorio Mods
Factorio mods that do not interact with Clusterio should not have any
considerations that need to be taken into account for them to work with
Clusterio.
Factorio mods that do not interact with Clusterio should not have any considerations that need to be taken into account for them to work with Clusterio.
Factorio Scenarios
------------------
## Factorio Scenarios
Clusterio patches saves and because of this scenarios have a few
limitations and must explicitly mark themselves as compatible with
Clusterio to work. The main limitation is that the
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
lib must be used, and control.lua cannot contain any code other than
calls to event_handler loading the relevant libs of the scenario.
This is because the control.lua file will be overwritten by the save
patcher. Scenarios also cannot use the event registering functions
exposed by `script` such as `script.on_event` or `script.on_nth_tick` as
using these will overwrite the handlers registered by the
event_handler lib.
Clusterio patches saves and because of this scenarios have a few limitations and must explicitly mark themselves as compatible with Clusterio to work.
The main limitation is that the [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) lib must be used, and control.lua cannot contain any code other than calls to event_handler loading the relevant libs of the scenario.
This is because the control.lua file will be overwritten by the save patcher.
Scenarios also cannot use the event registering functions exposed by `script` such as `script.on_event` or `script.on_nth_tick` as using these will overwrite the handlers registered by the event_handler lib.
A brief description of the usage of the event_handler library is
provided in the [event_handler interface](#event_handler-interface)
section.
A brief description of the usage of the event_handler library is provided in the [event_handler interface](#event_handler-interface) section.
Once a scenario is made using the event_handler it should have a
control.lua file that looks something like this:
Once a scenario is made using the event_handler it should have a control.lua file that looks something like this:
```lua
-- control.lua
@@ -55,8 +37,7 @@ event_handler.add_lib(require("module_foo"))
event_handler.add_lib(require("module_bar"))
```
To make Clusterio recognize and correctly handle this scenario it
needs to include a clusterio.json file with following content:
To make Clusterio recognize and correctly handle this scenario it needs to include a clusterio.json file with following content:
```json
{
@@ -70,60 +51,43 @@ needs to include a clusterio.json file with following content:
}
```
This tells Clusterio that it should load both module_foo and module_bar
by passing the result of requiring them to the `add_lib` function when
writing a new control.lua file to the save.
This tells Clusterio that it should load both module_foo and module_bar by passing the result of requiring them to the `add_lib` function when writing a new control.lua file to the save.
### event_handler interface
<sub>**Note:** At the time of writing the event_handler lib has been
a part of Factorio since at least 0.17.4, and was moved from base to
core in 0.17.63 but is still not documented anywhere. A brief
description is provided here for this reason.</sub>
<sub>**Note:** At the time of writing the event_handler lib has been a part of Factorio since at least 0.17.4, and was moved from base to core in 0.17.63 but is still not documented anywhere.
A brief description is provided here for this reason.</sub>
The event_handler lib provides a simple interface for registering
multiple callbacks to the same events without having to be concerned
with callback chaining or overwriting callbacks defined elsewhere. It
works by taking over the task of registering the actual callbacks with
Factorio, providing its own interface for the rest of the code to use.
The event_handler lib provides a simple interface for registering multiple callbacks to the same events without having to be concerned with callback chaining or overwriting callbacks defined elsewhere.
It works by taking over the task of registering the actual callbacks with Factorio, providing its own interface for the rest of the code to use.
The sole function of interest exported by event_handler is `add_lib`
which accepts a table with event handler callbacks definitions that it
will register. The following entries are recognized by `add_lib`, all
of which are optional:
The sole function of interest exported by event_handler is `add_lib` which accepts a table with event handler callbacks definitions that it will register.
The following entries are recognized by `add_lib`, all of which are optional:
- `on_init`:
Callback called when the callback of `script.on_init` is invoked.
- `on_load`:
Callback called when the callback of `script.on_load` is invoked.
- `on_configuration_changed`:
Callback called when the callback of
`script.on_configuration_changed` is invoked.
Callback called when the callback of `script.on_configuration_changed` is invoked.
- `events`:
Table mapping event ids as defined in `defines.events` to callbacks.
For example the table `{ [defines.events.on_player_died] = foo }`
will call bar with the usual `event` table as argument every time a
player dies.
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
- `on_nth_tick`:
Table mapping the nth tick number to a callback. For example the
table `{ [30] = bar }` will call bar every 30th tick.
Table mapping the nth tick number to a callback.
For example the table `{ [30] = bar }` will call bar every 30th tick.
- `add_remote_interface`:
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
has no special meaning and receives no arguments but should be used
for registering remote interfaces.
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
It has no special meaning and receives no arguments but should be used for registering remote interfaces.
- `add_commands`:
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
has no special meaning and receives no arguments but should be used
for registering commands.
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
It has no special meaning and receives no arguments but should be used for registering commands.
<sub>1: Before 0.17.69 these callbacks were called after
on_init/on_load.</sub>
<sub>1: Before 0.17.69 these callbacks were called after on_init/on_load.</sub>
The usual way to use `add_lib` is to define the table of events to
register in a separate file and return it, then load it in control.lua
via `require` before passing it to `add_lib`. For example a module
could be defined as
The usual way to use `add_lib` is to define the table of events to register in a separate file and return it, then load it in control.lua via `require` before passing it to `add_lib`.
For example a module could be defined as
```lua
-- example.lua
@@ -148,41 +112,27 @@ local event_handler = require("event_handler")
event_handler.add_lib(require("example"))
```
Because the event_handler lib registers events itself you may not use
`script.on_load` or `script.on_init` at all in your code and any usage
of `script.on_configuration_changed`, `script.on_nth_tick` and/or
`script.on_event` will cause the corresponding events registered with
event_handler to break and should therefore not be used.
Because the event_handler lib registers events itself you may not use `script.on_load` or `script.on_init` at all in your code and any usage of `script.on_configuration_changed`, `script.on_nth_tick` and/or `script.on_event` will cause the corresponding events registered with event_handler to break and should therefore not be used.
There's also the `add_libraries` function exported by event_handler,
which accepts a table and calls `add_lib` for each value in the table.
There's also the `add_libraries` function exported by event_handler, which accepts a table and calls `add_lib` for each value in the table.
Communicating with Clusterio
----------------------------
## Communicating with Clusterio
There's a poorly to virtuarly undocumented HTTP interface that can be
used to communicate with Clusterio to do things like get the status of
instances, grab items from the store, run commands on instances, etc,
etc. For now you can read the source code, and maybe ask on the Discord
for some pointers if you get stuck.
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [socket.md](socket.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
Clusterio Modules
-----------------
## Clusterio Modules
Modules are primarily used by plugins to inject code into Factorio games
with the save patcher, though it's also possible to make stand alone
modules that are loaded into the game if you don't need the capabilites
of the plugin system. The save patcher puts modules into the `modules`
folder of the Factorio save and adds code to `control.lua` to load the
module according to the `load` and `require` options to the module.json
file.
Modules are primarily used by plugins to inject code into Factorio games with the save patcher, though it's also possible to make stand alone modules that are loaded into the game if you don't need the capabilites of the plugin system.
The save patcher puts modules into the `modules` folder of the Factorio save and adds code to `control.lua` to load the module according to the `load` and `require` options to the module.json file.
Like with scenarios for Clusterio the [event_handler interface](#event_handler-interface) has to be used for any event subscriptions in modules.
Stand alone modules are placed into the modules folder of Clusterio,
plugin modules are located in the module folder of the plugin. In
either case a `module.json` file is required and has the following
structure:
Stand alone modules are placed into the modules folder of Clusterio, plugin modules are located in the module folder of the plugin.
In either case a `module.json` file is required and has the following structure:
```json
{
@@ -200,68 +150,44 @@ structure:
The following entries are supported in the module.json file:
- `name`:
Name of the module, must match the folder the module is located in
for stand alone modules or the name of the plugin the module is
located in.
Name of the module, must match the folder the module is located in for stand alone modules or the name of the plugin the module is located in.
- `version`:
Version of the module. Must be compatible with [Semantic Versioning
2.0.0](https://semver.org/). In plugin modules this defaults to the
version of the plugin otherwise it's required.
Version of the module.
Must be compatible with [Semantic Versioning 2.0.0](https://semver.org/).
In plugin modules this defaults to the version of the plugin otherwise it's required.
- `dependencies`:
Optional mapping of modules this module depends on and their
version. In the example `>=0.4.2` means that the foo module must be
at least version 0.4.2. See the [Ranges
syntax](https://www.npmjs.com/package/semver#ranges) of the
node-semver package for a full description of what operators are
supported. The events for dependencies is invoked before the events
of the dependent, and starting an instance will fail if the
dependencies cannot be satisfied. If not specified it will default
to depending on `clusterio`. Make sure to add `clusterio` to your
dependencies if you add other dependencies and depend on the Lua API.
Optional mapping of modules this module depends on and their version.
In the example `>=0.4.2` means that the foo module must be at least version 0.4.2.
See the [Ranges syntax](https://www.npmjs.com/package/semver#ranges) of the node-semver package for a full description of what operators are supported.
The events for dependencies is invoked before the events of the dependent, and starting an instance will fail if the dependencies cannot be satisfied.
If not specified it will default to depending on `clusterio`.
Make sure to add `clusterio` to your dependencies if you add other dependencies and depend on the Lua API.
- `require`:
Lua files to require in `control.lua`. This should only really be
needed if you want to make a global function available for use in
Lua commands. The paths are relative to the module's own folder and
should be specified using forward slashes as directory sepparators
if it's located in a sub directory in the module.
Lua files to require in `control.lua`.
This should only really be needed if you want to make a global function available for use in Lua commands.
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
- `load`:
Lua files to load with the `event_handler` lib, the result of
requiring the file will be passed to the `add_lib` function of the
`event_handler` lib in `control.lua`. See the section on the
[event_handler interface](#event_handler-interface) for a detailed
description on how the `event_handler` lib works. The paths are
relative to the module's own folder and should be specified using
forward slashes as directory sepparators if it's located in a sub
directory in the module.
Lua files to load with the `event_handler` lib, the result of requiring the file will be passed to the `add_lib` function of the `event_handler` lib in `control.lua`.
See the section on the [event_handler interface](#event_handler-interface) for a detailed description on how the `event_handler` lib works.
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
It's recommended to use the new style of defining modules in Lua, as
well as avoid the use of global variables as much as possible. This
means always declearing your top level variables and functions local and
exporting only the things you need in other files. You can require
other files in your module by prefixing your require paths with
`modules/your_module_name/`. It's also possible to require files from
other modules this way too.
It's recommended to use the new style of defining modules in Lua, as well as avoid the use of global variables as much as possible.
This means always declearing your top level variables and functions local and exporting only the things you need in other files.
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
It's also possible to require files from other modules this way too.
The global variables and data stored in the global table is shared by
all Clusterio modules as well as the scenario, so it's important that
you use unique names. It's recommended to prefix the global variables
and data in the global table that your module has with the name of your
module.
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
Because modules can be patched into an existing game you cannot rely on
the `on_init` callback to be called in Clusterio Modules. Nor can you
rely on the `on_configuration_changed` callback, as this is not called
when level code changes. The Clusterio Lua API provides the custom
`on_server_startup` event that can be used as a substitute, see the next
section.
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
Clusterio Lua API
-----------------
## Clusterio Lua API
Clusterio provides a save patched module as well as a regular Factorio
mod for interfacing with Clusterio. This includes tools sending data to
plugins, script events to listen for, and an item serialization module.
Clusterio provides a save patched module as well as a regular Factorio mod for interfacing with Clusterio.
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
### clusterio_api library
@@ -272,30 +198,27 @@ From a module
From a mod
`local clusterio_api = require("__clusterio_lib__/api")`
Provides the main interface to Clusterio from within the game. When
using the mod version you must call `init` before it'll function
properly.
Provides the main interface to Clusterio from within the game.
When using the mod version you must call `init` before it'll function properly.
#### init
<sub>Mod version only</sub>
Initialize the API. This should be called from both the `on_init` and
`on_load` events in your mod, and before this is called certain features
will not be available, most notably the events table.
Initialize the API.
This should be called from both the `on_init` and `on_load` events in your mod, and before this is called certain features will not be available, most notably the events table.
#### clusterio_api.events
Table of events raised by clusterio. If the game is started outside of
Clusterio then none of these events will be raised.
Table of events raised by clusterio.
If the game is started outside of Clusterio then none of these events will be raised.
In the mod version this table does not exist before the `init` function
is called.
In the mod version this table does not exist before the `init` function is called.
##### on_instance_updated
Raised after the name and id of an instance has been updated. This may
occur even if the id and name didn't change.
Raised after the name and id of an instance has been updated.
This may occur even if the id and name didn't change.
Event data:
- `instance_id`: the id of the instance.
@@ -303,33 +226,26 @@ Event data:
##### on_server_startup
Raised when Clusterio is starting up the server. It can be used to
initialize clusterio related features in a mod, and as a stand-in for
the `on_init` and `on_configuration_changed` event for modules. It is
invoked on the first tick the server runs after the save has been
patched, before most other events.
Raised when Clusterio is starting up the server.
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
It is invoked on the first tick the server runs after the save has been patched, before most other events.
Use this event to initialize and/or migrate the data structures
you need. Keep in mind that both mods and Clusterio modules can switch
from any version to any version so it should be able to handle both
forwards and backwards migrations gracefully.
Use this event to initialize and/or migrate the data structures you need.
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
#### clusterio_api.send_json(channel, data)
Send JSON data to Clusterio over the given channel. The `data` argument
should be table that can be serialized with `game.table_to_json`.
Clusterio plugins can listen to channels and will receive an event with
the data sent here. See the [Communicating with Factorio
section](writing-plugins.md#communicating-with-factorio) in the Writing
Plugins document for more information.
Send JSON data to Clusterio over the given channel.
The `data` argument should be table that can be serialized with `game.table_to_json`.
Clusterio plugins can listen to channels and will receive an event with the data sent here.
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
If the game is started outside of Clusterio the data will be sent, but
since there's no code following stdout to pick it up it will be lost.
If the game is started outside of Clusterio the data will be sent, but since there's no code following stdout to pick it up it will be lost.
**Note**: Payloads greater than 4 MB will cause stuttering in the game.
**Note**: This is not a binary safe way of sending data. Strings
embedded into the tables sent must be valid UTF-8 text.
**Note**: This is not a binary safe way of sending data.
Strings embedded into the tables sent must be valid UTF-8 text.
Parameters:
- `channel`: string identifying which channel to send it on.
@@ -344,19 +260,17 @@ From a module
From a mod
`local serialize = require("__clusterio_lib__/serialize")`
Serializes inventories and item stacks into a table that in turn can be
converted into JSON with `game.table_to_json`. Properly handles the
extra data for most items.
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
Properly handles the extra data for most items.
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
Returns a table containing a serialized representation of the equipment
grid passed.
Returns a table containing a serialized representation of the equipment grid passed.
#### serialize.deserialize_equipment_grid(LuaEquipmentGrid, serialized_grid)
Deserialize a previously serialized equipment grid into the target
LuaEquipmentGrid. Overwrites all content in the target.
Deserialize a previously serialized equipment grid into the target LuaEquipmentGrid.
Overwrites all content in the target.
#### serialize.serialize_item_stack(LuaItemStack, destination_table)
@@ -365,23 +279,19 @@ The destination_table should be a new empty table.
#### serialize.deserialize_item_stack(LuaItemStack, serialized_stack)
Deserialize a previously serialized item stack into the target
LuaItemStack. Overwrites the content of the item stack.
Deserialize a previously serialized item stack into the target LuaItemStack.
Overwrites the content of the item stack.
#### serialize.serialize_inventory(LuaInventory)
Returns a table containing a serialized representation of the inventory
passed.
Returns a table containing a serialized representation of the inventory passed.
#### serialize.deserialize_inventory(LuaInventory, serialized_inventory)
Deserialize a previously serialized inventory into the target
LuaInventory. Overwrites slots that have content in the serialized
inventory.
Deserialize a previously serialized inventory into the target LuaInventory.
Overwrites slots that have content in the serialized inventory.
Clusterio Plugins
-----------------
## Clusterio Plugins
See the [Writing Plugins](writing-plugins.md) document for details on
the plugin interface and how to write plugins for Clusterio.
See the [Writing Plugins](writing-plugins.md) document for details on the plugin interface and how to write plugins for Clusterio.
+268
View File
@@ -0,0 +1,268 @@
# Protocol
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
Two general kinds of messages are supported: events and requests.
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
Requests are pairs of request-response messages where one side sends the requests and the other side replies with a response.
Apart from the handshake used to establish connections the protocol is directionless, and either party can send or receive both requests and events.
Events and requests are defined as instances of the `Request` and `Event` classes, and specify a unique type string, who can send and receive the message, optional forwarding of the message and the format of the data payload as a JSON schema.
All messages sent and received are validated with JSON schemas to catch potential errors in sending messages and avoid having to write lots of validation code in message handlers.
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
For the master server there's a `Link` instance for each slave and control connection stored in the `WsServer` class.
For slaves the `Slave` instance is a link to the master server, and each `Instance` instance is an in-memory link to the `Slave` instance and has a corresponding `InstanceConnection` link stored on the `Slave` instance.
For ctl and web_ui there's a `Control` instance that acts as the link to the master server.
Messages received invokes message handlers that were registered when the link was brought up.
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
The handlers are defined on the link class itself for core messages, and on the plugin class in the case of plugin defined messages.
## Connection flow
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
If the client has no previous session with the server it sends a `register_slave` or `register_control` message depending on what kind of client it is to the server.
If the register is valid then the server will respond with a `ready` message with a session token and the connection has left the handshake phase, otherwise the server will close the connection with a WebSocket close code indicating the kind of failure.
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
Messages sent over the link should be stored in a send buffer and only cleared from the send buffer when a heartbeat with a `last_seq` equal or or higher is received or the session is invalidated.
If the connection goes stale or is unexpectedly closed the client can reastablish the same session by connecting to the server again and sending a `resume` message after received the `hello` from the server.
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
The client should then re-send all messages it has in its send buffer that is greater than the `last_seq` number in the `continue` message.
Otherwise the server will respond with `invalidate` and the client is expected to drop its send buffer start a new session with `register_slave` or `register_control`.
![Connection flow](../assets/connection_flow.svg)
## Connector flow
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
There's three kinds of connectors defined: `WebSocketClientConnector`, `WebSocketServerConnector` and `VirtualConnector`.
The virtual connector is for in-memory connections and doesn't have any connection state logic.
For the WebSocket connectors there's four states they can exist in:
- `closed` - The connector has just been created or has been explicitly closed.
- `connecting` (client only) - The connector is in the process of trying to connect to the server.
- `connected` - The connector is successfully connected.
- `resuming` - The connection dropped and the connector is in the process of attempting to resume it.
Along with the following events emitted when transition between states:
- `connect` From `connecting` (client) or `closed` (server) to `connected`
- `drop` - From `connected` to `resuming`
- `resume` - From `resuming` to `connected`
- `invalidate` - From `resuming` to `connecting` or `closed`
- `close` - From any state to `closed`
![Conector States](../assets/connector_states.svg)
### WsClientConnector
The client connector will keep trying to establish and mantain a connection after `.connect()` is called on it, stopping only in the case of authentication failure or `.close()`/`.setClosing()` being called.
Other than that, the logic is pretty straight forward.
![WsClientConnector](../assets/WsClientConnector.svg)
### WsServerConnector
The server connector doesn't do the work of connecting or validating the handshake.
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
![WsServerConnector](../assets/WsServerConnector.svg)
## Wire format
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
- `type: string` - The type of message
- `data: object` - Message data
Before a connection is established it's in the handshake phase and the `seq` property is `null`.
The `type` property is used to distinguish which of handshake, heartbeat, events or requests this message is and determines the expected format of the `data` property.
### Handshake Messages
The following messages are the only valid messages that can be sent during the handshake phase.
#### `hello`
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<string, string>` -
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
#### `register_slave`
Slave handshake for establishing a new connection session.
- `token: string` - Authentication token.
- `agent: string` - Human readable string of the software connecting.
- `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<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the slave.
#### `register_control`
Control handshake for enstablishing a new connection session.
- `token: string` - Authentication token.
- `agent: string` - Human readable string of the software connecting.
- `version: string` - The protocol version of the control, e.g. "2.0.0".
#### `resume`
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
- `session_token: string` - Session token to resume session with.
- `last_seq: integer | null` - Last message seq received by the client.
#### `invalidate`
Sent by the master server in reply to a resume for a session that cannot be resumed.
The client should start a new session with a register\_\* message in response.
#### `continue`
Sent by the master server to signal resuming the session was successful.
- `last_seq: integer | null` - Last message seq received by the server.
- `session_timeout: integer` - Session timeout in seconds.
- `heartbeat_interval: number` - Rate client should send heartbeats at.
#### `ready`
Sent by the master server to signal the connection has been established with a new session.
- `session_token: string` - Token to resume the session with.
- `session_timeout: integer` - Session timeout in seconds.
- `heartbeat_interval: number` - Rate client should send heartbeats at.
### Heartbeat message
Once connected as indicated by the `ready` and `continue` messages both sides of the connection starts sending heartbeats at the inverval given in the `ready` and `continue` messages.
The heartbeat should contain the `seq` of the last received message, and receiving a heartbeat with a given sequence number means that the other party of the link has received all messages up to that seq number.
#### `heartbeat`
Sent by both the master server and clients at the interval given in the continue/ready messages.
- `last_seq: integer` - Last message seq received over the link.
### Events
Events are messages send in one direction over the link, that the receiver is expected to act upon, but not make any replies (for that there's [Requests](#requests).
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
Plugins may defined their own events as well.
#### `*_event`
- any properties defined by the event type.
### Requests
Requests are messages which expects a response in return.
They function a lot like HTTP requests except that both parties of a connection can inniate a request that is expected to be responded to.
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
Plugins may define their own requests as well.
#### `*_request`
- any properties defined by the request type.
#### `*_response` success
- `seq: integer` - The seq number of the request message.
- any additional properties defined by the request type.
#### `*_response` error
- `seq: integer` - The seq number of the request message.
- `error: string` - Human readable text message describing the error.
### Graceful Disconnect
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
Either party of the link can send this request and the party sending the request should be the one that initates the close of the WebSocket.
#### `prepare_disconnect_request`
Request graceful disconnect of the connection.
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
Only after this process is done should the response be sent.
#### `prepare_disconnect_response`
Signals the preparation for disconnect is complete and that the connection can be closed.
The party that initiatied the prepare disconnect request may still send other requests/events over the link to finish its part of the disconnection proceedure.
If the error response variant is received it should be logged if possible and treated as a success.
### Example handshake
Server hello after opening the WebSocket.
```json
{
"seq": null,
"type": "hello",
"data": {
"version": "2.0.0"
}
}
```
Slave registering a new connection session.
```json
{
"seq": null,
"type": "register_slave",
"data": {
"token": "xyzabc123",
"agent": "Clusterio Slave",
"version": "2.0.0",
"name": "Foo",
"id": 123
}
}
```
Master server reply signaling the session is established and ready.
```json
{
"seq": null,
"type": "ready",
"data": {
"session_token": "xyz.azy.bzy",
"session_timeout": 60,
"heartbeat_interval": 15
}
}
```
Slave and master can now send events and requests over the connection.
+10 -24
View File
@@ -1,30 +1,16 @@
Save Patching
-------------
## Save Patching
In order to efficiently get data in and out of running Factorio servers
and to provide custom interfaces Clusterio uses save
patching<sup>[1]</sup> to add code to the game. The save patching is
based on the built in
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
library to Factorio and requires scenarios to be coded specifically for
it. Mods on the other hand work without modifications.
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
The save patching is based on the built in [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) library to Factorio and requires scenarios to be coded specifically for it.
Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on
a per instance basis, however disabling it means that modules are not
loaded into the game and most plugins will not function.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
<sub>1: Before version 2.0 of Clusterio this was done at runtime
with a patcher called Hotpatch, but due to the difficulty in supporting
run time patching and the lack of documentation it was replace it with
save patching instead.</sub>
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
User Accounts
-------------
## User Accounts
The master server creates and stores accounts for all Factorio users
that log into instances under its control. These accounts are used to
store per player data and authorize management of the cluster. By
default users accounts are assigned a Player role that gives a limited
read access to the cluster resources, and admins of the cluster can be
assigned an Admin role to get access to manage it.
The master server creates and stores accounts for all Factorio users that log into instances under its control.
These accounts are used to store per player data and authorize management of the cluster.
By default users accounts are assigned a Player role that gives a limited read access to the cluster resources, and admins of the cluster can be assigned a Cluster Admin role to get access to manage it.
+23 -21
View File
@@ -1,23 +1,25 @@
{
"plugins": [
"plugins/markdown"
],
"tags": {
"allowUnknownTags": false
},
"opts": {
"destination": "docs/api",
"recurse": true
},
"source": {
"include": [
"packages/master/src/",
"packages/master/master.js",
"packages/slave/slave.js",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
],
"excludePattern": "node_modules"
}
"plugins": [
"plugins/markdown"
],
"tags": {
"allowUnknownTags": false
},
"opts": {
"destination": "docs/api",
"recurse": true
},
"source": {
"include": [
"packages/master/src/",
"packages/master/master.js",
"packages/slave/src/",
"packages/slave/slave.js",
"packages/ctl/src/",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
],
"excludePattern": "node_modules"
}
}
+132 -63
View File
@@ -1,39 +1,100 @@
Managing a Cluster
==================
# Managing a Cluster
Clusterio clusters are managed through the master server by using the
`clusterioctl` command line interface which is invoked by running
`npx clusterioctl <command>` in the clusterio directory. This document
uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should
be executed. Mandatory parameters are shown in `<angles bracket>` and
optional pameters are in `[square brackets]`.
Clusterio clusters are managed through the master server by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should be executed.
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the
cluster it will connect to. The easiest way to do this is to run
`npx clusteriomaster bootstrap create-ctl-config <username>` on the
master server, which creates the necessary `config-control.json` for
managing the cluster as the given user.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-config <username>` on the master server, which creates the necessary `config-control.json` for managing the cluster as the given user.
Slaves
------
## Slaves
### List slaves
ctl> slave list
Lists all slaves.
### Generate token
ctl> slave generate-token [--id <slave id>]
Generate a token for a slave. `--id` is the slave id.
### Create config
ctl> slave create-config [--id <slave id>] [--name <slave name>] [--generate-token <bool>] [--output <path to output>]
Create a slave config. `--id` id the slave id. `--name` is the slave name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-slave.json", "-" for stdout.
## Instances
### List Instances
ctl> instance list
List instances.
### Create instance
ctl> instance create <name> [--id <instance id>]
Create a new instance. `name` is the instance name. `--id` is the optional id.
### Kill instance
ctl> instance kill <name>
Terminates the running Factorio server without giving it a chance to save or cleanup after itself.
Note: This may cause loss of data.
### Extract players from instance
ctl> instance extract-players <name>
Creates a user account in the cluster for each player that has been online on the currently running save of the instance and sets the online time of that account on this instance to the online time recorded in the save.
Useful when importing a save to a cluster.
Note that the online time in a save is recorded differntly from how Clusterio records online time.
Most notably Clusterio records wall clock time while the save time is recorded in ticks and this extraction of player time assumes the game runs at 60 ticks a second.
### Config management
#### List Configuration
ctl> instance config list <instance>
List config values for an instance. `instance` is the instance whose config values to list.
#### Set config value
ctl> instance config set <instance> <field> [value] [--stdin]
Set field in instance config. `instance` is instance to set. `field` is field to set. `value` is value to set in field. `--stdin` signals to read value from stdin.
#### Set config property of field
To be written.
#### Edit instance config
Instances
---------
ctl> instance config edit <instance> [editor]
To be written.
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
### Assign instance to slave
Users
-----
ctl> instace assign <instance> [slave]
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.
Assigns instance to slave. `instance` is instance to assign, `slave` is slave to assign to. If `slave` is not provided, instance will be unassigned.
## 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
@@ -46,40 +107,32 @@ Lists all user accounts in the cluster along with some data for each.
ctl> user create <name>
Creates a new empty user account for the given Factorio player. Note
that case matters here. This is usually not required as user accounts
are created automatically for players that join instances with save
patching enabled.
Creates a new empty user account for the given Factorio player.
Note that case matters here.
This is usually not required as user accounts are created automatically for players that join instances with save patching enabled.
### Promote User to Server Admin
ctl> user set-admin <name> [--revoke] [--create]
Promotes the user given to in-game admin on instances with the
`sync_adminlist` option enabled. If the `--revoke` switch is used the
user is removed from the adminlist.
Promotes the user given to in-game admin on instances with the `sync_adminlist` option enabled.
If the `--revoke` switch is used the user is removed from the adminlist.
Since admin status is a part of the account data the account must exist
for this to succeed, passing `--create` will create the account if it
does not exist.
Since admin status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
**Note:** Being a server admin does not grant any access to manage the
cluster. See [set-roles](#set-cluster-roles) for adding roles which
grant access to managing the clusetr.
**Note:** Being a server admin does not grant any access to manage the cluster.
See [set-roles](#set-cluster-roles) for adding roles which grant access to managing the clusetr.
### Whitelist User
ctl> user set-whitelisted <name> [--remove] [--create]
Add the user given to the whitelist on instances with the
`sync_whitelist` option enabled. If the `--remove` switch is used the
user is removed from the whitelist.
Add the user given to the whitelist on instances with the `sync_whitelist` option enabled.
If the `--remove` switch is used the user is removed from the whitelist.
Since whitelisted status is a part of the account data the account must
exist for this to succeed, passing `--create` will create the account if
it does not exist.
Since whitelisted status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
### Ban User
@@ -87,30 +140,49 @@ it does not exist.
ctl> user set-banned <name> [--reason <message>] [--pardon] [--create]
Ban user in-game from instances with the `sync_banlist` option enabled.
Reason is a message that will be shown to the user when attemption to
log in. If the `--pardon` switch is used it removes the ban.
Reason is a message that will be shown to the user when attemption to log in.
If the `--pardon` switch is used it removes the ban.
Since ban status is a part of the account data the account must exist
for this to succeed, passing `--create` will create the account if it
does not exist.
Since ban status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
Note: This bans the user from logging in to Factorio servers in the
cluster, it does not revoke access to any cluster management they might
have, see next section on setting roles for revoking tha.
Note: This bans the user from logging in to Factorio servers in the cluster, it does not revoke access to any cluster management they might have, see next section on setting roles for revoking tha.
### Bulk importing admin, whitelist and ban lists
Whitelists and admin lists can be bulk imported with the following commands:
ctl> user whitelist-bulk-import <file> [--create]
ctl> user admin-bulk-import <file> [--create]
The file should simply be newline seperated names:
joe
bob
allan
The `--create` option will create the user if they do not exist, and then set the relevent value.
Banlists can be bulk imported with the following command:
ctl> user banned-bulk-import <file> [--create]
The file should be names, followed by a ban reason:
joe greifed copper mines
bob stole materials
allan rude language
The first string on the line is used as the username, the others are used as the ban reason.
The `--create` option will create the user if they do not exist, then ban them.
### 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.
Calling with an empty roles argument will remove all roles from the user.
By default there's a roled named Admin which grants access to
everything and a role named Player which grants a limited read access to
the cluster, see [the section on roles](#roles) for more information
about setting up roles and permissions.
By default there's a roled named Cluster Admin which grants access to everything and a role named Player which grants a limited read access to the cluster, see [the section on roles](#roles) for more information about setting up roles and permissions.
### Delete user
@@ -119,14 +191,11 @@ about setting up roles and permissions.
Deletes everything stored on the master server for this user.
Note: If the player is banned from the cluster this will effectively
unban them, as the ban status is stored with the user account.
Note: If the player is banned from the cluster this will effectively unban them, as the ban status is stored with the user account.
Note: If the player joins the cluster again a new account will be made
for them automatically.
Note: If the player joins the cluster again a new account will be made for them automatically.
Roles
-----
## Roles
To be written.
+72
View File
@@ -0,0 +1,72 @@
# Setting up TLS
By default Clusterio will listen for connections over HTTP which is fine for local networks but over the Internet it should idealy be over HTTPS as there are some credentials that could be exposed to eavesdroppers.
The recommended setup is to have an ordinary HTTPS server software like Apache or Nginx to act as a reverse proxy in front of the Clusterio master server.
The reverse proxy would then be set up with a TLS certificate from a Certificate Authoritiy like [Let's Encrypt](https://letsencrypt.org).
Alternatively you can have Clusterio listen for connections over HTTPS directly by providing it with a TLS certificate and private key.
Note that a TLS certificate signed by a recognized Certificate Authority in general requires that you own a domain.
See the last section for how to create and use a self-signed certificate for an IP address if you don't have a domain.
## Using a reverse proxy
Configure the proxy to forward requests over HTTP to the port the master server is listening to so that for example a request to `https://example.com/instances/` is forwarded to `http://localhost:8080/instances/`.
It is possible to host the interface under a sub-path, in that case a request under the sub-path should be mapped to the master server with the sub-path stripped out.
For example `https://example.com/sub/path/instances/` would be mapped to `http://localhost:8080/instances`.
Additianlly WebSocket connections to `/api/socket` also needs to be forwarded.
Typically this requires explicit configuration and support for forwarding WebSocket connections.
If the interface hosted under a sub-path the `/api/socket` address is relative to the sub-path and the sub-path also needs to be stripped out when forwarded.
You can also optionally set the master server to listen only on the loopback interface to prevent it being directly connected to from the outside.
npx clusteriomaster config set master.bind_address 127.0.0.1
### Apache Config
To proxy with Apache make sure that `proxy_module`, `proxy_http_module` and `proxy_wstunnel_module` is loaded in the config, then add the following directives to wherever is the approriate place for your flavour of Apache config organization.
```apache
<Location /sub/path/>
ProxyPass "http://localhost:8080/"
</location>
<Location /sub/path/api/socket>
ProxyPass "ws://localhost:8080/api/socket"
</Location>
```
Replace `/sub/path/` with the location on the proxy you want to host the Clusterio interface under.
If the whole domain is to be dedicated to the Clusterio interface then `/sub/path/` should be just `/`.
Pay attention to the use of trailing slashes, as they are important.
## Hosting HTTPS directly from Node.js
Listening on HTTPS is supported by the Clusterio master server via the `master.https_port` option provided paths to a certificate file and a private key file is configured for the `master.tls_certificate` and `master.tls_private_key` options.
Both of these are expected to be unencrypted PEM files.
## Creating and using a self-signed certificate
Create a self-signed certificate with OpenSSL by running
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650 -subj /CN=localhost -addext "subjectAltName=IP:<ip>"
substituting `<ip>` with the IP address the server will be accessed under, for example `IP:203.0.113.4`.
Multiple addresses can be given by supplying a comma sepparated list of `IP:address` pairs and DNS names can also be specified using the `DNS:example.com` format.
You need to supply every name you want to connect to the server under the subjectAltName field, for example to connect to the HTTPS port locally via an address like `https://localhost:4443/` you need to add `DNS:localhost`.
This creates a private key file named `key.pem` and a public certificate file named `cert.pem` and these are used for the `master.tls_certificate` and `master.tls_private_key` config options respectively on the master server:
npx clusteriomaster config set master.tls_certificate cert.pem
npx clusteriomaster config set master.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as slaves and configure it as the `slave.tls_ca` option:
npx clusterioslave config set slave.tls_ca cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
npx clusterioctl control-config set control.tls_ca cert.pem
-287
View File
@@ -1,287 +0,0 @@
WebSocket protocol
==================
<sub>This document describes the protocol used between the master and
slaves, and unless you're developing Clusterio it's probably not going
to be very useful for you.</sub>
The protocol is built on WebSocket and uses JSON encoded text messages
to communicate to and from the master server. These messages contain
a JSON encoded object with the following three properties:
- seq - integer|null - The sequence number of the message
- type - string - The type of message
- data - object - Message data
For the handshake and heartbeats the seq property is null.
Handshake
---------
Upon connecting to the WebSocket endpoint the server will respond with a
`hello` message containing the master server version as the data. The
client is then expected to send either `register_*` or a `resume`
message to set up the connection with the master. The exact flow is
described below.
### Client connection flow
For a client making a new connecting to the master server the first
course of action after obtaining a token is to connect with WebSocket to
the /api/socket endpoint and wait for the server hello. After the
hello, the client sends a register\_\* message corresponding to the type
of the client. If successful the server will reply with a ready message
and the connection has been established. See the flowchart below.
![Client connection flowchart](assets/connection_flow.svg)
If for any reason the connection is closed or lost during the connection
attempt the process is started over after a random delay, with the
exception of the connection being closed with a 4003 code which means
authentication failed and the client should stop trying to connect. The
wait for hello and wait for ready states should also have a timeout,
after which the connection is closed if the hello/reply wasn't received
in time.
### Client reconnection flow
If the connection is lost after the client has received the ready
message from the server it should attempt a reconnect instead. This is
very similar to a new connection, but instead of a register\_\* messsage
the client sends a resume message and the server replies with a continue
message if resuming the session was successful. This continue message
has sequence number of the last message the master received and the
client is expected to imediately send all messages from its send buffer
that occured after the sequence number given.
The server will respond with an invalidate message if resuming the
session is not possible. In this case the client should continue from
the new session state of the connection flow (see the previous section).
![Client reconnection flowchart](assets/reconnection_flow.svg)
Like the connection flow the process starts over after a delay if the
connection is closed or lost. The same exception for connection closed
with 4003 applies as with the connection flow. Additionally after an
invalidate message has been received and the connection flow fails, it
should start over from the connection flow.
### Server connection flow
From the master server side new connections and reconnection attempts
start out the same, a new connection has been made and the server sends
the hello messoge to the client. If a vaild register\_\* message is
received a new connection is set up for the client and the ready message
is sent in reply. If a valid resume message is received and the session
that's attempted to be resumed is active it'll signal that the session
is resumed with a continue message and the client upon receiving the
continue message should send the messages from its send buffer from
after the last\_seq given in the continue message.
If the session token used to resume is not valid it'll send an invalidate
message and expect the client to continue with a registr\_\* message. If an
invalid or unexpected message is received during the handshake the connection
is closed. See the flowchart below.
![Client connection flowchart](assets/server_flow.svg)
For authentication failure the connection is closed with code 4003,
which the connection client should interpret as connecting will never
succeed and stop trying to reconnect.
### Connection tracking and heartbeats
Once connected as indicated by the ready and continue messages from the
master server the client must start sending heartbeats at the interval
given in the ready/continue message, and storing messages sent over the
link in a sending side buffer. The heartbeats sent must have the seq of
the last received message on the link and acts as an ack for received
messages. The master server also sends heartbeats in the same manner
and the client should remove messages up to and including the sequence
number received in the heartbeat from its send buffer.
Handshake messages
------------------
### `hello`
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`
Slave handshake for establishing a new connection session.
- token - string - Authentication token.
- agent - string - Human readable string of the software connecting.
- 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`
Control handshake for enstablishing a new connection session.
- token - string - Authentication token.
- agent - string - Human readable string of the software connecting.
- version - string - The protocol version of the control, e.g. "2.0.0".
### `resume`
Handshake for resuming an existing connection session where the
connection had dropped unexpectedly.
- session_token - string - Session token to resume session with.
- last_seq - integer|null - Last message seq received by the client.
### `invalidate`
Sent by the master server in reply to a resume for a session that cannot
be resumed. The client should start a new session with a register\_\*
message in response.
### `continue`
Sent by the master server to signal resuming the session was successful.
- last_seq - integer|null - Last message seq received by the server.
- heartbeat_interval - number - Rate client should send heartbeats at.
### `ready`
Sent by the master server to signal the connection has been established
with a new session.
- session_token - string - Token to resume the session with.
- heartbeat_interval - number - Rate client should send heartbeats at.
### `heartbeat`
Sent by both the master server and clients at the interval given in the
continue/ready messages.
- last_seq - integer - Last message seq received over the link.
Example handshake
-----------------
Server hello after opening the WebSocket.
```json
{
"seq": null,
"type": "hello",
"data": {
"version": "2.0.0"
}
}
```
Slave registering a new connection session.
```json
{
"seq": null,
"type": "register_slave",
"data": {
"token": "xyzabc123",
"agent": "Clusterio Slave",
"version": "2.0.0",
"name": "Foo",
"id": 123
}
}
```
Master server reply signaling the session is established and ready.
```json
{
"seq": null,
"type": "ready",
"data": {
"session_token": "xyz.azy.bzy",
"heartbeat_interval": 45
}
}
```
Events
------
Events are messages send in one direction over the link, that the
receiver is expected to act upon, but not make any replies (for that
there's [Requests](#requests). By convention message types ending in
`*_event` are events. The data payload of an event consists solely of
event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
the recognized events and their contents.
Requests
--------
Requests are messages which expects a response in return. They function
a lot like HTTP requests except that both parties of a connection can
inniate a request that is expected to be responded to. By convention
message types ending in `*_request` are requests, and is expected to be
replied to with a corresponding `*_response` message. If an error
occured while processing the request an error response containing an
error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
the recognized requests and their contents.
### `*_response`
- seq - integer - The seq number of the request message.
- additional properties may be present depending on the type of request.
### `*_response` error
- seq - integer - The seq number of the request message.
- error - string - Human readable text message describing the error.
Graceful Disconnect
-------------------
To ensure no data is lost when closing the link it's important to
send a prepare disconnect request and wait for it to complete before
closing the WebSocket. Either party of the link can send this request
and the party sending the request should be the one that initates the
close of the WebSocket.
### `prepare_disconnect_request`
Request graceful disconnect of the connection. The receiving party should
stop tasks that send events and requests over the link and wait for
pending requests it has over the link to complete, but it should not
stop responding to requests sent to it. Only after this process is done
should the response be sent.
### `prepare_disconnect_response`
Signals the preparation for disconnect is complete and that the
connection can be closed. The party that initiatied the prepare
disconnect request may still send other requests/events over the link to
finish its part of the disconnection proceedure.
If the error response variant is received it should be logged if
possible and treated as a success.
+434 -476
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!README.md
+4
View File
@@ -0,0 +1,4 @@
# External plugins directory
Plugins placed here will have their npm dependencies incorporated in the shared pnpm storage for this repository when `pnpm install` is invoked.
Use this to enable `--dev-plugin` to work with plugins developed outside of the Clusterio repository.
-7
View File
@@ -1,7 +0,0 @@
{
"packages": [
"packages/*",
"plugins/*"
],
"version": "2.0.0-alpha.2"
}
+19 -19
View File
@@ -2,11 +2,11 @@
"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",
"test": "mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint \"*.js\" packages plugins test",
"docs": "jsdoc --configure docs/jsdoc.json",
"bundle-dependencies": "bundle-dependencies"
@@ -18,24 +18,24 @@
]
},
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-react": "^7.12.1",
"@clusterio/ctl": "file:./packages/ctl",
"@clusterio/lib": "file:./packages/lib",
"@clusterio/master": "file:packages/master",
"@clusterio/slave": "file:./packages/slave",
"eslint": "^7.2.0",
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/master": "workspace:*",
"@clusterio/slave": "workspace:*",
"eslint": "^8.18.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.16.2",
"form-data": "^4.0.0",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.3",
"jsdoc": "^3.6.6",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"nyc": "^14.1.1",
"phin": "^3.4.1",
"webpack": "^5.0.0"
"mocha": "^9.2.2",
"node-forge": "^1.3.1",
"nyc": "^15.1.0",
"phin": "^3.5.1"
}
}
+5
View File
@@ -0,0 +1,5 @@
# Clusterio installer
Installer for Clusterio.
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
+662
View File
@@ -0,0 +1,662 @@
#!/usr/bin/env node
"use strict";
const child_process = require("child_process");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
const { levels, logger, setLogLevel } = require("./logging");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const availablePlugins = [
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
// Comunity plugins
{ name: "Discord Bridge", value: "@hornwitser/discord_bridge" },
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio",
],
darwin: [
"/Applications/factorio.app/Contents",
],
};
class LineSplitter extends stream.Transform {
constructor(options) {
super(options);
this._partial = null;
}
_transform(chunk, encoding, callback) {
if (this._partial) {
chunk = Buffer.concat([this._partial, chunk]);
this._partial = null;
}
while (chunk.length) {
let end = chunk.indexOf("\n");
if (end === -1) {
this._partial = chunk;
break;
}
let next = end + 1;
// Eat carriage return as well if present
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
end -= 1;
}
let line = chunk.slice(0, end);
chunk = chunk.slice(next);
this.push(line);
}
callback();
}
_flush(callback) {
if (this._partial) {
this.push(this._partial);
this._partial = null;
}
callback();
}
}
class InstallError extends Error { }
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
});
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { logger.verbose(line.toString()); });
child.stdout.pipe(stdout);
let stderr = new LineSplitter({ readableObjectMode: true });
stderr.on("data", line => { logger.verbose(`err: ${line.toString()}`); });
child.stderr.pipe(stderr);
});
}
async function execMaster(args) {
if (dev) {
return await execFile("node", [path.join("packages", "master"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args);
}
async function execSlave(args) {
if (dev) {
return await execFile("node", [path.join("packages", "slave"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join("packages", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
function validateSlaveToken(token) {
let parts = token.split(".");
if (parts.length !== 3) {
throw new InstallError("Invalid token");
}
let parsed;
try {
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
} catch (err) {
throw new InstallError("Invalid token");
}
if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) {
throw new InstallError("Invalid token");
}
}
async function validateInstallDir() {
let entries = new Set(await fs.readdir("."));
if (entries.size) {
if (!entries.has("package.json")) {
throw new InstallError("Refusing to install to non-empty directory");
}
let packageData;
try {
packageData = JSON.parse(await fs.readFile("package.json"));
} catch (err) {
throw new InstallError(`Failed to read package.json: ${err.message}`);
}
if (packageData.name !== "clusterio-install" || !packageData.private) {
throw new InstallError("Refusing to run on non-clusterio installation");
}
}
}
async function validateNotRoot(args) {
if (args.allowInstallAsRoot) {
return;
}
if (os.userInfo().uid === 0) {
throw new InstallError(
"Refusing to install as root. Create a separate user account for clusterio " +
"and then use su/sudo to switch to it before invoking the installer."
);
}
}
async function installClusterio(mode, plugins) {
try {
await safeOutputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
} catch (err) {
if (err.code !== "EEXIST") {
throw new InstallError(`Failed to write package.json: ${err.message}`);
}
}
let components = [];
if (["standalone", "master"].includes(mode)) {
components.push("@clusterio/master");
}
if (["standalone", "slave"].includes(mode)) {
components.push("@clusterio/slave");
}
if (mode === "ctl") {
components.push("@clusterio/ctl");
}
logger.info(`Please wait, installing ${mode}`);
try {
await execFile(`npm${scriptExt}`, ["install", ...components, ...plugins]);
} catch (err) {
throw new InstallError(`Failed to install: ${err.message}`);
}
if (plugins.length) {
logger.info("Setting up plugins");
let pluginList;
try {
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
} catch (err) {
if (err.code === "ENOENT") {
pluginList = new Map();
} else {
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
}
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
// eslint-disable-next-line node/global-require
let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] }));
pluginList.set(pluginInfo.name, plugin);
}
}
try {
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
}
}
async function groupIdToName(gid) {
try {
let exec = util.promisify(child_process.exec);
let { stdout } = await exec(`getent group ${gid}`);
return stdout.split(":")[0];
} catch (err) {
logger.warn(`getent group ${gid} failed: ${err.message}`);
return gid;
}
}
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
"run-master.cmd",
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
);
} else {
await safeOutputFile(
"run-master.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
{ mode: 0o755 },
);
await safeOutputFile(
"systemd/clusteriomaster.service",
`[Unit]
Description=Clusterio Master
[Service]
User=${os.userInfo().username}
Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
[Install]
WantedBy=multi-user.target
`);
}
}
if (["standalone", "slave"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
"run-slave.cmd",
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
);
} else {
await safeOutputFile(
"run-slave.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
{ mode: 0o755 },
);
await safeOutputFile(
"systemd/clusterioslave.service",
`[Unit]
Description=Clusterio Slave
[Service]
User=${os.userInfo().username}
Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn
[Install]
WantedBy=multi-user.target
`);
}
}
}
// eslint-disable-next-line complexity
async function inquirerMissingArgs(args) {
let answers = {};
if (args.mode) { answers.mode = args.mode; }
answers = await inquirer.prompt([
{
type: "list",
name: "mode",
message: "Operating mode to install",
default: "standalone",
choices: [
{ name: "Standalone (install both master and slave on this computer)", value: "standalone" },
{ name: "Master only", value: "master" },
{ name: "Slave only", value: "slave" },
{ name: "Ctl only", value: "ctl" },
{ name: "Plugins only", value: "plugins" },
],
},
], answers);
if (["standalone", "master"].includes(answers.mode)) {
if (args.admin) { answers.admin = args.admin; }
answers = await inquirer.prompt([
{
type: "input",
name: "admin",
message: "Admin account name",
validate: input => {
if (!input) {
return "May not be empty";
}
return true;
},
},
], answers);
}
if (answers.mode === "slave") {
if (args.slaveName) { answers.slaveName = args.slaveName; }
answers = await inquirer.prompt([
{
type: "input",
name: "slaveName",
message: "Name of slave",
},
], answers);
}
if (["slave", "ctl"].includes(answers.mode)) {
if (args.masterUrl) { answers.masterUrl = args.masterUrl; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterUrl",
message: "Master server URL",
},
], answers);
if (args.masterToken) { answers.masterToken = args.masterToken; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterToken",
message: "Master authentication Token",
},
], answers);
}
if (answers.mode === "slave") {
validateSlaveToken(answers.masterToken);
}
if (["standalone", "slave"].includes(answers.mode)) {
let myIp = "localhost";
if (args.publicAddress) {
answers.publicAddress = args.publicAddress;
} else {
try {
let result = await phin("https://api.ipify.org/");
myIp = result.body.toString();
} catch (err) { /* ignore */ }
}
answers = await inquirer.prompt([
{
type: "input",
name: "publicAddress",
message: "Public DNS/IP address of this server",
default: myIp,
},
], answers);
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
if (answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
type: "list",
name: "factorioDir",
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
], answers);
if (answers.factorioDir === null) {
answers = await inquirer.prompt([
{
type: "input",
name: "factorioDir",
message: "Path to Factorio installation",
askAnswered: true,
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.ensureDir("factorio");
}
}
if (dev) {
answers.plugins = [];
} else if (args.plugins) {
answers.plugins = args.plugins;
}
answers = await inquirer.prompt([
{
type: "checkbox",
name: "plugins",
message: "Plugins to install",
choices: availablePlugins,
pageSize: 20,
},
], answers);
return answers;
}
async function downloadLinuxServer() {
let res = await phin("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// get the filename of the latest factorio archive from redirected url
const filename = path.posix.basename(url.pathname);
const version = filename.match(/(?<=factorio_headless_x64_).*(?=\.tar\.xz)/)[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await phin({
url: url.href,
stream: true,
});
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
res.pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.ensureDir(tmpFactorioDir);
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.unlink(archivePath);
await fs.rename(tmpFactorioDir, factorioDir);
}
}
async function main() {
let args = yargs
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
})
.option("dev", {
nargs: 0, describe: "Initialize development repository", hidden: true, default: false, type: "boolean",
})
.option("mode", {
nargs: 1, describe: "Operating mode to install",
choices: ["standalone", "master", "slave", "ctl", "plugins"],
})
.option("admin", {
nargs: 1, describe: "Admin account name [standalone/master]", type: "string",
})
.option("slave-name", {
nargs: 1, describe: "Slave name [slave]", type: "string",
})
.option("master-url", {
nargs: 1, describe: "Master URL [slave/ctl]", type: "string",
})
.option("master-token", {
nargs: 1, describe: "Master authentication token [slave/ctl]", type: "string",
})
.option("public-address", {
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/slave]", type: "string",
})
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/slave]", type: "string",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
})
.strict()
;
if (process.platform === "linux") {
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
.conflicts("factorio-dir", "download-headless");
}
args = args.argv;
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
if (!dev) {
await validateInstallDir();
await validateNotRoot(args);
}
let answers = await inquirerMissingArgs(args);
if (answers.downloadHeadless) {
await downloadLinuxServer();
}
logger.verbose(JSON.stringify(answers));
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
let adminToken = null;
if (["standalone", "master"].includes(answers.mode)) {
logger.info("Setting up master");
await execMaster(["bootstrap", "create-admin", answers.admin]);
let result = await execMaster(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
if (answers.mode === "standalone") {
logger.info("Setting up slave");
await execSlave(["config", "set", "slave.name", "local"]);
let result = await execSlave(["config", "show", "slave.id"]);
let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
result = await execMaster(["bootstrap", "generate-slave-token", slaveId]);
let slaveToken = result.stdout.split("\n").slice(-2)[0];
await execSlave(["config", "set", "slave.master_token", slaveToken]);
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
if (answers.mode === "slave") {
logger.info("Setting up slave");
let slaveId = JSON.parse(Buffer.from(answers.masterToken.split(".")[1], "base64")).slave;
await execSlave(["config", "set", "slave.id", slaveId]);
await execSlave(["config", "set", "slave.name", answers.slaveName]);
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode);
}
if (answers.mode === "ctl") {
await execCtl(["control-config", "set", "control.master_url", answers.masterUrl]);
await execCtl(["control-config", "set", "control.master_token", answers.masterToken]);
}
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (adminToken) {
console.log(`Admin authentication token: ${adminToken}`);
}
/* eslint-enable no-console */
}
if (module === require.main) {
main().catch(err => {
if (err instanceof InstallError) {
logger.error(err.message);
} else {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured installing clusterio, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
});
}
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable no-console */
"use strict";
const chalk = require("chalk");
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
audit: 3,
info: 4,
verbose: 6,
});
const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
audit: "bold greenBright",
info: "bold blueBright",
verbose: "bold grey",
};
let maxLogLevel = levels.info;
const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
if (level > maxLogLevel) {
return;
}
let style = chalk;
for (let part of colors[name].split(" ")) {
style = style[part];
}
if (["fatal", "error"].includes(name)) {
console.error(`${style(name)} ${message}`);
} else if (["warn"].includes(name)) {
console.warn(`${style(name)} ${message}`);
} else {
console.log(`${style(name)} ${message}`);
}
};
}
module.exports = {
colors,
levels,
setLogLevel: (level) => { maxLogLevel = level; },
logger,
};
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@clusterio/create",
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.13",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
],
"engines": {
"node": ">=12"
},
"dependencies": {
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"inquirer": "^8.1.0",
"phin": "^3.5.1",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
}
+27 -44
View File
@@ -1,70 +1,57 @@
Clusterio Command Line Interface
================================
# Clusterio Command Line Interface
Provides a command line interface for managing a Clusterio cluster.
Note that this document only describes the options and commands for
managing clusterioctl itself, see [Managing a
cluster](/docs/managing-a-cluster.md) for the available commands used
for managing a cluster.
Note that this document only describes the options and commands for managing clusterioctl itself, see [Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
Usage
-----
## Usage
npx clusterioctl <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to ctl. Defaults to `plugin-list.json`
and will be created if it does not exist. See the `plugin` command
for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to ctl.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for
ctl. Defaults to `config-control.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for ctl.
Defaults to `config-control.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by ctl. The available plugins
will be automatically loaded and the commands they provide will be added
to the ctl utility.
Configure plugins available to be loaded by ctl.
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioctl plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -74,18 +61,17 @@ Lists the plugins set up to be available by name followed by path.
### `config-control`
Manage the ctl configuration. This allows setting the url and token to
the master server to manage.
Manage the ctl configuration.
This allows setting the url and token to the master server to manage.
#### `config-control set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
See docs/configuration.md in the main repositiory for the available configuration.
#### `config-control show <config-entry>`
@@ -100,9 +86,6 @@ Lists up all configuration entries with their currently configured values.
## See Also
[Managing a cluster](/docs/managing-a-cluster.md) for the available
commands used for managing a cluster.
[Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+123 -563
View File
@@ -8,10 +8,7 @@
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 setBlocking = require("set-blocking");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
@@ -20,494 +17,10 @@ const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libCommand = require("@clusterio/lib/command");
const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
/**
* 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 libLink.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(() => {});
},
}));
const commands = require("./src/commands");
/**
@@ -515,13 +28,13 @@ debugCommands.add(new libCommand.Command({
* @private
*/
class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
constructor(url, maxReconnectDelay, tlsCa, token) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
register() {
console.log("SOCKET | registering control");
logger.verbose("Connector | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterioctl",
@@ -538,32 +51,67 @@ class ControlConnector extends libLink.WebSocketClientConnector {
*/
class Control extends libLink.Link {
constructor(connector, controlPlugins) {
constructor(connector, controlConfig, tlsCa, controlPlugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* Control config used for connecting to the master.
* @type {module:lib/config.ControlConfig}
*/
this.config = controlConfig;
/**
* Certificate authority used to validate TLS connections to the master.
* @type {?string}
*/
this.tlsCa = tlsCa;
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
libPlugin.attachPluginMessages(this, controlPlugin);
}
/**
* Keep the control connection alive after the command completes.
* @type {boolean}
*/
this.keepOpen = false;
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutputColored(output));
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
async saveListUpdateEventHandler() { }
async userUpdateEventHandler() { }
async setLogSubscriptions({
all = false,
master = false,
slave_ids = [],
instance_ids = [],
max_level = null,
}) {
await libLink.messages.setLogSubscriptions.send(this, {
all, master, slave_ids, instance_ids, max_level,
});
}
async logMessageEventHandler(message) {
logger.log(message.data.info);
}
async debugWsMessageEventHandler(message) {
console.log("WS", message.data.direction, message.data.content);
print("WS", message.data.direction, message.data.content);
}
async shutdown() {
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
@@ -572,15 +120,40 @@ class Control extends libLink.Link {
}
}
await this.connector.close(1001, "Control Quit");
await this.connector.close(1000, "Control Quit");
}
}
async function loadPlugins(pluginList) {
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let controlPlugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.controlEntrypoint) {
continue;
}
let ControlPluginClass = await libPluginLoader.loadControlPluginClass(pluginInfo);
let controlPlugin = new ControlPluginClass(pluginInfo, logger);
controlPlugins.set(pluginInfo.name, controlPlugin);
await controlPlugin.init();
}
return controlPlugins;
}
async function startControl() {
yargs
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stderr",
default: "server",
choices: Object.keys(levels),
type: "string",
})
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
@@ -601,9 +174,16 @@ async function startControl() {
;
// Parse the args first to get the configured plugin list.
let args = yargs.argv;
let args = yargs.parse();
console.log(`Loading available plugins from ${args.pluginList}`);
// Log stream for the ctl session.
logger.add(new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new libLoggingUtils.TerminalFormat(),
}));
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
@@ -619,58 +199,31 @@ async function startControl() {
return;
}
logger.verbose("Loading Plugins");
let controlPlugins = await loadPlugins(pluginList);
// Add all cluster management commands including ones from plugins
const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(slaveCommands);
rootCommands.add(instanceCommands);
rootCommands.add(permissionCommands);
rootCommands.add(roleCommands);
rootCommands.add(userCommands);
rootCommands.add(debugCommands);
console.log("Loading Plugin info");
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let controlPlugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.controlEntrypoint) {
continue;
}
let { ControlPlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
let controlPlugin = new ControlPlugin(pluginInfo);
controlPlugins.set(pluginInfo.name, controlPlugin);
await controlPlugin.init();
await controlPlugin.addCommands(rootCommands);
}
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
command.register(yargs);
}
}
let rootCommands = await commands.registerCommands(controlPlugins, yargs);
// Reparse after commands have been added with help and strict checking.
args = yargs
.help()
.strict()
.argv
.parse()
;
console.log(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig();
logger.verbose(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig("control");
try {
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
} catch (err) {
if (err.code === "ENOENT") {
console.log("Config not found, initializing new config");
logger.verbose("Config not found, initializing new config");
await controlConfig.init();
} else {
throw err;
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
@@ -694,20 +247,27 @@ async function startControl() {
// The remaining commands require connecting to the master server.
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
console.error("Missing URL and/or token to connect with. See README.md for setting up access.");
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let tlsCa = null;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath);
}
let controlConnector = new ControlConnector(
controlConfig.get("control.master_url"),
controlConfig.get("control.reconnect_delay"),
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.master_token")
);
let control = new Control(controlConnector, controlPlugins);
let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins);
try {
await controlConnector.connect();
} catch(err) {
} catch (err) {
if (err instanceof libErrors.AuthenticationFailed) {
throw new libErrors.StartupError(err.message);
}
@@ -715,25 +275,26 @@ async function startControl() {
}
process.on("SIGINT", () => {
console.log("Caught interrupt signal, closing connection");
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
console.error(err);
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
let keepOpen = Boolean(args.keepOpen);
try {
await targetCommand.run(args, control);
} catch (err) {
keepOpen = false;
control.keepOpen = false;
if (err instanceof libErrors.CommandError) {
console.error(`Error running command: ${err.message}`);
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof libErrors.RequestError) {
console.error(`Error sending request: ${err.message}`);
logger.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else {
@@ -741,7 +302,7 @@ async function startControl() {
}
} finally {
if (!keepOpen) {
if (!control.keepOpen) {
await control.shutdown();
}
}
@@ -749,13 +310,11 @@ async function startControl() {
module.exports = {
Control,
// for testing only
_formatOutputColored: formatOutputColored,
};
if (module === require.main) {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
@@ -765,21 +324,22 @@ 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 |
+----------------------------------------------------------------+`
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
console.error(`
logger.error(`
+---------------------------------+
| Unable to to start clusterioctl |
+---------------------------------+`
+---------------------------------+
${err.stack}`
);
}
console.error(err);
process.exitCode = 1;
});
}
+6 -4
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.13",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,13 +15,15 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.2",
"@clusterio/lib": "^2.0.0-alpha.13",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"yargs": "^14.2.0"
},
"publishConfig": {
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
"use strict";
/**
* Helper class for implementing the exponential backoff algorithm
*
* Provides a convenient interface for implementing an exponential backoff
* when reconnecting or resending in a distributed network.
*
* Once instantiated the delay method will return the next interval to wait
* for in milliseconds before making another attempt. For each attempt the
* delay chosen is a random value between 0 and the current maximum delay
* wwhich starts at a value of 2 * `base` and doubles for ever attempt up to
* the passed value of `max`. The value for the delay will reset back to
* `base` if more time than the value for `reset` has passed between two
* invocations of delay.
*
* @alias module:lib/ExponentialBackoff
*/
class ExponentialBackoff {
/**
* Construct ExponentialBackoff helper
*
* The options passed may also be modified on the instance at runtime.
*
* @param {object=} options -
* Options for this exponential backoff helper
* @param {number=} [options.base=1] -
* Base delay in seconds. The first delay will on average be this
* long, but can be anywhere between 0 and 2 times this value.
* @param {number=} [options.max=60] -
* Maximum delay in seconds. A delay will not take more than this
* value.
* @param {number=} [options.reset=2*max] -
* Time in seconds between two invocations of delay which will cause
* it to start over with `base` instead of doubling.
*/
constructor(options = {}) {
/**
* Base delay in seconds. The first delay will on average be this
* long, but can be anywhere between 0 and 2 times this value.
* @type {number}
*/
this.base = options.base || 1;
/**
* Maximum delay in seconds. A delay will not take more than this
* value.
* @type {number}
*/
this.max = options.max || 60;
/**
* Time in seconds between two invocations of delay which will cause
* it to start over with `base` instead of doubling.
* @type {number}
*/
this.reset = options.reset || 2 * this.max;
this._exp = 0;
this._lastInvocationTime = Date.now();
}
/**
* Compute the next delay to wait
*
* Returns the next delay to wait according to the exponential backoff
* algorithm. This is a value between 0 and min(2 * options.base *
* 2<sup>retries</sup>, optinos.max).
*
* @returns {number} time in milliseconds to wait.
*/
delay() {
let invocationTime = Date.now();
let interval = (invocationTime - this._lastInvocationTime) / 1000;
this._lastInvocationTime = invocationTime;
if (interval > this.reset) {
this._exp = 0;
}
this._exp = Math.min(this._exp + 1, Math.log2(this.max));
return Math.random() * this.base * 2 ** this._exp * 1000;
}
}
module.exports = ExponentialBackoff;
+84
View File
@@ -0,0 +1,84 @@
"use strict";
/**
* Stastics collected about a player.
* @alias module:lib/PlayerStats
*/
class PlayerStats {
/**
* Count of the number of times this player has been seen joining this server.
* @type {number}
*/
joinCount = 0;
/**
* Time in ms this player has been seen online on this instance.
* @type {number}
*/
onlineTimeMs = 0;
/**
* Timestamp the player was last seen joining.
* @type {Date=}
*/
lastJoinAt;
/**
* Timestamp the player was last seen leaving.
* @type {Date=}
*/
lastLeaveAt;
/**
* Reason the player was last seen leaving with.
* @type {string=}
*/
lastLeaveReason;
static jsonSchema = {
type: "object",
properties: {
"join_count": { type: "integer" },
"online_time_ms": { type: "number" },
"last_join_at": { type: "number" },
"last_leave_at": { type: "number" },
"last_leave_reason": { type: "string" },
},
};
constructor(json = {}) {
if (json["join_count"]) {
this.joinCount = json["join_count"];
}
if (json["online_time_ms"]) {
this.onlineTimeMs = json["online_time_ms"];
}
if (json["last_join_at"]) {
this.lastJoinAt = new Date(json["last_join_at"]);
}
if (json["last_leave_at"]) {
this.lastLeaveAt = new Date(json["last_leave_at"]);
this.lastLeaveReason = json["last_leave_reason"] || "quit";
}
}
toJSON() {
let json = {};
if (this.joinCount) {
json["join_count"] = this.joinCount;
}
if (this.onlineTimeMs) {
json["online_time_ms"] = this.onlineTimeMs;
}
if (this.lastJoinAt) {
json["last_join_at"] = this.lastJoinAt.getTime();
}
if (this.lastLeaveAt) {
json["last_leave_at"] = this.lastLeaveAt.getTime();
json["last_leave_reason"] = this.lastLeaveReason;
}
return json;
}
}
module.exports = PlayerStats;
+3 -6
View File
@@ -1,8 +1,5 @@
Common Library for Clusterio
============================
# Common Library for Clusterio
This package contains functionality shared between master, slave, ctl
and/or plugins of Clusterio.
This package contains functionality shared between master, slave, ctl and/or plugins of Clusterio.
See [the Clusterio repository](https://github.com/clusterio/factorioClusterio)
for more inforation on Clusterio.
See [the Clusterio repository](https://github.com/clusterio/clusterio) for more information on Clusterio.
+104
View File
@@ -0,0 +1,104 @@
"use strict";
/**
* Helper class for rate limiting actions
*
* Provides a simple interface for implementing actions that are rate
* limited on invocations.
*
* @example
* const rateLimiter = RateLimiter({
* action: () => { console.log("action"); },
* maxRate: 1,
* });
*
* for (let i = 0; i < 100; i++) {
* rateLimiter.activate();
* // Prints action twice to the console, once for the first activation
* // and one more time 1 second later for 99 other activations that were
* // exceeding the rate limit.
* }
* @alias module:lib/RateLimiter
*/
class RateLimiter {
/**
* Construct RateLimiter helper
*
* The options passed may also be modified on the instance at runtime.
*
* @param {object=} options - Options for this rate limiter
* @param {number=} options.maxRate -
* Maximum rate of activations per second.
* @param {function()=} options.action -
* Callback invoked at maste `maxRate` per second on activation.
*/
constructor(options = {}) {
/**
* Maximum rate of activations per second.
* @type {number}
*/
this.maxRate = options.maxRate || 1;
/**
* Callback invoked at most `maxRate` per second on activation.
* @type {function()}
*/
this.action = options.action || null;
this._lastRun = 0;
this._runTimeout = null;
this.lastProgressBroadcast = Date.now();
}
/**
* Activate the rate limited action
*
* Checks if the invocation of the rate limited action now would fall
* within the rate limit. If it is within the limit then the action is
* invoked immediatly, otherwise a timeout is registered to invoke the
* action at next point in time where it will be within the rate limit.
*
* @returns {boolean} true if activation was within the rate limit.
*/
activate() {
let now = Date.now();
if (now < this._lastRun + 1000 / this.maxRate) {
if (!this._runTimeout && this.action !== null) {
this._runTimeout = setTimeout(() => {
this._runTimeout = null;
this._lastRun = Date.now();
this.action();
}, this._lastRun + 1000 / this.maxRate - now);
}
return false;
}
// We may end up activating before the timeout does.
if (this._runTimeout) {
clearTimeout(this._runTimeout);
this._runTimeout = null;
}
this._lastRun = now;
if (this.action !== null) {
this.action();
}
return true;
}
/**
* Cancel pending action
*
* Cancel pending action invocation. This is necessary when shutting
* down the use of the rate limit as a timeout is registered to invoke
* the next action when activated faster than the rate limit allows.
*/
cancel() {
if (this._runTimeout) {
clearTimeout(this._runTimeout);
}
}
}
module.exports = RateLimiter;
+12
View File
@@ -0,0 +1,12 @@
export { default as libConfig } from "./config";
export { default as libErrors } from "./errors";
export { default as libFactorio } from "./factorio/exchange_string";
export { default as libHelpers } from "./helpers";
export { default as libLink } from "./link";
export { default as libLogging } from "./logging";
export { default as libPlugin } from "./plugin";
export { default as libSchema } from "./schema";
export { default as libUsers } from "./users";
export { default as ExponentialBackoff } from "./ExponentialBackoff";
export { default as RateLimiter } from "./RateLimiter";
+69 -55
View File
@@ -1,63 +1,18 @@
/* eslint-disable no-console */
"use strict";
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const klaw = require("klaw");
const path = require("path");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
const finished = util.promisify(stream.finished);
async function main() {
const args = yargs
.scriptName("build")
.options({
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod", nargs: 1, type: "string", default: "dist" },
"bump-patch": { describe: "Increment patch number of build", type: "boolean", default: false },
"factorio-version": { describe: "Override factorio_version", type: "string" },
})
.strict()
.argv
;
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(".");
patch = String(Number.parseInt(patch, 10) + 1);
info.version = [major, minor, patch].join(".");
}
if (args.factorioVersion) {
info.factorio_version = args.factorioVersion;
}
if (args.dependencies) {
info.dependencies = args.dependencies;
}
if (args.clean) {
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let [, name, version] = match;
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
await fs.remove(modPath);
}
}
}
}
async function buildMod(args, info) {
if (args.build) {
await fs.ensureDir(args.outputDir);
let modName = `${info.name}_${info.version}`;
@@ -77,12 +32,12 @@ async function build(args) {
;
await events.once(walker, "end");
for (let pathParts of info.additional_files || []) {
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
let filePath = path.join(args.sourceDir, ...pathParts);
if (!await fs.pathExists(filePath)) {
throw new Error(`Additional file ${filePath} does not exist`);
}
zip.file(path.posix.join(modName, path.basename(filePath)), fs.createReadStream(filePath));
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
}
delete info.additional_files;
@@ -91,7 +46,7 @@ async function build(args) {
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 finished(writeStream);
} else {
let modDir = path.join(args.outputDir, modName);
@@ -101,9 +56,9 @@ async function build(args) {
}
console.log(`Building ${modDir}`);
await fs.copy(args.sourceDir, modDir);
for (let pathParts of info.additional_files || []) {
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
let filePath = path.join(...pathParts);
await fs.copy(filePath, path.join(modDir, path.basename(filePath)));
await fs.copy(filePath, path.join(modDir, fileName));
}
delete info.additional_files;
@@ -112,6 +67,65 @@ async function build(args) {
}
}
async function build(args) {
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
if (args.factorioVersion) {
info.factorio_version = args.factorioVersion;
}
if (args.dependencies) {
info.dependencies = args.dependencies;
}
if (args.clean) {
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let name = match[1];
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
await fs.remove(modPath);
}
}
}
}
if (info.variants) {
for (let variantOverrides of info.variants) {
let variantInfo = {
...info,
...variantOverrides,
};
delete variantInfo.variants;
await buildMod(args, variantInfo);
}
} else {
await buildMod(args, info);
}
}
async function main() {
const args = yargs
.scriptName("build")
.options({
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
})
.strict()
.argv
;
await build(args);
}
if (module === require.main) {
main().catch(err => { console.log(err); });
}
+2 -2
View File
@@ -7,8 +7,8 @@
* @module lib/command
*/
"use strict";
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("./link");
const libErrors = require("./errors");
/**
* Represents a command that can be runned by clusterioctl
+171 -45
View File
@@ -4,9 +4,20 @@
const events = require("events");
const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("@clusterio/lib/helpers");
const { basicType } = require("../helpers");
/**
* Invalid Access exception
*
* Exception class for when an access attempt is made to a field that
* does not permitt this access is done.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidAccess extends Error { };
/**
* Invalid Value exception
*
@@ -69,13 +80,24 @@ function splitOn(separator, string) {
* @memberof module:lib/config
*/
class Config extends events.EventEmitter {
constructor() {
/**
* Create a new instance of the given config
*
* @param {string} location -
* Location to evaluate access for this instance from
*/
constructor(location) {
super();
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`);
}
if (typeof location !== "string") {
throw new Error("location must be a string");
}
this.location = location;
this._groups = new Map();
this._unknownGroups = new Map();
}
@@ -83,8 +105,8 @@ class Config extends events.EventEmitter {
async init() {
for (let [name, GroupClass] of this.constructor._groups) {
if (!this._groups.has(name)) {
let group = new GroupClass();
await group.init(this);
let group = new GroupClass(this);
await group.init();
this._groups.set(name, group);
}
}
@@ -101,7 +123,13 @@ class Config extends events.EventEmitter {
}
}
async load(serializedConfig) {
/**
* Load config from a serialized object
*
* @param {object} serializedConfig - Serialized config to load.
* @param {string=} location - Location used for access control.
*/
async load(serializedConfig, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let GroupClass = this.constructor._groups.get(serializedGroup.name);
@@ -110,8 +138,8 @@ class Config extends events.EventEmitter {
continue;
}
let group = new GroupClass();
await group.load(this, serializedGroup);
let group = new GroupClass(this);
await group.load(serializedGroup, location);
this._groups.set(group.name, group);
}
@@ -121,12 +149,13 @@ class Config extends events.EventEmitter {
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the config.
*/
serialize() {
serialize(location = this.location) {
let groups = [...this._unknownGroups.values()];
for (let group of this._groups.values()) {
groups.push(group.serialize());
groups.push(group.serialize(location));
}
return { groups };
@@ -140,28 +169,42 @@ class Config extends events.EventEmitter {
*
* @param {Object} serializedConfig - Output from .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedConfig, notify) {
update(serializedConfig, notify, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let group = this._groups.get(serializedGroup.name);
if (group) {
group.update(serializedGroup, notify);
group.update(serializedGroup, notify, location);
} else {
this._unknownGroups.set(serializedGroup.name, serializedGroup);
}
}
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of the field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).canAccess(field, location);
}
/**
* Get the value for a config field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} the value for the field.
*/
get(name) {
get(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).get(field);
return this.group(group).get(field, location);
}
/**
@@ -169,10 +212,11 @@ class Config extends events.EventEmitter {
*
* @param {string} name - Name of field to set.
* @param {*} value - the value to set on field
* @param {string=} location - Location used for access control.
*/
set(name, value) {
set(name, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).set(field, value);
this.group(group).set(field, value, location);
}
/**
@@ -181,10 +225,11 @@ class Config extends events.EventEmitter {
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
*/
setProp(name, prop, value) {
setProp(name, prop, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).setProp(field, prop, value);
this.group(group).setProp(field, prop, value, location);
}
/**
@@ -280,15 +325,42 @@ class ConfigGroup {
*
* After the creation of the new config group you have to call
* either .init() or .load() on it in order to fully initialize it.
*
* @param {module:lib/config.Config} config -
* Parent config for this group instance.
*/
constructor() {
constructor(config) {
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`);
}
this._fields = new Map();
this._unknownFields = new Map();
this._config = null;
this.config = config;
}
/**
* Check access for a field
*
* @param {object} def - definition for for the field to check.
* @param {string} remote - location of remote access to check for.
* @param {boolean} error - throw on failure to pass check.
* @throws {module:lib/config.InvalidAccess}
* if access is not granted and error is true.
* @returns {boolean} true if access is granted
* @private
*/
_checkAccess(def, remote, error) {
for (let loc of [remote, this.config.location]) {
if (!def.access.includes(loc)) {
if (error) {
throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`);
} else {
return false;
}
}
}
return true;
}
/**
@@ -296,16 +368,15 @@ class ConfigGroup {
*
* Computes and assigns the initial values for all fields of the
* config group.
*
* @param {module:lib/config.Config} config - Config group belongs to.
*/
async init(config) {
this._config = config;
async init() {
for (let [name, def] of this.constructor._definitions) {
if (this._fields.has(name)) {
continue;
}
if (!def.access.includes(this.config.location)) {
continue;
}
let value = null;
if (typeof def.initial_value === "function") {
@@ -326,13 +397,13 @@ class ConfigGroup {
* group and then initializes any possible missing fields from it to
* their default values.
*
* @param {module:lib/config.Config} config - Config group belongs to.
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {string=} location - Location used for access control.
*/
async load(config, serializedGroup) {
this.update(serializedGroup, false);
await this.init(config);
async load(serializedGroup, location = this.config.location) {
this.update(serializedGroup, false, location);
await this.init();
}
/**
@@ -344,16 +415,34 @@ class ConfigGroup {
return this.constructor.groupName;
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
return this._checkAccess(def, location, false);
}
/**
* Get value for field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} Value stored for the field.
*/
get(name) {
if (!this.constructor._definitions.has(name)) {
get(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
return this._fields.get(name);
}
@@ -366,14 +455,16 @@ class ConfigGroup {
*
* @param {string} name - Name of field to set.
* @param {*} value - Value to set for field.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if value is not allowed for the field.
*/
set(name, value) {
set(name, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
// Empty strings are treates as null
if (value === "") {
@@ -386,8 +477,11 @@ class ConfigGroup {
if (basicType(value) === "string") {
if (def.type === "boolean") {
if (value === "true") { value = true; }
else if (value === "false") { value = false; }
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
@@ -415,8 +509,8 @@ class ConfigGroup {
let prev = this._fields.get(name);
this._fields.set(name, value);
if (this._config && !isDeepStrictEqual(value, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
@@ -429,14 +523,16 @@ class ConfigGroup {
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if field is not an object.
*/
setProp(name, prop, value) {
setProp(name, prop, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
@@ -445,10 +541,14 @@ class ConfigGroup {
let prev = this._fields.get(name);
let updated = {...prev || {}};
updated[prop] = value;
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
this._fields.set(name, updated);
if (this._config && !isDeepStrictEqual(updated, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (this.config && !isDeepStrictEqual(updated, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
@@ -461,8 +561,9 @@ class ConfigGroup {
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedGroup, notify) {
update(serializedGroup, notify, location = this.config.location) {
if (basicType(serializedGroup) !== "object") {
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
}
@@ -482,6 +583,10 @@ class ConfigGroup {
continue;
}
if (!this._checkAccess(def, location, false)) {
continue;
}
if (def.optional) {
if (value !== null && basicType(value) !== def.type) {
continue;
@@ -497,8 +602,8 @@ class ConfigGroup {
let prev = this._fields.get(name);
this._fields.set(name, value);
if (notify && this._config && !isDeepStrictEqual(value, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (notify && this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
}
@@ -506,11 +611,17 @@ class ConfigGroup {
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the group.
*/
serialize() {
serialize(location = this.config.location) {
let fields = {};
for (let [name, value] of this._fields) {
let def = this.constructor._definitions.get(name);
if (!this._checkAccess(def, location, false)) {
continue;
}
fields[name] = value;
}
@@ -550,6 +661,9 @@ class ConfigGroup {
* Define a new configuration field
*
* @param {Object} item - Field definition.
* @param {Array<string>=} item.access -
* Array of strings declaring where this config entry can be read
* and modified from.
* @param {string} item.type -
* string declaring the type of the config value, supports
* boolean, string, number and object.
@@ -602,6 +716,12 @@ class ConfigGroup {
}
break;
case "access":
if (valueType !== "array") {
throw new Error("access must be an array");
}
break;
case "enum":
if (valueType !== "array") {
throw new Error("enum must be an array");
@@ -648,11 +768,16 @@ class ConfigGroup {
}
let computed = {
access: this.defaultAccess,
optional: false,
fullName: `${this.groupName}.${item.name}`,
...item,
};
if (!computed.access) {
throw new Error(`access not set and no defaultAccess defined for ${this.name}`);
}
if (!computed.optional && computed.initial_value === undefined) {
throw new Error(`Non-optional field ${item.name} needs an initial_value`);
}
@@ -688,8 +813,8 @@ class PluginConfigGroup extends ConfigGroup {
if (!this._initialized) {
super._initSubclass();
this.define({
name: "enabled",
description: "Enable plugin",
name: "load_plugin",
title: "Load Plugin",
type: "boolean",
initial_value: true,
});
@@ -699,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup {
module.exports = {
InvalidAccess,
InvalidField,
InvalidValue,
+103 -29
View File
@@ -10,7 +10,15 @@ const classes = require("./classes");
* @memberof module:lib/config
*/
class MasterGroup extends classes.ConfigGroup { }
MasterGroup.defaultAccess = ["master", "slave", "control"];
MasterGroup.groupName = "master";
MasterGroup.define({
name: "name",
title: "Name",
description: "Name of the cluster.",
type: "string",
initial_value: "Your Cluster",
});
MasterGroup.define({
name: "database_directory",
title: "Database directory",
@@ -24,6 +32,7 @@ MasterGroup.define({
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
type: "number",
optional: true,
initial_value: 8080,
});
MasterGroup.define({
name: "https_port",
@@ -31,7 +40,13 @@ MasterGroup.define({
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
type: "number",
optional: true,
initial_value: 8443,
});
MasterGroup.define({
name: "bind_address",
title: "Bind Address",
description: "IP address to bind the HTTP and HTTPS ports on.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "external_address",
@@ -46,7 +61,6 @@ MasterGroup.define({
description: "Path to the certificate to use for HTTPS.",
type: "string",
optional: true,
initial_value: "database/certificates/cert.crt",
});
MasterGroup.define({
name: "tls_private_key",
@@ -54,16 +68,9 @@ MasterGroup.define({
description: "Path to the private key to use for HTTPS.",
type: "string",
optional: true,
initial_value: "database/certificates/cert.key",
});
MasterGroup.define({
name: "tls_bits",
title: "TLS Bits",
description: "Number of bits to use for auto generated TLS certificate.",
type: "number",
initial_value: 2048,
});
MasterGroup.define({
access: ["master"],
name: "auth_secret",
title: "Master Authentication Secret",
description:
@@ -76,21 +83,35 @@ MasterGroup.define({
MasterGroup.define({
name: "heartbeat_interval",
title: "Heartbeat Interval",
description: "Interval heartbeats are sent out on WebSocket connections",
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "session_timeout",
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
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",
name: "metrics_timeout",
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from slaves.",
type: "number",
initial_value: 30,
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
description: "ID of role assigned by default to new users",
description: "ID of role assigned by default to new users.",
type: "number",
optional: true,
initial_value: 1,
@@ -112,6 +133,7 @@ MasterConfig.registerGroup(MasterGroup);
* @memberof module:lib/config
*/
class SlaveGroup extends classes.ConfigGroup {}
SlaveGroup.defaultAccess = ["master", "slave", "control"];
SlaveGroup.groupName = "slave";
SlaveGroup.define({
name: "name",
@@ -141,7 +163,7 @@ SlaveGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
initial_value: "https://localhost:8443/",
initial_value: "http://localhost:8080/",
});
SlaveGroup.define({
name: "master_token",
@@ -149,6 +171,12 @@ SlaveGroup.define({
type: "string",
initial_value: "enter token here",
});
SlaveGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
SlaveGroup.define({
name: "public_address",
description: "Public facing address players should connect to in order to join instances on this slave",
@@ -156,11 +184,11 @@ SlaveGroup.define({
initial_value: "localhost",
});
SlaveGroup.define({
name: "reconnect_delay",
title: "Reconnect Delay",
name: "max_reconnect_delay",
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 5,
initial_value: 60,
});
SlaveGroup.finalize();
@@ -179,6 +207,7 @@ SlaveConfig.registerGroup(SlaveGroup);
* @memberof module:lib/config
*/
class InstanceGroup extends classes.ConfigGroup { }
InstanceGroup.defaultAccess = ["master", "slave", "control"];
InstanceGroup.groupName = "instance";
InstanceGroup.define({
name: "name",
@@ -196,6 +225,12 @@ InstanceGroup.define({
type: "number",
optional: true,
});
InstanceGroup.define({
name: "auto_start",
description: "Automatically start this instance when the slave hosting it is started up",
type: "boolean",
initial_value: false,
});
InstanceGroup.finalize();
/**
@@ -204,6 +239,7 @@ InstanceGroup.finalize();
* @memberof module:lib/config
*/
class FactorioGroup extends classes.ConfigGroup { }
FactorioGroup.defaultAccess = ["master", "slave", "control"];
FactorioGroup.groupName = "factorio";
FactorioGroup.define({
name: "version",
@@ -229,6 +265,14 @@ FactorioGroup.define({
type: "string",
optional: true,
});
FactorioGroup.define({
name: "player_online_autosave_slots",
description:
"Rename autosaves where players have been online since the previous autosave into a separate autosave " +
"pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initial_value: 5,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
@@ -241,14 +285,29 @@ FactorioGroup.define({
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "enable_authserver_bans",
description: "Turn on Factorio.com based multiplayer bans.",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
type: "object",
initial_value: {
"tags": ["clusterio"],
"auto_pause": false,
},
initial_value: {}, // See create instance handler in master.
});
FactorioGroup.define({
name: "verbose_logging",
description: "Enable verbose logging on the Factorio server",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "strip_paths",
description: "Strip down instance paths in the log",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_adminlist",
@@ -268,6 +327,12 @@ FactorioGroup.define({
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "max_concurrent_commands",
description: "Maximum number of RCON commands trasmitted in parallel",
type: "number",
initial_value: 5,
});
FactorioGroup.finalize();
/**
@@ -286,6 +351,7 @@ InstanceConfig.registerGroup(FactorioGroup);
* @memberof module:lib/config
*/
class ControlGroup extends classes.ConfigGroup {}
ControlGroup.defaultAccess = ["control"];
ControlGroup.groupName = "control";
ControlGroup.define({
name: "master_url",
@@ -300,11 +366,17 @@ ControlGroup.define({
optional: true,
});
ControlGroup.define({
name: "reconnect_delay",
title: "Reconnect Delay",
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "max_reconnect_delay",
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 2,
initial_value: 60,
});
ControlGroup.finalize();
@@ -318,9 +390,9 @@ ControlConfig.registerGroup(ControlGroup);
function validateGroup(pluginInfo, groupName) {
if (!pluginInfo[groupName] instanceof classes.PluginConfigGroup) {
if (!(pluginInfo[groupName].prototype instanceof classes.PluginConfigGroup)) {
throw new Error(
`Expected ${groupName} for ${pluginInfo.name} to be an instance of PluginConfigGroup`
`Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup`
);
}
@@ -345,6 +417,7 @@ function registerPluginConfigGroups(pluginInfos) {
} else {
class MasterConfigGroup extends classes.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = pluginInfo.name;
MasterConfigGroup.finalize();
MasterConfig.registerGroup(MasterConfigGroup);
@@ -357,6 +430,7 @@ function registerPluginConfigGroups(pluginInfos) {
} else {
class InstanceConfigGroup extends classes.PluginConfigGroup { }
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
InstanceConfigGroup.groupName = pluginInfo.name;
InstanceConfigGroup.finalize();
InstanceConfig.registerGroup(InstanceConfigGroup);
+5 -2
View File
@@ -4,7 +4,10 @@
* @module lib/config
*/
"use strict";
const classes = require("./classes");
const definitions = require("./definitions");
module.exports = {
...require("./classes"),
...require("./definitions"),
...classes,
...definitions,
};
+6 -7
View File
@@ -8,7 +8,8 @@
"use strict";
const fs = require("fs-extra");
const { basicType } = require("@clusterio/lib/helpers");
const libFileOps = require("./file_ops");
const { basicType } = require("./helpers");
/**
@@ -59,9 +60,8 @@ async function loadJsonAsMap(filePath) {
// File does not exist, return empty map
return new Map();
} else {
throw err;
}
throw err;
}
}
@@ -79,7 +79,7 @@ async function loadJsonAsMap(filePath) {
*/
async function saveMapAsJson(filePath, map) {
let obj = mapToObject(map);
await fs.outputFile(filePath, JSON.stringify(obj, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, 4));
}
/**
@@ -121,9 +121,8 @@ async function loadJsonArrayAsMap(filePath) {
// File does not exist, return empty map
return new Map();
} else {
throw err;
}
throw err;
}
}
@@ -140,7 +139,7 @@ async function loadJsonArrayAsMap(filePath) {
* @throws {Error} if an error occured writing to the file.
*/
async function saveMapAsJsonArray(filePath, map) {
await fs.outputFile(filePath, JSON.stringify([...map.values()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, 4));
}
+11
View File
@@ -16,6 +16,16 @@ class CommandError extends Error { }
*/
class RequestError extends Error { }
/**
* Thrown when a permission check fails.
*
* Is a subclass of RequestError to prevent logging stack traces when
* requests fail due to permission denied.
*
* @static
*/
class PermissionError extends RequestError { }
/**
* Signal for messages that fail validation
* @static
@@ -66,6 +76,7 @@ class PluginError extends Error {
module.exports = {
CommandError,
RequestError,
PermissionError,
InvalidMessage,
SessionLost,
AuthenticationFailed,
+393
View File
@@ -0,0 +1,393 @@
"use strict";
const zlib = require("zlib");
class MapReaderState {
constructor(buf) {
this.pos = 0;
this.buf = buf;
this.last_position = { x: 0, y: 0 };
}
}
function readUInt8(state) {
let value = state.buf.readUInt8(state.pos);
state.pos += 1;
return value;
}
function readBool(state) {
let value = readUInt8(state) !== 0;
return value;
}
function readInt16(state) {
let value = state.buf.readInt16LE(state.pos);
state.pos += 2;
return value;
}
function readUInt16(state) {
let value = state.buf.readUInt16LE(state.pos);
state.pos += 2;
return value;
}
function readInt32(state) {
let value = state.buf.readInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32(state) {
let value = state.buf.readUInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32so(state) {
let value = readUInt8(state);
if (value === 0xff) {
return readUInt32(state);
}
return value;
}
function readFloat(state) {
let value = state.buf.readFloatLE(state.pos);
state.pos += 4;
return value;
}
function readDouble(state) {
let value = state.buf.readDoubleLE(state.pos);
state.pos += 8;
return value;
}
function readString(state) {
let size = readUInt32so(state);
let data = state.buf.slice(state.pos, state.pos + size).toString("utf-8");
state.pos += size;
return data;
}
function readOptional(state, readValue) {
let load = readUInt8(state) !== 0;
if (!load) {
return null;
}
return readValue(state);
}
function readArray(state, readItem) {
let size = readUInt32so(state);
let array = [];
for (let i = 0; i < size; i++) {
let item = readItem(state);
array.push(item);
}
return array;
}
function readDict(state, readKey, readValue) {
let size = readUInt32so(state);
let mapping = new Map();
for (let i = 0; i < size; i++) {
let key = readKey(state);
let value = readValue(state);
mapping.set(key, value);
}
return mapping;
}
function readVersion(state) {
let major = readUInt16(state);
let minor = readUInt16(state);
let patch = readUInt16(state);
let developer = readUInt16(state);
return [major, minor, patch, developer];
}
function readFrequencySizeRichness(state) {
return {
frequency: readFloat(state),
size: readFloat(state),
richness: readFloat(state),
};
}
function readAutoplaceSetting(state) {
return {
treat_missing_as_default: readBool(state),
settings: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
};
}
function readMapPosition(state) {
let x, y;
let x_diff = readInt16(state) / 256;
if (x_diff === 0x7fff / 256) {
x = readInt32(state) / 256;
y = readInt32(state) / 256;
} else {
let y_diff = readInt16(state) / 256;
x = state.last_position.x + x_diff;
y = state.last_position.y + y_diff;
}
state.last_position.x = x;
state.last_position.x = y;
return { x, y };
}
function readBoundingBox(state) {
return {
left_top: readMapPosition(state),
right_bottom: readMapPosition(state),
orientation: {
x: readInt16(state),
y: readInt16(state),
},
};
}
function readCliffSettings(state) {
return {
name: readString(state),
elevation_0: readFloat(state),
elevation_interval: readFloat(state),
richness: readFloat(state),
};
}
function readMapGenSettings(state) {
return {
terrain_segmentation: readFloat(state),
water: readFloat(state),
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
seed: readUInt32(state),
width: readUInt32(state),
height: readUInt32(state),
area_to_generate_at_start: readBoundingBox(state),
starting_area: readFloat(state),
peaceful_mode: readBool(state),
starting_points: readArray(state, readMapPosition),
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
cliff_settings: readCliffSettings(state),
};
}
function readPollution(state) {
return {
enabled: readOptional(state, readBool),
diffusion_ratio: readOptional(state, readDouble),
min_to_diffuse: readOptional(state, readDouble),
ageing: readOptional(state, readDouble),
expected_max_per_chunk: readOptional(state, readDouble),
min_to_show_per_chunk: readOptional(state, readDouble),
min_pollution_to_damage_trees: readOptional(state, readDouble),
pollution_with_max_forest_damage: readOptional(state, readDouble),
pollution_per_tree_damage: readOptional(state, readDouble),
pollution_restored_per_tree_damage: readOptional(state, readDouble),
max_pollution_to_restore_trees: readOptional(state, readDouble),
enemy_attack_pollution_consumption_modifier: readOptional(state, readDouble),
};
}
function readSteeringValue(state) {
return {
radius: readOptional(state, readDouble),
separation_factor: readOptional(state, readDouble),
separation_force: readOptional(state, readDouble),
force_unit_fuzzy_goto_behavior: readOptional(state, readBool),
};
}
function readSteering(state) {
return {
default: readSteeringValue(state),
moving: readSteeringValue(state),
};
}
function readEnemyEvolution(state) {
return {
enabled: readOptional(state, readBool),
time_factor: readOptional(state, readDouble),
destroy_factor: readOptional(state, readDouble),
pollution_factor: readOptional(state, readDouble),
};
}
function readEnemyExpansion(state) {
return {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
friendly_base_influence_radius: readOptional(state, readUInt32),
enemy_building_influence_radius: readOptional(state, readUInt32),
building_coefficient: readOptional(state, readDouble),
other_base_coefficient: readOptional(state, readDouble),
neighbouring_chunk_coefficient: readOptional(state, readDouble),
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
max_colliding_tiles_coefficient: readOptional(state, readDouble),
settler_group_min_size: readOptional(state, readUInt32),
settler_group_max_size: readOptional(state, readUInt32),
min_expansion_cooldown: readOptional(state, readUInt32),
max_expansion_cooldown: readOptional(state, readUInt32),
};
}
function readUnitGroup(state) {
return {
min_group_gathering_time: readOptional(state, readUInt32),
max_group_gathering_time: readOptional(state, readUInt32),
max_wait_time_for_late_members: readOptional(state, readUInt32),
max_group_radius: readOptional(state, readDouble),
min_group_radius: readOptional(state, readDouble),
max_member_speedup_when_behind: readOptional(state, readDouble),
max_member_slowdown_when_ahead: readOptional(state, readDouble),
max_group_slowdown_factor: readOptional(state, readDouble),
max_group_member_fallback_factor: readOptional(state, readDouble),
member_disown_distance: readOptional(state, readDouble),
tick_tolerance_when_member_arrives: readOptional(state, readUInt32),
max_gathering_unit_groups: readOptional(state, readUInt32),
max_unit_group_size: readOptional(state, readUInt32),
};
}
function readPathFinder(state) {
return {
fwd2bwd_ratio: readOptional(state, readInt32),
goal_pressure_ratio: readOptional(state, readDouble),
use_path_cache: readOptional(state, readBool),
max_steps_worked_per_tick: readOptional(state, readDouble),
max_work_done_per_tick: readOptional(state, readUInt32),
short_cache_size: readOptional(state, readUInt32),
long_cache_size: readOptional(state, readUInt32),
short_cache_min_cacheable_distance: readOptional(state, readDouble),
short_cache_min_algo_steps_to_cache: readOptional(state, readUInt32),
long_cache_min_cacheable_distance: readOptional(state, readDouble),
cache_max_connect_to_cache_steps_multiplier: readOptional(state, readUInt32),
cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
cache_path_start_distance_rating_multiplier: readOptional(state, readDouble),
cache_path_end_distance_rating_multiplier: readOptional(state, readDouble),
stale_enemy_with_same_destination_collision_penalty: readOptional(state, readDouble),
ignore_moving_enemy_collision_distance: readOptional(state, readDouble),
enemy_with_different_destination_collision_penalty: readOptional(state, readDouble),
general_entity_collision_penalty: readOptional(state, readDouble),
general_entity_subsequent_collision_penalty: readOptional(state, readDouble),
extended_collision_penalty: readOptional(state, readDouble),
max_clients_to_accept_any_new_request: readOptional(state, readUInt32),
max_clients_to_accept_short_new_request: readOptional(state, readUInt32),
direct_distance_to_consider_short_request: readOptional(state, readUInt32),
short_request_max_steps: readOptional(state, readUInt32),
short_request_ratio: readOptional(state, readDouble),
min_steps_to_check_path_find_termination: readOptional(state, readUInt32),
start_to_goal_cost_multiplier_to_terminate_path_find: readOptional(state, readDouble),
overload_levels: readOptional(state, (p) => readArray(p, readUInt32)),
overload_multipliers: readOptional(state, (p) => readArray(p, readDouble)),
negative_path_cache_delay_interval: readOptional(state, readUInt32),
};
}
function readDifficultySettings(state) {
return {
recipe_difficulty: readUInt8(state),
technology_difficulty: readUInt8(state),
technology_price_multiplier: readDouble(state),
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
};
}
function readMapSettings(state) {
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
};
}
/**
* @typedef {object} module:lib/factorio.MapExchangeResult
* @property {array} version -
* Version of Factorio the string was created with.
* @property {object} map_gen_settings -
* Decoded map generator settings in the format the --map-gen-settings
* command line option to Factorio expect.
* @property {object} map_settings -
* Decoded map settings in the format the --map-settings command line option
* to Factorio expects.
* @property {number} checksum - checksum for the exchange string.
*/
/**
* Parse a Map Exchange String
*
* Reads and decodes the data in the given map exchange string and returns
* data structures that can be fed into the Factorio server when creating a
* save to set the map gen settings and the map settings for the save.
*
* @param {string} exchangeString - Max Exchange String to parse.
* @returns {module:lib/factorio.MapExchangeResult} Parsed result.
* @memberof module:lib/factorio
*/
function readMapExchangeString(exchangeString) {
exchangeString = exchangeString.replace(/[ \t\n\r]+/g, "");
if (!/>>>[0-9a-zA-Z\/+]+={0,3}<<</.test(exchangeString)) {
throw new Error("Not a map exchange string");
}
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
} catch (err) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed map exchange string: zlib inflate failed");
}
}
let state = new MapReaderState(buf);
let data;
try {
data = {
version: readVersion(state),
unknown: readUInt8(state),
map_gen_settings: readMapGenSettings(state),
map_settings: readMapSettings(state),
checksum: readUInt32(state),
};
} catch (err) {
if (err.code === "ERR_OUT_OF_RANGE") {
throw new Error("Malformed map exchange string: reached end before finishing parsing");
}
throw err;
}
if (state.pos !== buf.length) {
throw new Error("Malformed map exchange string: data after end");
}
return data;
}
module.exports = {
readMapExchangeString,
};
+149 -97
View File
@@ -2,11 +2,11 @@
"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");
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
/**
* Generate the export mod needed for exportData
@@ -90,7 +90,16 @@ async function loadFile(server, modVersions, modPath) {
}
}
let zip = await loadZip(server, modVersions, mod);
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
let file = zip.file(filePath);
if (!file) {
return null;
@@ -99,6 +108,105 @@ async function loadFile(server, modVersions, modPath) {
return await file.async("nodebuffer");
}
async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, iconCache) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
return icon;
}
async function loadSimpleIcon(server, modVersions, item, size, iconCache) {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache);
if (icon) {
let iconScale = size / item.icon_size;
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
return icon;
}
async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
let icon = await Jimp.create(size, size);
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
// The size in pixels of one unit
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = layer.icon_size || item.icon_size;
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
if (!iconLayer) {
continue;
}
iconLayer = iconLayer.clone();
let tint;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
r: (layer.tint.r || 0) / divisor,
g: (layer.tint.g || 0) / divisor,
b: (layer.tint.b || 0) / divisor,
a: layer.tint.a || 1,
};
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
let layerScale = layer.scale || 32 / layerSize;
let [xs, ys] = layer.shift || [0, 0];
let realScale = layerScale * baseUnit;
let sizeShift = (size - layerSize * realScale) / 2;
xs = xs * baseUnit + sizeShift;
ys = ys * baseUnit + sizeShift;
if (realScale !== 1) {
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
x += xs;
y += ys;
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = iconLayer.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
let sa = sdata[sidx + 3] / 255;
let da = ddata[didx + 3] / 255;
let db = da * (1 - sa * tint.a);
let cb = sa + da * (1 - sa);
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
});
}
return icon;
}
/**
* Export item icons and data
*
@@ -130,107 +238,21 @@ async function exportItems(server, modVersions, items) {
let pos = 0;
let iconCache = new Map();
async function loadIcon(iconPath, iconSize, iconMipmaps) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
console.log(`Warning: ${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
return icon;
}
let simpleIcons = new Map();
for (let item of items) {
let icon;
let iconPos;
if (item.icons) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
icon = await Jimp.create(size, size);
icon = await loadLayeredIcon(server, modVersions, item, size, iconCache);
iconPos = pos;
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
// The size in pixels of one unit
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = layer.icon_size || item.icon_size;
let iconLayer = await loadIcon(layer.icon, layerSize, layer.icon_mipmaps || 0);
if (!iconLayer) {
continue;
}
iconLayer = iconLayer.clone();
let tint;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
r: (layer.tint.r || 0) / divisor,
g: (layer.tint.g || 0) / divisor,
b: (layer.tint.b || 0) / divisor,
a: layer.tint.a || 1,
};
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
let layerScale = layer.scale || 32 / layerSize;
let [xs, ys] = layer.shift || [0, 0];
let realScale = layerScale * baseUnit;
let sizeShift = (size - layerSize * realScale) / 2;
xs = xs * baseUnit + sizeShift;
ys = ys * baseUnit + sizeShift;
if (realScale !== 1) {
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, function(x, y, sidx) {
x += xs;
y += ys;
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = this.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
let sa = sdata[sidx + 3] / 255;
let da = ddata[didx + 3] / 255;
let db = da * (1 - sa * tint.a);
let cb = sa + da * (1 - sa);
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
});
}
} else {
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadIcon(item.icon, item.icon_size, 0);
icon = await loadSimpleIcon(server, modVersions, item, size, iconCache);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
let iconScale = size / item.icon_size;
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
}
}
@@ -290,17 +312,25 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
mergeLocale(libIni.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);
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
if (err.code === "ENOENT") {
continue;
}
throw err;
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(ini.parse(content.toString("utf8")));
mergeLocale(libIni.parse(content.toString("utf8")));
}
}
@@ -324,9 +354,9 @@ async function exportData(server) {
server.on("ipc-item_export", data => items.push(data));
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("output", data => {
if (data.format === "seconds" && data.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(data.message);
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
if (match) {
modOrder.push(match[1]);
}
@@ -344,6 +374,28 @@ async function exportData(server) {
throw new Error("No items got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (!match) {
continue;
}
let modVersion = `${match[2]}.${match[3]}.${match[4]}`;
let normalizedVersion =
`${Number.parseInt(match[2], 10)}.${Number.parseInt(match[3], 10)}.${Number.parseInt(match[4], 10)}`
;
if (modVersion === normalizedVersion) {
continue;
}
if (modVersions.get(match[1]) === normalizedVersion) {
modVersions.set(match[1], modVersion);
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let locale = await exportLocale(server, modVersions, modOrder, "en");
+9 -3
View File
@@ -4,8 +4,14 @@
* @author Hornwitser
*/
"use strict";
const exchange_string = require("./exchange_string");
const export_ = require("./export");
const patch = require("./patch");
const server = require("./server");
module.exports = {
...require("./export"),
...require("./patch"),
...require("./server"),
...exchange_string,
...export_,
...patch,
...server,
};
+7 -5
View File
@@ -7,7 +7,7 @@ const JSZip = require("jszip");
const path = require("path");
const semver = require("semver");
const libHash = require("@clusterio/lib/hash");
const libHash = require("../hash");
const knownScenarios = {
@@ -78,7 +78,7 @@ function generateLoader(patchInfo) {
];
for (let moduleName of patchInfo["scenario"]["modules"]) {
lines.push('event_handler.add_lib(require("'+moduleName+'"))');
lines.push(`event_handler.add_lib(require("${moduleName}"))`);
}
lines.push(...[
@@ -90,10 +90,10 @@ function generateLoader(patchInfo) {
for (let file of module["files"]) {
let requirePath = `modules/${module.name}/${file["path"].slice(0, -4)}`;
if (file["load"]) {
lines.push('event_handler.add_lib(require("'+requirePath+'"))');
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
}
if (file["require"]) {
lines.push('require("'+requirePath+'")');
lines.push(`require("${requirePath}")`);
}
}
}
@@ -284,9 +284,11 @@ async function patch(savePath, modules) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
// Write back the save
let tempSavePath = `${savePath}.tmp`;
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let pipe = stream.pipe(fs.createWriteStream(savePath));
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
await events.once(pipe, "finish");
await fs.rename(tempSavePath, savePath);
}
module.exports = {
+225 -147
View File
@@ -6,10 +6,14 @@ const path = require("path");
const events = require("events");
const util = require("util");
const crypto = require("crypto");
const ini = require("ini");
const rconClient = require("rcon-client");
const libErrors = require("@clusterio/lib/errors");
const libErrors = require("../errors");
const libFileOps = require("../file_ops");
const libIni = require("../ini");
const { logger } = require("../logging");
const { escapeRegExp } = require("../helpers");
const { LineSplitter } = require("../stream");
/**
@@ -191,82 +195,6 @@ async function generatePassword(length) {
}
}
/**
* Line splitter for chunked data
*
* Splits a stream of bytes into lines terminated by either line feed or
* carriage return followed by linefeed. Each line sepparated is passed
* to the callback one at a time without the line terminator included.
*
* 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 {
/**
* Create a line splitter
*
* @param {function(Buffer)} callback - Function called for every line.
*/
constructor(callback) {
this._callback = callback;
this._partial = null;
}
/**
* Stream in bytes
*
* Input bytes to split lines over. Will invoke the callback for every
* line found in buf and keep any potential partial line at the end of
* buf for the next invocation.
*
* @param {Buffer} buf - Data to stream in.
*/
data(buf) {
if (this._partial) {
buf = Buffer.concat([this._partial, buf]);
this._partial = null;
}
while (buf.length) {
let end = buf.indexOf("\n");
if (end === -1) {
this._partial = buf;
break;
}
let next = end + 1;
// Eat carriage return as well if present
if (end >= 1 && buf[end-1] === "\r".charCodeAt(0)) {
end -= 1;
}
let line = buf.slice(0, end);
buf = buf.slice(next);
this._callback(line);
}
}
/**
* Mark end of stream
*
* Signal the end of the bytestream. If the previous invocation of the
* data method ended with a partial line this will invoke the callback
* with this line.
*/
end() {
if (this._partial) {
this._callback(this._partial);
this._partial = null;
}
}
}
/**
* Interpret lines of output from Factorio
*
@@ -286,7 +214,6 @@ class LineSplitter {
function parseOutput(line, source) {
let output = {
source,
received: Date.now(),
};
// There are three broad categories of output from Factorio, the first kind
@@ -302,13 +229,15 @@ function parseOutput(line, source) {
// A seconds output has two different kinds of formats: The first is a
// log level and source location and has a format of
// "Level File.cpp:123: message"
const secLogRegex = /^(\w+) (\w+\.cpp:\d+): (.*)$/;
// "Script @/path/to/file.lua:123: message"
// "Script =(command):123: message"
const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/;
let secLogMatch = secLogRegex.exec(secContent);
if (secLogMatch) {
output.type = "log";
output.level = secLogMatch[1];
output.file = secLogMatch[2];
output.message = secLogMatch[3];
output.message = secLogMatch[5];
// The other possibility is that the content is a generic message
} else {
@@ -365,15 +294,15 @@ const outputHeuristics = [
// Message indicating the RCON interface has started
{
filter: { type: "log", message: /^Starting RCON interface/ },
action: function(output) {
action: function() {
this._startRcon().catch((err) => { this.emit("error", err); });
},
},
// Message indicating the server is done starting up
{
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function(output) {
filter: { type: "log", message: /^(update|map)Tick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function() {
this._notifyGameReady().catch((err) => { this.emit("error", err); });
},
},
@@ -384,8 +313,8 @@ const outputHeuristics = [
type: "log",
message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/,
},
action: function(output) {
let name = /^Saving to (_autosave\d+) /.exec(output.message)[1];
action: function(parsed) {
let name = /^Saving to (_autosave\d+) /.exec(parsed.message)[1];
this.emit("_autosave", name);
},
},
@@ -396,7 +325,7 @@ const outputHeuristics = [
type: "log",
message: /^Saving game as /,
},
action: function(output) {
action: function() {
this.emit("_saving");
},
},
@@ -412,7 +341,7 @@ const outputHeuristics = [
"Can't save to default location: Default location not known",
],
},
action: function(output) {
action: function() {
this.emit("_saved");
},
},
@@ -423,13 +352,13 @@ const outputHeuristics = [
type: "generic",
message: /^Quitting: /,
},
action: function(output) {
action: function(parsed) {
this.emit("_quitting");
// On windows the Factorio server will wait for input on the console input buffer
// when an error occurs. Since we can't send input there we terminate the server
// process when this happens.
if (process.platform === "win32" && output.message === "Quitting: multiplayer error.") {
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
this._server.kill();
}
},
@@ -445,8 +374,8 @@ const outputHeuristics = [
"MultiplayerManager failed: Host address is already in use.",
],
},
action: function(output) {
this._unexpected.push(`Factorio failed to bind to game port: ${output.message}`);
action: function(parsed) {
this._unexpected.push(`Factorio failed to bind to game port: ${parsed.message}`);
},
},
{
@@ -458,8 +387,8 @@ const outputHeuristics = [
"Can't bind socket: Permission denied",
],
},
action: function(output) {
this._unexpected.push(`Factorio failed to bind to RCON port: ${output.message}`);
action: function(parsed) {
this._unexpected.push(`Factorio failed to bind to RCON port: ${parsed.message}`);
},
},
];
@@ -480,6 +409,7 @@ const outputHeuristics = [
* - 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
* - save-finished - invoked when the server has finished a manual save
* - exit - invoked when the sterver has exited
* @extends events.EventEmitter
* @memberof module:lib/factorio
@@ -491,12 +421,23 @@ class FactorioServer extends events.EventEmitter {
* @param {string} factorioDir - Directory of the Factorio install(s).
* @param {string} writeDir - Directory to write runtime data to.
* @param {object} options - Optional parameters.
* @param {Logger} options.logger - Logger to use for reporting errors.
* @param {string} options.version -
* Version of Factorio to use. Can also be the string "latest" to
* use the latest version found in `factorioDir`.
* @param {number} options.gamePort - UDP port to host game on.
* @param {number} options.rconPort - TCP port to use for RCON.
* @param {string} options.rconPassword - Password use for RCON.
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
* @param {boolean} options.enableAuthserverBans -
* Enable Factorio.com based multiplayer bans.
* @param {boolean} options.verboseLogging - Enable verbose logging.
* @param {boolean} options.stripPaths - Strip paths in the console.
* @param {number} options.maxConcurrentCommands -
* Maximum number of RCON commands transmitted in parallel on the
* RCON connection.
* @param {number} options.hangTimeout -
* Timeout in ms to wait for a response to a shutdown command.
*/
constructor(factorioDir, writeDir, options) {
super();
@@ -508,6 +449,7 @@ class FactorioServer extends events.EventEmitter {
this._version = null;
this._dataDir = null;
this._logger = options.logger || logger;
this._targetVersion = options.version || "latest";
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
@@ -517,29 +459,54 @@ class FactorioServer extends events.EventEmitter {
this.rconPassword = options.rconPassword;
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist;
/** Enable Factorio.com based multiplayer bans **/
this.enableAuthserverBans = options.enableAuthserverBans;
/** Enable verbose logging */
this.verboseLogging = options.verboseLogging;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
/** Timeout in ms to wait for a response to a shutdown command */
this.hangTimeout = options.hangTimeout || 5000;
this._state = "new";
this._server = null;
this._rconClient = null;
this._rconReady = false;
this._gameReady = false;
if (options.stripPaths) {
let chars = new Set(path.resolve(this.writePath("temp")));
chars.delete(":"); // Having a colon could lead to matching the line number
chars = [...chars].join("");
let tempPath = `${path.resolve(this.writePath("temp", "currently-playing"))}${path.sep}`;
let writePath = `${path.resolve(this.writePath())}${path.sep}`;
this._stripRegExp = new RegExp(
// The script printer formats paths using / on both windows and linux.
// But the save path is printed with \ on windows and / on linux.
`(${escapeRegExp(tempPath.replace(/\\/g, "/"))})|` +
`(${escapeRegExp(writePath)})|` +
`(\\.\\.\\.[${escapeRegExp(chars)}/]*?currently-playing/)`,
"g"
);
}
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
this._stdout = new LineSplitter((line) => { this._handleOutput(line, "stdout"); });
this._stderr = new LineSplitter((line) => { this._handleOutput(line, "stderr"); });
this._killed = false;
// Track autosaving
this._runningAutosave = null;
this.on("_autosave", function(name) {
this.on("_autosave", name => {
this.emit("autosave-start", name);
this._runningAutosave = name;
});
this.on("_saved", function() {
this.on("_saved", () => {
if (this._runningAutosave) {
this.emit("autosave-finished", this._runningAutosave);
this._runningAutosave = null;
} else {
this.emit("save-finished");
}
});
}
@@ -569,7 +536,11 @@ class FactorioServer extends events.EventEmitter {
let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
if (type === "j") {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
try {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
} catch (err) {
throw new Error(`Malformed JSON to ${channel}: ${line.slice(channelEnd + 2).toString("utf-8")}`);
}
} else if (type === "f") {
let fileName = line.slice(channelEnd + 2).toString("utf-8");
@@ -594,7 +565,7 @@ class FactorioServer extends events.EventEmitter {
}
if (!this.emit(`ipc-${channel}`, content)) {
console.warn(`Warning: Unhandled ipc-${channel}`, content);
this._logger.warn(`Warning: Unhandled ipc-${channel}`, { content });
}
}
@@ -606,31 +577,37 @@ class FactorioServer extends events.EventEmitter {
this.emit(source, line);
let output = parseOutput(line.toString("utf-8"), source);
line = line.toString("utf-8");
if (this._stripRegExp) {
line = line.replace(this._stripRegExp, "");
}
let parsed = parseOutput(line, source);
heuristicLoop: for (let heuristic of outputHeuristics) {
for (let [name, expected] of Object.entries(heuristic.filter)) {
if (!Object.hasOwnProperty.call(output, name)) {
if (!Object.hasOwnProperty.call(parsed, name)) {
continue heuristicLoop;
}
if (expected instanceof RegExp) {
if (!expected.test(output[name])) {
if (!expected.test(parsed[name])) {
continue heuristicLoop;
}
} else if (expected instanceof Array) {
if (!expected.includes(output[name])) {
if (!expected.includes(parsed[name])) {
continue heuristicLoop;
}
} else if (expected !== output[name]) {
} else if (expected !== parsed[name]) {
continue heuristicLoop;
}
}
// If we get here the filter matched the output
heuristic.action.call(this, output);
heuristic.action.call(this, parsed);
}
this.emit("output", output);
this.emit("output", parsed, line);
}
async _startRcon() {
@@ -643,11 +620,11 @@ class FactorioServer extends events.EventEmitter {
}
let config = {
host: "localhost",
host: "127.0.0.1", // localhost may resolve to ::1 while Factorio listen to only IPv4 by default.
port: this.rconPort,
password: this.rconPassword,
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
maxPending: 5,
maxPending: this.maxConcurrentCommands,
};
this._rconClient = new rconClient.Rcon(config);
@@ -655,12 +632,32 @@ class FactorioServer extends events.EventEmitter {
this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); });
this._rconClient.on("end", () => {
this._rconClient = null;
this._rconReady = false;
this._startRcon().catch(() => { /* Ignore */ });
});
// XXX: Workaround to suppress bogus event listener warning
if (this._rconClient.emitter instanceof events.EventEmitter) {
this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5);
}
await this._rconClient.connect();
}
get maxConcurrentCommands() {
return this._maxConcurrentCommands;
}
set maxConcurrentCommands(value) {
this._maxConcurrentCommands = value;
this.setMaxListeners(value + 5);
if (this._rconClient) {
if (this._rconClient.sendQueue) {
this._rconClient.sendQueue.maxConcurrent = value;
}
// XXX: Workaround to suppress bogus event listener warning
if (this._rconClient.emitter instanceof events.EventEmitter) {
this._rconClient.emitter.setMaxListeners(value + 5);
}
}
}
/**
* Initialize class instance
*
@@ -694,43 +691,51 @@ class FactorioServer extends events.EventEmitter {
}
_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(); });
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { this._handleOutput(line, "stdout"); });
this._server.stdout.pipe(stdout);
let stderr = new LineSplitter({ readableObjectMode: true });
stderr.on("data", line => { this._handleOutput(line, "stderr"); });
this._server.stderr.pipe(stderr);
}
_watchExit() {
this._server.on("exit", (code, signal) => {
if (this._state !== "stopping") {
if (this._unexpected.length === 0) {
if (signal === "SIGKILL") {
if (signal === "SIGKILL") {
if (this._killed) {
this.emit("error", new libErrors.EnvironmentError("Factorio server was killed"));
} else {
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}`;
} else {
msg = `Factorio server was unexpectedly shut by down by signal ${signal}`;
}
this.emit("error", new Error(msg));
}
} else if (this._unexpected.length === 0) {
let msg;
if (code !== null) {
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 libErrors.EnvironmentError(msg));
} else if (this._unexpected.length === 1) {
this.emit("error", new Error(this._unexpected[0]));
this.emit("error", new libErrors.EnvironmentError(this._unexpected[0]));
} else {
this.emit("error", new Error(
"Factorio server unexpectedly shut down. Possible causes:\n- "+
this._unexpected.join("\n- ")
this.emit("error", new libErrors.EnvironmentError(
"Factorio server unexpectedly shut down. Possible causes:"+
`\n- ${this._unexpected.join("\n- ")}`
));
}
}
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
// Reset server state
this._state = "init";
this._server = null;
@@ -763,17 +768,27 @@ class FactorioServer extends events.EventEmitter {
*
* @param {string} name -
* Name of the save to create. Should end with ".zip".
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async create(name) {
async create(name, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "create";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -786,6 +801,7 @@ class FactorioServer extends events.EventEmitter {
try {
let [code, signal] = await events.once(this._server, "exit");
void signal;
if (code !== 0) {
throw new Error(`Factorio exited with status ${code}`);
}
@@ -808,7 +824,7 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
@@ -816,6 +832,8 @@ class FactorioServer extends events.EventEmitter {
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -835,21 +853,32 @@ class FactorioServer extends events.EventEmitter {
* argument to start the given scenario.
*
* @param {string} scenario - Name of the scenario to run.
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async startScenario(scenario) {
async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "running";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", this.gamePort,
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -880,6 +909,9 @@ class FactorioServer extends events.EventEmitter {
if (!this._rconReady) {
await events.once(this, "rcon-ready");
}
if (!this._rconClient) {
throw new Error("RCON connection lost");
}
let response = await this._rconClient.send(message);
if (expectEmpty && response !== "") {
@@ -898,17 +930,25 @@ class FactorioServer extends events.EventEmitter {
this._check(["running"]);
let hanged = true;
function setAlive(output) { hanged = false; }
let stopped = false;
function setAlive() { hanged = false; }
let timeoutId = setTimeout(() => {
if (hanged) {
console.error("Factorio appears to have hanged, sending SIGKILL");
this._logger.error("Factorio appears to have hanged, sending SIGKILL");
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server.kill("SIGKILL");
}
}, 5000);
}, this.hangTimeout);
// It's possible the server decides to exit on its own, in which
// case the stop operation has to be cancelled.
this._server.once("exit", () => {
stopped = true;
clearTimeout(timeoutId);
});
this._state = "stopping";
// On Windows we need to save the map as there's no graceful shutdown.
@@ -940,6 +980,12 @@ class FactorioServer extends events.EventEmitter {
await this._rconClient.end();
}
// The Factorio server may have decided to get ahead of us and
// stop by itself before we had the chance to signal it.
if (stopped) {
return;
}
// On linux this sends SIGTERM to the process. On windows this
// will terminate the process with no cleanup. You can send your
// complaint for the lack of graceful shutdown to the Factorio
@@ -970,12 +1016,18 @@ class FactorioServer extends events.EventEmitter {
* Kill the server
*
* Terminates the server without any cleanup or saving.
* @param {boolean} [unexpected=false] -
* If true raise an error event as a result of killing the Factorio
* process.
*/
async kill() {
async kill(unexpected = false) {
this._check(["running", "stopping"]);
this._state = "stopping";
this._killed = true;
if (!unexpected) {
this._state = "stopping";
}
this._server.kill("SIGKILL");
await events.once(this._server, "exit");
await new Promise(resolve => this._server.once("exit", resolve));
}
/**
@@ -1020,6 +1072,20 @@ class FactorioServer extends events.EventEmitter {
return path.join(this._dataDir, ...parts);
}
/**
* Get Factorio binary path
*
* Get the path to the factorio binary depending on the platform (MacOS support)
*
* @returns {string} Path to factorio binary
*/
binaryPath() {
if (process.platform === "darwin") {
return this.dataPath("..", "MacOS", "factorio");
}
return this.dataPath("..", "bin", "x64", "factorio");
}
/**
* Return path in write directory
*
@@ -1045,13 +1111,26 @@ class FactorioServer extends events.EventEmitter {
}
async _writeConfigIni() {
let content = ini.encode({
let content = libIni.stringify({
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
},
});
await fs.outputFile(this.writePath("config.ini"), content);
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings, mapSettings) {
if (mapGenSettings) {
await libFileOps.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
);
}
if (mapSettings) {
await libFileOps.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
);
}
}
}
@@ -1065,6 +1144,5 @@ module.exports = {
_findVersion: findVersion,
_randomDynamicPort: randomDynamicPort,
_generatePassword: generatePassword,
_LineSplitter: LineSplitter,
_parseOutput: parseOutput,
};
+80 -1
View File
@@ -4,7 +4,7 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const crypto = require("crypto"); // needed for getTempFile
/**
* Returns the newest file in a directory
*
@@ -32,6 +32,85 @@ async function getNewestFile(directory, filter = (name) => true) {
return newestFile;
}
/**
* Modifies name in case it already exisist at the given directory
*
* Checks the directory passed if it already contains a file or folder with
* the given name, if it does not returns name unmodified, otherwise it
* returns a modified name that does not exist in the directory.
*
* Warning: this function should not be relied upon to be accurate for
* security sensitive applications. The selection process is inherently
* racy and a file or folder may have been created in the folder by the time
* this function returns.
*
* @param {string} directory - directory to check in.
* @param {string} name - file name to check for, may have extension.
* @param {string} extension - dot extension used for the file name.
* @returns {string} modified name with extension that likely does not exist
* in the folder
*/
async function findUnusedName(directory, name, extension = "") {
if (extension && name.endsWith(extension)) {
name = name.slice(0, -extension.length);
}
while (true) {
if (!await fs.pathExists(path.join(directory, `${name}${extension}`))) {
return `${name}${extension}`;
}
let match = /^(.*?)(-(\d+))?$/.exec(name);
if (!match[2]) {
name = `${match[1]}-2`;
} else {
name = `${match[1]}-${Number.parseInt(match[3], 10) + 1}`;
}
}
}
/**
* Warning: this function should not be relied upon to be accurate for
* security sensitive applications. The selection process is inherently
* racy and a file or folder may have been created in the folder by the time
* this function returns.
*
* @param {string} prefix - Prefix for file
* @param {string} suffix - Suffix for file
* @param {string} tmpdir - Directory for temp file
*/
async function getTempFile(prefix, suffix, tmpdir) {
prefix = (typeof prefix !== "undefined") ? prefix : "tmp.";
suffix = (typeof suffix !== "undefined") ? suffix : "";
tmpdir = (typeof tmpdir !== "undefined") ? tmpdir : "./";
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
let freeFile = await findUnusedName(tmpdir, fileName);
let fullPath = path.join(tmpdir, freeFile);
return fullPath;
}
/**
* Safely write data to a file
*
* Same as fs-extra.outputFile except the data is written to a temporary
* file that's renamed over the target file. The name of the temporary file
* is the same as the target file with the suffix `.tmp` added.
*
* If the operation fails it may leave behind the temporary file. This
* should not be too much of an issue as the next time the same file is
* written the temporary will be overwritten and renamed to the target file.
*
* @param {string} file - Path to file to write.
* @param {string|Buffer} data - Content to write.
* @param {object|string} options - see fs.writeFile, `flag` must not be set.
*/
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
module.exports = {
getNewestFile,
findUnusedName,
getTempFile,
safeOutputFile,
};
+3 -3
View File
@@ -14,14 +14,14 @@ const fs = require("fs");
* @returns {Promise<string>} hash of the stream.
*/
function hashStream(stream) {
return new Promise(function(resolve, reject) {
return new Promise((resolve, reject) => {
let hasher = crypto.createHash("sha1");
hasher.setEncoding("hex");
hasher.on("finish", function() {
hasher.on("finish", () => {
resolve(hasher.read());
});
stream.on("error", function(error) {
stream.on("error", (error) => {
// the docs doesn't say anything about what's passed on error
reject(error);
});
+76
View File
@@ -19,6 +19,82 @@ function basicType(value) {
return typeof value;
}
/**
* Asynchronously wait for the given duration
*
* @param {number} duration - Time to wait for in milliseconds.
*/
async function wait(duration) {
await new Promise(resolve => { setTimeout(resolve, duration); });
}
/**
* Resolve a promise with a timeout.
*
* @param {Promise} promise - Promise to wait for.
* @param {number} time - Maximum time im milliseconds to wait for.
* @param {*=} timeoutResult - Value to return if the operation timed out.
*/
async function timeout(promise, time, timeoutResult) {
let timer;
try {
return await Promise.race([
promise,
new Promise(resolve => {
timer = setTimeout(() => resolve(timeoutResult), time);
}),
]);
} finally {
clearTimeout(timer);
}
}
/**
* Read stream to the end and return its content
*
* Reads the stream given asynchronously until the end is reached and
* returns all the data which was read from the stream.
*
* @param {Readable} stream - byte stream to read to the end.
* @returns {Buffer} content of the stream.
*/
async function readStream(stream) {
let chunks = [];
for await (let chunk of stream) {
// Support using ^Z to end input on Windows
if (process.platform === "win32" && stream.isTTY && chunk.toString() === "\x1a\r\n") {
break;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
/**
* Escapes text for inclusion in a RegExp
*
* Adds \ character in front of special meta characters in the passsed in
* text so that it can be embedded into a RegExp and only match the text.
*
* See https://stackoverflow.com/a/9310752
*
* @param {string} text - Text to escape RegExp meta chars in.
* @returns {string} escaped text.
*/
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
module.exports = {
basicType,
wait,
timeout,
readStream,
escapeRegExp,
};
+49
View File
@@ -0,0 +1,49 @@
"use strict";
const libBuildMod = require("./build_mod");
const libCommand = require("./command");
const libConfig = require("./config");
const libDatabase = require("./database");
const libErrors = require("./errors");
const libFactorio = require("./factorio");
const libFileOps = require("./file_ops");
const libHash = require("./hash");
const libHelpers = require("./helpers");
const libLink = require("./link");
const libLogging = require("./logging");
const libLoggingUtils = require("./logging_utils");
const libLuaTools = require("./lua_tools");
const libPlugin = require("./plugin");
const libPluginLoader = require("./plugin_loader");
const libPrometheus = require("./prometheus");
const libSchema = require("./schema");
const libSharedCommands = require("./shared_commands");
const libUsers = require("./users");
const ExponentialBackoff = require("./ExponentialBackoff");
const RateLimiter = require("./RateLimiter");
module.exports = {
libBuildMod,
libCommand,
libConfig,
libDatabase,
libErrors,
libFactorio,
libFileOps,
libHash,
libHelpers,
libLink,
libLogging,
libLoggingUtils,
libLuaTools,
libPlugin,
libPluginLoader,
libPrometheus,
libSchema,
libSharedCommands,
libUsers,
ExponentialBackoff,
RateLimiter,
};
+94
View File
@@ -0,0 +1,94 @@
/**
* Factorio Compatible INI parser
*
* @module lib/ini
* @author Hornwitser
*/
"use strict";
/**
* Parse INI file into object of sections
* @param {string} input - content of INI file to parse.
* @returns {object<string, object<string, string>>}
* sections parsed from the ini file.
*/
function parse(input) {
const sections = {};
let currentSection = sections;
const lines = input.split(/\r\n|\n/);
for (let number = 1; number <= lines.length; number++) {
let line = lines[number - 1].replace(/^[\t ]+/, "");
if (!line || line[0] === "#" || line[0] === ";") {
continue;
}
// Read section header
if (line[0] === "[") {
line = line.replace(/[\t ]+$/, "");
if (line.slice(-1) !== "]") {
throw new Error(`Unterminated section header on line ${number}`);
}
const name = line.slice(1, -1);
if (Object.prototype.hasOwnProperty.call(sections, name)) {
throw new Error(`Duplicated section [${name}] on line ${number}`);
}
currentSection = {};
sections[name] = currentSection;
continue;
}
// Read key value
const equalIndex = line.indexOf("=");
if (equalIndex === -1) {
throw new Error(`Missing value for key ${line} on line ${number}`);
}
const key = line.slice(0, equalIndex);
if (Object.prototype.hasOwnProperty.call(currentSection, key)) {
throw new Error(`Duplicated key ${key} on line ${number}`);
}
const value = line.slice(equalIndex + 1);
currentSection[key] = value;
}
return sections;
}
/**
* Serialize an object of section definitions into an INI file
* @param {object<string, object<string, string>>} sections -
* Sections to encode into ini format
* @returns {string} sections serialized into INI format
*/
function stringify(sections) {
let serialized = [];
let first = true;
for (let [key, value] of Object.entries(sections)) {
if (typeof value === "string") {
serialized.push(`${key}=${value}`);
first = false;
}
}
for (let [section, content] of Object.entries(sections)) {
if (typeof content === "object") {
if (!first) {
serialized.push("");
}
first = false;
serialized.push(`[${section}]`);
for (let [key, value] of Object.entries(content)) {
serialized.push(`${key}=${value}`);
}
}
}
if (serialized.length) { serialized.push(""); }
return serialized.join("\n");
}
module.exports = {
parse,
stringify,
};
+3 -1
View File
@@ -1,6 +1,8 @@
/* eslint-disable node/global-require */
"use strict";
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
// eslint-disable-next-line node/no-extraneous-require
module.exports = require("fast-deep-equal/es6");
} else {
module.exports = require("util").isDeepStrictEqual;
+204 -109
View File
@@ -3,8 +3,10 @@
const events = require("events");
const WebSocket = require("isomorphic-ws");
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const ExponentialBackoff = require("../ExponentialBackoff");
/**
@@ -14,9 +16,14 @@ const libErrors = require("@clusterio/lib/errors");
* @memberof module:lib/link
*/
class WebSocketBaseConnector extends events.EventEmitter {
constructor() {
constructor(sessionTimeout) {
super();
this._sessionTimeout = sessionTimeout;
// One of closed, connecting (client only), connected and resuming.
this._state = "closed";
this._closing = false;
this._socket = null;
this._seq = 1;
this._lastHeartbeat = null;
@@ -26,6 +33,26 @@ class WebSocketBaseConnector extends events.EventEmitter {
this._sendBuffer = [];
}
_reset() {
this._state = "closed";
this._closing = false;
this._socket = null;
this._seq = 1;
this._lastHeartbeat = null;
this._heartbeatId = null;
this._heartbeatInterval = null;
this._lastReceivedSeq = null;
this._sendBuffer.length = 0;
}
_check(...expectedStates) {
if (!expectedStates.includes(this._state)) {
throw new Error(
`Expected state ${expectedStates} but state is ${this._state}`
);
}
}
_dropSendBufferSeq(seq) {
if (seq === null) {
return;
@@ -43,12 +70,15 @@ class WebSocketBaseConnector extends events.EventEmitter {
}
_doHeartbeat() {
this._check(["connected", "closing"]);
this._check("connected");
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
console.log("SOCKET | closing after heartbeat timed out");
this._socket.close(1008, "Heartbeat timeout");
clearInterval(this._heartbeatId);
this._heartbeatId = null;
logger.verbose("Connector | closing after heartbeat timed out");
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket.close(4008, "Heartbeat timeout");
} else {
this._socket.terminate();
}
} else {
this._socket.send(JSON.stringify({
@@ -61,9 +91,8 @@ class WebSocketBaseConnector extends events.EventEmitter {
_processHeartbeat(message) {
if (!libSchema.heartbeat(message)) {
console.log("SOCKET | closing after received invalid heartbeat:", message);
logger.warn("Connector | closing after received invalid heartbeat");
this._socket.close(1002, "Invalid heartbeat");
console.log(libSchema.heartbeat.errors);
return;
}
@@ -75,6 +104,9 @@ class WebSocketBaseConnector extends events.EventEmitter {
* Start sending heartbeats
*/
startHeartbeat() {
if (this._heartbeatId) {
throw new Error("heartbeat is already running");
}
this._lastHeartbeat = Date.now();
this._heartbeatId = setInterval(() => {
this._doHeartbeat();
@@ -88,6 +120,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
if (this._heartbeatId) {
clearInterval(this._heartbeatId);
this._heartbeatId = null;
this._lastHeartbeat = null;
}
}
@@ -99,14 +132,14 @@ class WebSocketBaseConnector extends events.EventEmitter {
* @returns {number} the sequence number of the message sent
*/
send(type, data) {
if (!["handshake", "connected", "closing"].includes(this._state)) {
if (!["connected", "resuming"].includes(this._state)) {
throw new libErrors.SessionLost("No session");
}
let seq = this._seq;
this._seq += 1;
let message = { seq, type, data };
this._sendBuffer.push(message);
if (["connected", "closing"].includes(this._state)) {
if (this._state === "connected") {
this._socket.send(JSON.stringify(message));
}
return seq;
@@ -115,11 +148,11 @@ class WebSocketBaseConnector extends events.EventEmitter {
/**
* Set connection state to closing
*
* Signal the connection is about tho close and that once the close
* Signal the connection is about to close and that once the close
* frame is received it should be considered finished.
*/
setClosing() {
this._state = "closing";
throw new Error("Abstract function");
}
/**
@@ -133,7 +166,15 @@ class WebSocketBaseConnector extends events.EventEmitter {
* True if the connection is closing down or closed
*/
get closing() {
return ["closing", "closed"].includes(this._state);
return this._closing || this._state === "closed";
}
/**
* True if the connector currently has a valid session that is either
* connected or in the process of resuming.
*/
get hasSession() {
return ["connected", "resuming"].includes(this._state);
}
}
@@ -144,40 +185,32 @@ class WebSocketBaseConnector extends events.EventEmitter {
* @memberof module:lib/link
*/
class WebSocketClientConnector extends WebSocketBaseConnector {
constructor(url, reconnectDelay) {
super();
constructor(url, maxReconnectDelay, tlsCa = null) {
super(null);
this._url = url;
this._backoff = new ExponentialBackoff({ max: maxReconnectDelay });
this._tlsCa = tlsCa;
// The following states are used in the client connector
// new: Not connected
// handshake: Attempting to (re)connect to server.
// closed: Not connected
// connecting: Attempting to connect to server.
// resuming: Attempting to resume an existing connection to the server.
// connected: Connection is online
// closing: Connection is in the process of being closed.
this._state = "new";
this._reconnectId = null;
this._sessionToken = null;
this._connected = false;
this._reconnectDelay = reconnectDelay;
this._timeout = 15 * 60;
this._startedReconnect = null;
this._startedResuming = null;
}
_check(expectedStates) {
if (!expectedStates.includes(this._state)) {
throw new Error(
`Expected state ${expectedStates} but state is ${this._state}`
);
}
}
setTimeout(timeout) {
this._timeout = timeout;
if (this._reconnectId && this.timeout < this._reconnectDelay / 2) {
clearTimeout(this._reconnectId);
this.reconnect();
}
_reset() {
clearTimeout(this._reconnectId);
this._reconnectId = null;
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sessionTimeout = null;
this._sendBuffer.length = 0;
this._startedResuming = null;
super._reset();
}
/**
@@ -190,7 +223,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
* @param {Object} data - Data to send with message.
*/
sendHandshake(type, data) {
this._check(["handshake"]);
this._check("connecting", "resuming");
this._socket.send(JSON.stringify({ seq: null, type, data }));
}
@@ -203,27 +236,53 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
* @param {string} reason - WebSocket close reason.
*/
async close(code, reason) {
if (this._state === "new") {
if (this._state === "closed") {
return;
}
if (this._reconnectId) {
clearTimeout(this._reconnectId);
this._reconnectId = null;
this._closing = true;
if (this._state === "connected" || this._socket) {
this._socket.close(code, reason);
await events.once(this, "close");
} else {
this._reset();
this.emit("close");
}
}
/**
* Notify the connection is expected to close
*
* Sets the internal flag to signal not to attempt to resume the
* connection if the server closes it. This will close the connection
* if it's currently attempting to connect or resume.
*/
setClosing() {
if (this._state === "closed") {
return;
}
this.stopHeartbeat();
this._state = "closing";
this._socket.close(code, reason);
await events.once(this, "close");
this._closing = true;
if (this._state === "connected") {
return;
}
if (this._socket) {
this._socket.close(1000, "Connector closing");
} else {
this._reset();
this.emit("close");
}
}
/**
* Connect to the master server
*/
async connect() {
this._check(["new"]);
this._state = "handshake";
this._check("closed");
this._state = "connecting";
this._doConnect();
@@ -231,23 +290,21 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
await events.once(this, "connect");
}
async _doConnect() {
_doConnect() {
let url = new URL(this._url);
url.pathname += "api/socket";
// Open WebSocket to master
console.log(`SOCKET | connecting to ${url}`);
logger.verbose(`Connector | connecting to ${url}`);
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket = new WebSocket(url);
} else {
this._socket = new WebSocket(url, {
// For now we do not verify TLS certificates since the default setup is
// to create a self-signed certificate.
rejectUnauthorized: false,
});
let options = {};
if (this._tlsCa) { options.ca = this._tlsCa; }
this._socket = new WebSocket(url, options);
}
this._attachSocketHandlers();
@@ -257,78 +314,113 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
* Attempt re-establishing the connecting again
*/
reconnect() {
if (this._socket) {
throw new Error("Cannot reconnect while socket is open");
}
if (this._reconnectId !== null) {
return;
logger.error("Unexpected double call to reconnect");
}
if (this._startedReconnect === null) {
this._startedReconnect = Date.now();
let delay = this._backoff.delay();
if (
this._state === "resuming"
&& this._startedResuming + this._sessionTimeout * 1000 < Date.now() + delay
) {
logger.error("Connector | Session timed out trying to resume");
this._reset();
this._state = "connecting";
this.emit("invalidate");
}
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`);
logger.verbose(
`Connector | waiting ${(Math.round(delay / 10) / 100)} seconds for reconnect`
);
this._reconnectId = setTimeout(() => {
this._reconnectId = null;
this._doConnect();
}, delay * 1000);
}, delay);
this._reconnectTime = Date.now() + delay;
}
_attachSocketHandlers() {
this._socket.onclose = event => {
console.log(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
const previousState = this._state;
// Authentication failed
if (event.code === 4003) {
this.emit("error", new libErrors.AuthenticationFailed(event.reason));
this._state = "closing";
this._closing = true;
}
if (this._state === "closing") {
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sendBuffer.length = 0;
this._state = "new";
this._connected = false;
this.emit("close");
this._socket = null;
if (this._state === "connected") {
this.stopHeartbeat();
if (this._closing) {
this._reset();
this.emit("close");
} else {
this._state = "resuming";
this._startedResuming = Date.now();
this.reconnect();
this.emit("drop");
}
} else {
this._state = "handshake";
this.reconnect();
if (this._connected) {
this._connected = false;
this.emit("drop");
// eslint-disable-next-line no-lonely-if
if (this._closing) {
this._reset();
this.emit("close");
} else {
this.reconnect();
}
}
this.stopHeartbeat();
// Log must be sent after state change is complete
let message = `Connector | Close (code: ${event.code}, reason: ${event.reason})`;
if (previousState === "connected" && event.code !== 1000) {
logger.info(message);
} else {
logger.verbose(message);
}
};
this._socket.onerror = event => {
// It's assumed that close is always called by ws
console.error("SOCKET | Error:", event);
let code = !event.error ? "" : `, code: ${event.error.code}`;
let message = `Connector | Socket error: ${event.message || "unknown error"}${code}`;
if (this._state === "connected") {
logger.error(message);
} else {
// Don't log as error if not actually connected, prevents
// log spam during reconnecting attempts.
logger.verbose(message);
}
if (event.error) {
// Abort connection if certificate validation failed
if ([
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"ERR_TLS_CERT_ALTNAME_INVALID",
].includes(event.error.code)) {
this.emit("error", new libErrors.AuthenticationFailed(event.error.message));
this._closing = true;
}
}
};
this._socket.onopen = () => {
console.log("SOCKET | Open");
logger.verbose("Connector | Open");
};
// Handle messages
this._socket.onmessage = event => {
let message = JSON.parse(event.data);
if (this._state === "handshake") {
if (["connecting", "resuming"].includes(this._state)) {
this._processHandshake(message);
} else if (["connected", "closing"].includes(this._state)) {
} else if (this._state === "connected") {
if (message.seq !== null) {
this._lastReceivedSeq = message.seq;
}
@@ -348,17 +440,17 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
_processHandshake(message) {
if (!libSchema.serverHandshake(message)) {
console.log("SOCKET | closing after received invalid handshake:", message);
logger.warn("Connector | closing after received invalid handshake");
this._socket.close(1002, "Invalid handshake");
return;
}
let { seq, type, data } = message;
let { type, data } = message;
if (type === "hello") {
console.log(`SOCKET | received hello from master version ${data.version}`);
logger.verbose(`Connector | received hello from master version ${data.version}`);
this.emit("hello", data);
if (this._sessionToken) {
console.log("SOCKET | Attempting resume");
logger.verbose("Connector | Attempting resume");
this.sendHandshake("resume", {
session_token: this._sessionToken,
last_seq: this._lastReceivedSeq,
@@ -368,36 +460,39 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
} else if (type === "ready") {
console.log("SOCKET | received ready from master");
logger.verbose("Connector | received ready from master");
this._state = "connected";
this._sessionToken = data.session_token;
this._sessionTimeout = data.session_timeout;
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("connect");
this.emit("connect", data);
} else if (type === "continue") {
console.log("SOCKET | resuming existing session");
logger.info("Connector | resuming existing session");
this._state = "connected";
this._heartbeatInterval = data.heartbeat_interval;
this._sessionTimeout = data.session_timeout;
this.startHeartbeat();
this._dropSendBufferSeq(data.last_seq);
for (let bufferedMessage of this._sendBuffer) {
this._socket.send(JSON.stringify(bufferedMessage));
}
this._startedReconnect = null;
this._connected = true;
this.emit("connect");
this._startedResuming = null;
this.emit("resume");
} else if (type === "invalidate") {
this._heartbeatInterval = null;
logger.warn("Connector | session invalidated by master");
this._state = "connecting";
this._seq = 1;
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sessionTimeout = null;
this._sendBuffer.length = 0;
this._startedResuming = null;
this.emit("invalidate");
this.register();
}
+7 -3
View File
@@ -4,8 +4,12 @@
* @author Hornwitser
*/
"use strict";
const link = require("./link");
const messages = require("./messages");
const connectors = require("./connectors");
module.exports = {
...require("./link"),
...require("./messages"),
...require("./connectors"),
...link,
...messages,
...connectors,
};
+19 -8
View File
@@ -1,9 +1,9 @@
// Implementation of Link class
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const messages = require("./messages");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
// Some definitions for the terminology used here:
// link: Either side of a master - client connection
@@ -35,9 +35,21 @@ class Link {
this.processMessage(payload);
} catch (err) {
if (err instanceof libErrors.InvalidMessage) {
console.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
logger.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
if (err.errors) {
console.error(err.errors);
logger.error(JSON.stringify(err.errors, null, 4));
}
// Send back an error response if this was a request.
if (
typeof payload.type === "string"
&& payload.type.endsWith("_request")
&& Number.isInteger(payload.seq)
) {
this.connector.send(
`${payload.type.slice(0, -8)}_response`,
{ seq: payload.seq, error: err.message }
);
}
} else {
this.connector.emit("error", err);
@@ -111,9 +123,8 @@ class Link {
if (handler) {
handler(message);
return true;
} else {
return false;
}
return false;
}
/**
@@ -123,7 +134,7 @@ class Link {
* @returns {Boolean} true if a waiter was triggered.
*/
_processWaiters(message) {
let { seq, type, data } = message;
let { type, data } = message;
let waiters = this._waiters.get(type);
if (!waiters || !waiters.length) {
return false;
+518 -110
View File
@@ -1,7 +1,28 @@
// Message definitions for links
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const PlayerStats = require("../PlayerStats");
class MissingLinkHandlerError extends Error {
constructor(type, source, target) {
super();
this.code = "MISSING_LINK_HANDLER";
this.type = type;
this.source = source;
this.target = target;
this.handler = null;
this.plugin = null;
}
get message() {
let handler = this.handler ? `${this.handler}` : "handler";
let plugin = this.plugin ? ` on plugin ${this.plugin}` : "";
return `Missing ${handler}${plugin} for ${this.type} on ${this.source}-${this.target} link`;
}
}
/**
* Represents a message that can be sent over the link
@@ -50,16 +71,24 @@ class Request extends Message {
* Optional target to forward this request to. 'instance' add
* instance_id into the requestProperties and forward to the given
* instance. 'master' forwards it to the master server.
* @param {?Array<string>} requestRequired -
* List of properties that are required to be present in the data
* payload of this request. Defaults to all.
* @param {Object<string, Object>} requestProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of this requst.
* @param {?Array<string>} responseRequired -
* List of properties that are required to be present in the data
* payload of the repsonse to this request. Defaults to all.
* @param {Object<string, Object>} responseProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of the response to
* this requst.
*/
constructor({
type, permission, links, forwardTo = null, requestProperties = {}, responseProperties = {},
type, permission, links, forwardTo = null,
requestRequired = null, requestProperties = {},
responseRequired = null, responseProperties = {},
}) {
super();
this.type = type;
@@ -68,8 +97,8 @@ class Request extends Message {
this.forwardTo = forwardTo;
this.handlerSuffix = "RequestHandler";
this.requestType = type + "_request";
this.responseType = type + "_response";
this.requestType = `${type}_request`;
this.responseType = `${type}_response`;
if (permission === undefined && links.includes("control-master")) {
throw new Error(`permission is required for ${this.type} request over control-master links`);
@@ -78,7 +107,12 @@ class Request extends Message {
throw new Error(`permission is not allowed on ${this.type} request as it is not over control-master link`);
}
if (!requestRequired) {
requestRequired = Object.keys(requestProperties);
}
if (forwardTo === "instance") {
requestRequired = ["instance_id", ...requestRequired];
requestProperties = {
"instance_id": { type: "integer" },
...requestProperties,
@@ -94,12 +128,16 @@ class Request extends Message {
"type": { const: this.requestType },
"data": {
additionalProperties: false,
required: Object.keys(requestProperties),
required: requestRequired,
properties: requestProperties,
},
},
});
if (!responseRequired) {
responseRequired = Object.keys(responseProperties);
}
this._responseValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
@@ -108,7 +146,7 @@ class Request extends Message {
anyOf: [
{
additionalProperties: false,
required: ["seq", ...Object.keys(responseProperties)],
required: ["seq", ...responseRequired],
properties: {
"seq": { type: "integer" },
...responseProperties,
@@ -168,15 +206,18 @@ class Request extends Message {
}
if (!handler) {
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
throw new MissingLinkHandlerError(this.requestType, link.source, link.target);
}
// Check permission if this is a handler for a control connection on the master
if (this.permission !== null && `${link.source}-${link.target}` === "master-control") {
let origHandler = handler;
handler = async function(message, request) {
this.user.checkPermission(request.permission);
return await origHandler.call(this, message, request);
// Abuse this binding to get a hold of the ControlConnection instance
// eslint-disable-next-line no-invalid-this, consistent-this
let controlConnection = this;
controlConnection.user.checkPermission(request.permission);
return await origHandler.call(controlConnection, message, request);
};
}
@@ -187,16 +228,17 @@ class Request extends Message {
response = {};
}
if (!this._responseValidator({ seq: 0, type: this.responseType, response })) {
console.error(this._responseValidator.errors);
let data = { ...response, seq: message.seq };
if (!this._responseValidator({ seq: 0, type: this.responseType, data })) {
logger.error(JSON.stringify(this._responseValidator.errors, null, 4));
throw new Error(`Validation failed responding to ${this.requestType}`);
}
link.connector.send(this.responseType, { ...response, seq: message.seq });
link.connector.send(this.responseType, data);
}).catch(err => {
if (!(err instanceof libErrors.RequestError)) {
console.error(`Unexpected error while responding to ${this.requestType}`, err);
logger.error(`Unexpected error while responding to ${this.requestType}:\n${err.stack}`);
}
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
});
@@ -217,7 +259,7 @@ class Request extends Message {
async send(link, data = {}) {
// XXX validate link target/source?
if (!this._requestValidator({ seq: 0, type: this.requestType, data })) {
console.error(this._requestValidator.errors);
logger.error(JSON.stringify(this._requestValidator.errors, null, 4));
throw new Error(`Validation failed sending ${this.requestType}`);
}
let seq = link.connector.send(this.requestType, data);
@@ -259,6 +301,46 @@ messages.debugDumpWs = new Request({
// Management requests
messages.getMasterConfig = new Request({
type: "get_master_config",
links: ["control-master"],
permission: "core.master.get_config",
responseProperties: {
"serialized_config": { type: "object" },
},
});
messages.setMasterConfigField = new Request({
type: "set_master_config_field",
links: ["control-master"],
permission: "core.master.update_config",
requestProperties: {
"field": { type: "string" },
"value": { type: "string" },
},
});
messages.setMasterConfigProp = new Request({
type: "set_master_config_prop",
links: ["control-master"],
permission: "core.master.update_config",
requestRequired: ["field", "prop"],
requestProperties: {
"field": { type: "string" },
"prop": { type: "string" },
"value": {},
},
});
let slaveProperties = {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
"public_address": { type: ["null", "string"] },
};
messages.listSlaves = new Request({
type: "list_slaves",
links: ["control-master"],
@@ -269,18 +351,46 @@ messages.listSlaves = new Request({
items: {
additionalProperties: false,
required: ["agent", "version", "name", "id", "connected"],
properties: {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
},
properties: slaveProperties,
},
},
},
});
messages.setSlaveSubscriptions = new Request({
type: "set_slave_subscriptions",
links: ["control-master"],
permission: "core.slave.subscribe",
requestProperties: {
"all": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
},
});
let instanceProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"game_port": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]},
};
messages.getInstance = new Request({
type: "get_instance",
links: ["control-master"],
permission: "core.instance.get",
requestProperties: {
"id": { type: "integer" },
},
responseProperties: instanceProperties,
});
messages.listInstances = new Request({
type: "list_instances",
links: ["control-master"],
@@ -291,25 +401,31 @@ messages.listInstances = new Request({
items: {
additionalProperties: false,
required: ["name", "id", "assigned_slave", "status"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
properties: instanceProperties,
},
},
},
});
messages.setInstanceSubscriptions = new Request({
type: "set_instance_subscriptions",
links: ["control-master"],
permission: "core.instance.subscribe",
requestProperties: {
"all": { type: "boolean" },
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.generateSlaveToken = new Request({
type: "generate_slave_token",
links: ["control-master"],
permission: "core.slave.generate_token",
requestProperties: {
"slave_id": { type: "integer" },
"slave_id": { type: ["integer", "null"] },
},
responseProperties: {
"token": { type: "string" },
@@ -330,18 +446,6 @@ messages.createSlaveConfig = new Request({
},
});
messages.setInstanceOutputSubscriptions = new Request({
type: "set_instance_output_subscriptions",
links: ["control-master"],
permission: "core.instance.follow_log",
requestProperties: {
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.createInstance = new Request({
type: "create_instance",
links: ["control-master"],
@@ -378,6 +482,7 @@ messages.setInstanceConfigProp = new Request({
type: "set_instance_config_prop",
links: ["control-master"],
permission: "core.instance.update_config",
requestRequired: ["instance_id", "field", "prop"],
requestProperties: {
"instance_id": { type: "integer" },
"field": { type: "string" },
@@ -392,19 +497,10 @@ messages.assignInstanceCommand = new Request({
permission: "core.instance.assign",
requestProperties: {
"instance_id": { type: "integer" },
"slave_id": { type: "integer" },
"slave_id": { type: ["integer", "null"] },
},
});
messages.assignInstance = new Request({
type: "assign_instance",
links: ["master-slave"],
requestProperties: {
"serialized_config": { type: "object" },
},
forwardTo: "instance",
});
messages.startInstance = new Request({
type: "start_instance",
links: ["control-master", "master-slave", "slave-instance"],
@@ -415,11 +511,141 @@ messages.startInstance = new Request({
forwardTo: "instance",
});
let saveList = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["type", "name", "size", "mtime_ms", "loaded"],
properties: {
"type": { enum: ["file", "directory", "special"] },
"name": { type: "string" },
"size": { type: "integer" },
"mtime_ms": { type: "number" },
"loaded": { type: "boolean" },
"default": { type: "boolean" },
},
},
};
messages.listSaves = new Request({
type: "list_saves",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.save.list",
forwardTo: "instance",
responseProperties: {
"list": saveList,
},
});
messages.createSave = new Request({
type: "create_save",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.create_save",
permission: "core.instance.save.create",
forwardTo: "instance",
requestProperties: {
"name": { type: "string" },
"seed": { type: ["integer", "null"] },
"map_gen_settings": { type: ["object", "null"] },
"map_settings": { type: ["object", "null"] },
},
});
messages.renameSave = new Request({
type: "rename_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.rename",
forwardTo: "instance",
requestProperties: {
"old_name": { type: "string" },
"new_name": { type: "string" },
},
});
messages.copySave = new Request({
type: "copy_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.copy",
forwardTo: "instance",
requestProperties: {
"source": { type: "string" },
"destination": { type: "string" },
},
});
messages.deleteSave = new Request({
type: "delete_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.delete",
forwardTo: "instance",
requestProperties: {
"save": { type: "string" },
},
});
messages.downloadSave = new Request({
type: "download_save",
links: ["control-master"],
permission: "core.instance.save.download",
requestProperties: {
"instance_id": { type: "integer" },
"save": { type: "string" },
},
responseProperties: {
"stream_id": { type: "string" },
},
});
messages.transferSave = new Request({
type: "transfer_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.transfer",
requestProperties: {
"instance_id": { type: "integer" },
"source_save": { type: "string" },
"target_instance_id": { type: "integer" },
"target_save": { type: "string" },
"copy": { type: "boolean" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.pullSave = new Request({
type: "pull_save",
links: ["master-slave"],
requestProperties: {
"instance_id": { type: "integer" },
"stream_id": { type: "string" },
"filename": { type: "string" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.pushSave = new Request({
type: "push_save",
links: ["master-slave"],
requestProperties: {
"instance_id": { type: "integer" },
"stream_id": { type: "string" },
"save": { type: "string" },
},
});
messages.setSaveListSubscriptions = new Request({
type: "set_save_list_subscriptions",
links: ["control-master"],
permission: "core.instance.save.list_subscribe",
requestProperties: {
"all": { type: "boolean" },
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.loadScenario = new Request({
@@ -429,6 +655,9 @@ messages.loadScenario = new Request({
forwardTo: "instance",
requestProperties: {
"scenario": { type: "string" },
"seed": { type: ["integer", "null"] },
"map_gen_settings": { type: ["object", "null"] },
"map_settings": { type: ["object", "null"] },
},
});
@@ -439,6 +668,13 @@ messages.exportData = new Request({
forwardTo: "instance",
});
messages.extractPlayers = new Request({
type: "extract_players",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.extract_players",
forwardTo: "instance",
});
messages.stopInstance = new Request({
type: "stop_instance",
links: ["control-master", "master-slave", "slave-instance"],
@@ -446,6 +682,13 @@ messages.stopInstance = new Request({
forwardTo: "instance",
});
messages.killInstance = new Request({
type: "kill_instance",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.kill",
forwardTo: "instance",
});
messages.deleteInstance = new Request({
type: "delete_instance",
links: ["control-master", "master-slave"],
@@ -551,6 +794,37 @@ messages.deleteRole = new Request({
},
});
const userRequired = ["name", "roles", "instances"];
const userProperties = {
"name": { type: "string" },
"roles": { type: "array", items: { type: "integer" }},
"is_admin": { type: "boolean" },
"is_banned": { type: "boolean" },
"is_whitelisted": { type: "boolean" },
"ban_reason": { type: "string" },
"instances": { type: "array", items: { type: "integer" }},
"is_deleted": { type: "boolean" },
"player_stats": PlayerStats.jsonSchema,
"instance_stats": {
type: "array",
items: {
type: "array",
items: [{ type: "integer" }, PlayerStats.jsonSchema],
},
},
};
messages.getUser = new Request({
type: "get_user",
links: ["control-master"],
permission: "core.user.get",
requestProperties: {
"name": { type: "string" },
},
responseRequired: userRequired,
responseProperties: userProperties,
});
messages.listUsers = new Request({
type: "list_users",
links: ["control-master"],
@@ -560,20 +834,26 @@ messages.listUsers = new Request({
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" }},
},
required: userRequired,
properties: userProperties,
},
},
},
});
messages.setUserSubscriptions = new Request({
type: "set_user_subscriptions",
links: ["control-master"],
permission: "core.user.subscribe",
requestProperties: {
"all": { type: "boolean" },
"names": {
type: "array",
items: { type: "string" },
},
},
});
messages.createUser = new Request({
type: "create_user",
links: ["control-master"],
@@ -636,8 +916,92 @@ messages.deleteUser = new Request({
},
});
messages.setLogSubscriptions = new Request({
type: "set_log_subscriptions",
links: ["control-master"],
permission: "core.log.follow",
requestProperties: {
"all": { type: "boolean" },
"master": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
"instance_ids": {
type: "array",
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
},
});
messages.queryLog = new Request({
type: "query_log",
links: ["control-master"],
permission: "core.log.query",
requestProperties: {
"all": { type: "boolean" },
"master": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
"instance_ids": {
type: "array",
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
"limit": { type: "integer" },
"order": { enum: ["asc", "desc"] },
},
responseProperties: {
"log": {
type: "array",
items: { type: "object" },
},
},
});
// Internal requests
messages.updateInstances = new Request({
type: "update_instances",
links: ["slave-master"],
requestProperties: {
"instances": {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["serialized_config", "status"],
properties: {
"serialized_config": { type: "object" },
"status": { enum: [
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
},
},
},
},
});
messages.assignInstance = new Request({
type: "assign_instance",
links: ["master-slave"],
requestProperties: {
"serialized_config": { type: "object" },
},
forwardTo: "instance",
});
messages.unassignInstance = new Request({
type: "unassigne_instance",
links: ["master-slave"],
forwardTo: "instance",
});
messages.getMetrics = new Request({
type: "statistics_exporter:get_metrics",
type: "get_metrics",
links: ["master-slave", "slave-instance"],
responseProperties: {
"results": { type: "array" },
@@ -675,11 +1039,14 @@ class Event extends Message {
* links that are downstream, so sending an event set to instance
* broadcast that's sent to a slave will only be broadcast to that
* slave's instances.
* @param {?Array<string>} eventRequired -
* List of properties required to be present in the event payload.
* Defaults to all.
* @param {Object<string, Object>} eventProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of this event.
*/
constructor({ type, links, forwardTo = null, broadcastTo = null, eventProperties = {} }) {
constructor({ type, links, forwardTo = null, broadcastTo = null, eventRequired = null, eventProperties = {} }) {
super();
this.type = type;
this.links = links;
@@ -687,7 +1054,12 @@ class Event extends Message {
this.broadcastTo = broadcastTo;
this.handlerSuffix = "EventHandler";
if (!eventRequired) {
eventRequired = Object.keys(eventProperties);
}
if (forwardTo === "instance") {
eventRequired = ["instance_id", ...eventRequired];
eventProperties = {
"instance_id": { type: "integer" },
...eventProperties,
@@ -701,14 +1073,14 @@ class Event extends Message {
throw new Error(`Invalid broadcastTo value ${broadcastTo}`);
}
this.eventType = type + "_event";
this.eventType = `${type}_event`;
this._eventValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.eventType },
"data": {
additionalProperties: false,
required: Object.keys(eventProperties),
required: eventRequired,
properties: eventProperties,
},
},
@@ -766,13 +1138,13 @@ class Event extends Message {
}
if (!handler) {
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
throw new MissingLinkHandlerError(this.eventType, link.source, link.target);
}
link.setHandler(this.eventType, message => {
// XXX Should event handlers even be allowed to be async?
handler.call(link, message, this).catch(err => {
console.error(`Unexpected error while handling ${this.eventType}`, err);
logger.error(`Unexpected error while handling ${this.eventType}:\n${err.stack}`);
});
}, this._eventValidator);
}
@@ -788,7 +1160,7 @@ class Event extends Message {
*/
send(link, data = {}) {
if (!this._eventValidator({ seq: 0, type: this.eventType, data })) {
console.error(this._eventValidator.errors);
logger.error(JSON.stringify(this._eventValidator.errors, null, 4));
throw new Error(`Validation failed sending ${this.eventType}`);
}
@@ -805,6 +1177,52 @@ messages.debugWsMessage = new Event({
},
});
messages.accountUpdate = new Event({
type: "account_update",
links: ["master-control"],
eventRequired: [],
eventProperties: {
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
});
messages.logMessage = new Event({
type: "log_message",
links: ["slave-master", "master-control"],
eventProperties: {
"info": {
type: "object",
required: ["level", "message"],
properties: {
"level": { type: "string" },
"message": { type: "string" },
},
},
},
});
messages.slaveUpdate = new Event({
type: "slave_update",
links: ["master-control"],
eventProperties: slaveProperties,
});
messages.instanceInitialized = new Event({
type: "instance_initialized",
links: ["instance-slave"],
@@ -819,60 +1237,39 @@ messages.instanceInitialized = new Event({
messages.instanceStatusChanged = new Event({
type: "instance_status_changed",
links: ["instance-slave", "slave-master"],
eventRequired: ["instance_id", "status"],
eventProperties: {
"instance_id": { type: "integer" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
"game_port": { type: ["null", "integer"] },
},
});
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.instanceUpdate = new Event({
type: "instance_update",
links: ["master-control"],
eventProperties: instanceProperties,
});
messages.instanceOutput = new Event({
type: "instance_output",
messages.saveListUpdate = new Event({
type: "save_list_update",
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" },
},
},
"list": saveList,
},
});
messages.userUpdate = new Event({
type: "user_update",
links: ["master-control"],
eventRequired: userRequired,
eventProperties: userProperties,
});
messages.masterConnectionEvent = new Event({
type: "master_connection_event",
links: ["slave-instance"],
@@ -939,10 +1336,13 @@ messages.playerEvent = new Event({
type: "player_event",
links: ["instance-slave", "slave-master"],
forwardTo: "master",
eventRequired: ["instance_id", "type", "name"],
eventProperties: {
"instance_id": { type: "integer" },
"type": { type: "string", enum: ["join", "leave"] },
"type": { type: "string", enum: ["join", "leave", "import"] },
"name": { type: "string" },
"reason": { type: "string" },
"stats": PlayerStats.jsonSchema,
},
});
@@ -959,7 +1359,15 @@ messages.playerEvent = new Event({
*/
function attachAllMessages(link) {
for (let [name, message] of Object.entries(messages)) {
message.attach(link, link[name + message.handlerSuffix]);
let handler = `${name}${message.handlerSuffix}`;
try {
message.attach(link, link[handler]);
} catch (err) {
if (err.code === "MISSING_LINK_HANDLER") {
err.handler = handler;
}
throw err;
}
}
}
+94
View File
@@ -0,0 +1,94 @@
"use strict";
const winston = require("winston");
const Transport = require("winston-transport");
const { LEVEL, MESSAGE } = require("triple-beam");
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
audit: 3,
info: 4,
server: 5,
verbose: 6,
});
const colors = {
fatal: "inverse red",
error: "bold brightRed",
warn: "bold brightYellow",
audit: "bold brightGreen",
info: "bold brightBlue",
server: "bold",
verbose: "bold grey",
};
winston.addColors(colors);
// The Console Transport is replicated here because it's overly complicated
// in winston, writes to stdout/err instead of console and doesn't work in
// the browser.
class ConsoleTransport extends Transport {
constructor(options = {}) {
super(options);
this.errorLevels = new Set(options.errorLevels || ["fatal", "error"]);
this.warnLevels = new Set(options.warnLevels || ["warn"]);
}
log(info, callback) {
/* eslint-disable no-console */
if (this.errorLevels.has(info[LEVEL])) {
console.error(info[MESSAGE]);
} else if (this.warnLevels.has(info[LEVEL])) {
console.warn(info[MESSAGE]);
} else {
console.log(info[MESSAGE]);
}
/* eslint-enable no-console */
callback();
}
}
/**
* Formats winston log messages for the web console
*/
class WebConsoleFormat {
constructor(options = {}) {
this.options = options;
}
transform(info, options) {
let src = " ";
if (info.slave_id) {
let slaveName = info.slave_id;
src += `s:${slaveName} - `;
}
if (info.instance_id) {
let instanceName = info.instance_id;
src += `i:${instanceName}`;
}
if (info.plugin) {
src += `${info.plugin}: `;
}
if (info.stack) {
info[MESSAGE] = `[${info.level}]${src}${info.stack}`;
} else {
info[MESSAGE] = `[${info.level}]${src}${info.message}`;
}
return info;
}
}
const logger = winston.createLogger({
level: "verbose",
levels,
format: winston.format.timestamp(),
});
module.exports = {
ConsoleTransport,
WebConsoleFormat,
colors,
levels,
logger,
};
+524
View File
@@ -0,0 +1,524 @@
/**
* Utilities for logging on the Node.js side
*
* @author Hornwitser
* @module lib/logging_utils
*/
"use strict";
const events = require("events");
const fs = require("fs-extra");
const path = require("path");
const stream = require("stream");
const util = require("util");
const winston = require("winston");
const Transport = require("winston-transport");
const { LEVEL, MESSAGE } = require("triple-beam");
const chalk = require("chalk");
const libLink = require("./link");
const libErrors = require("./errors");
const libFileOps = require("./file_ops");
const { levels, logger } = require("./logging");
const libStream = require("./stream");
const finished = util.promisify(stream.finished);
/**
* Format a parsed Factorio message with colors
*
* Formats a parsed Factorio output from lib/factorio into a readable
* colorized output using terminal escape codes that can be printed.
*
* @param {Object} parsed - Parsed Factorio server output.
* @returns {string} terminal colorized message.
* @private
*/
function formatServerOutput(parsed) {
let time = "";
if (parsed.format === "seconds") {
time = `${chalk.yellow(parsed.time.padStart(8))} `;
} else if (parsed.format === "date") {
time = `${chalk.yellow(parsed.time)} `;
}
let info = "";
if (parsed.type === "log") {
let level = parsed.level;
if (level === "Script") {
level = chalk.bold.greenBright(level);
} else if (level === "Verbose") {
level = chalk.bold.gray(level);
} else if (level === "Info") {
level = chalk.bold.blueBright(level);
} else if (parsed.level === "Warning") {
level = chalk.bold.yellowBright(level);
} else if (parsed.level === "Error") {
level = chalk.bold.redBright(level);
}
info = `${level} ${chalk.gray(parsed.file)}: `;
} else if (parsed.type === "action") {
info = `[${chalk.yellow(parsed.action)}] `;
}
return time + info + parsed.message;
}
// These are defined here to avoid circular dependencies and pulling them
// into the web interface code.
/**
* Formats winston log messages for a character terminal.
* @static
*/
class TerminalFormat {
constructor(options = {}) {
this.options = options;
this.colorize = winston.format.colorize(options);
}
transform(info, options) {
info = this.colorize.transform(info, this.colorize.options);
let ts = "";
if (options.showTimestamp && info.timestamp) {
ts = `${info.timestamp.replace("T", " ")} `;
}
let src = " ";
if (info.instance_name) {
src += `i:${info.instance_name} `;
} else if (info.slave_name) {
src += `s:${info.slave_name} `;
}
if (info.plugin) {
src += `${info.plugin}: `;
}
if (info[LEVEL] === "server" && info.parsed) {
info.message = formatServerOutput(info.parsed);
}
if (info.stack) {
info[MESSAGE] = `${ts}[${info.level}]${src}${info.stack}`;
} else {
info[MESSAGE] = `${ts}[${info.level}]${src}${info.message}`;
}
return info;
}
}
/**
* Sends logs over a lib/link connection.
* @static
*/
class LinkTransport extends Transport {
constructor(options) {
super(options);
this.link = options.link;
this.filter = options.filter || null;
}
log(info, callback) {
if (this.filter && !this.filter(info)) {
return callback();
}
try {
libLink.messages.logMessage.send(this.link, { info });
} catch (err) {
// Ignore session lost errors.
if (!(err instanceof libErrors.SessionLost)) {
throw err;
}
}
return callback();
}
}
const logFileGlob = /^[a-z]+-(\d{4}-\d{2}-\d{2})\.log$/;
/**
* Filter object for logs
* @typedef {Object} module:lib/logging_utils~LogFilter
* @property {string} [max_level] -
* Maximum log level to include. Higher levels are more verbose.
* @property {boolean} [all] -
* Include log entries from master, all slaves and all instances.
* @property {boolean} [master] -
* Include log entries from the master server.
* @property {Array<number>} [slave_ids] -
* Include log entries for the given slaves and instances of those
* slaves by id.
* @property {Array<number>} [instance_ids] -
* Include log entries for the given instances by id.
*/
/**
* Keeps an index over a log directory to speed up queries to it
* @static
*/
class LogIndex {
constructor(logDirectory, serialized) {
this.logDirectory = logDirectory;
this.index = new Map();
if (serialized.version !== 0) {
return;
}
for (let [file, serializedEntry] of Object.entries(serialized.files)) {
this.index.set(file, {
levels: new Set(serializedEntry.levels),
master: serializedEntry.master,
slave_ids: new Set(serializedEntry.slave_ids),
instance_ids: new Set(serializedEntry.instance_ids),
});
}
}
serialize() {
let serialized = {
version: 0,
files: {},
};
for (let [file, entry] of this.index) {
serialized.files[file] = {
levels: [...entry.levels],
master: entry.master,
slave_ids: [...entry.slave_ids],
instance_ids: [...entry.instance_ids],
};
}
return serialized;
}
/**
* Load or create an empty index for the given directory
*
* Loads index.json from the given log directy expecting it to contain
* data about previously indexed files saved with {@link
* module:lib/logging_utils.LogIndex#save}.
*
* @param {string} logDirectory - Path to directory to load from.
* @returns {module:lib/logging_utils.LogIndex} loaded or created log index.
*/
static async load(logDirectory) {
try {
let content = await fs.readFile(path.join(logDirectory, "index.json"));
return new LogIndex(logDirectory, JSON.parse(content));
} catch (err) {
if (err.code !== "ENOENT") {
logger.warn(`Failed to load ${path.join(logDirectory, "index.json")}: ${err}`);
}
return new LogIndex(logDirectory, {});
}
}
/**
* Save index
*
* Saves corrently held index into index.json in the log directory it
* was loaded/created from.
*/
async save() {
await libFileOps.safeOutputFile(
path.join(this.logDirectory, "index.json"),
JSON.stringify(this.serialize(), null, 4)
);
}
async indexFile(file) {
let filePath = path.join(this.logDirectory, file);
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
let fileStream = fs.createReadStream(filePath);
fileStream.pipe(lineStream);
let entry = {
levels: new Set(),
master: false,
slave_ids: new Set(),
instance_ids: new Set(),
};
for await (let line of lineStream) {
let info;
try {
info = JSON.parse(line);
} catch (err) {
entry.levels.add("info");
entry.master = true;
continue;
}
if (info.level) {
entry.levels.add(info.level);
}
if (info.slave_id === undefined) {
entry.master = true;
} else if (info.instance_id === undefined) {
entry.slave_ids.add(info.slave_id);
}
if (info.instance_id !== undefined) {
entry.instance_ids.add(info.instance_id);
}
}
return entry;
}
/**
* Build index for files currently present in the log directory
*/
async buildIndex() {
let today = new Date(Date.now() - 300e3).toISOString().slice(0, 10);
for (let file of await fs.readdir(this.logDirectory)) {
let match = logFileGlob.exec(file);
if (!match || match[1] >= today || this.index.has(file)) {
continue;
}
let entry = await this.indexFile(file);
this.index.set(file, entry);
}
}
/**
* Check if a file includes entries for the given filter
*
* @param {string} file -
* Name of file in the log directory to check for.
* @param {module:lib/logging_utils~LogFilter} filter -
* Filter to check index if file contains entries for.
* @returns {boolean}
* true if the file may contain entries included by the filter.
*/
filterIncludesFile(file, { max_level, all, master, instance_ids, slave_ids }) {
let entry = this.index.get(file);
if (!entry) {
return true;
}
const hasOwnProperty = Object.prototype.hasOwnProperty;
if (max_level && hasOwnProperty.call(levels, max_level)) {
let fileLevels = [...entry.levels].map(
fileLevel => (hasOwnProperty.call(levels, fileLevel) ? levels[fileLevel] : levels.verbose)
);
if (fileLevels.every(level => level > levels[max_level])) {
return false;
}
}
if (all) {
return true;
}
if (master && entry.master) {
return true;
}
if (slave_ids && slave_ids.some(id => entry.slave_ids.has(id))) {
return true;
}
if (instance_ids && instance_ids.some(id => entry.instance_ids.has(id))) {
return true;
}
return false;
}
}
/**
* Create log filter by level and source.
*
* @param {module:lib/logging_utils~LogFilter} filter -
* Filter to filter log entries by.
* @returns {function(object): boolean}
* filter returning true for log entries that match it.
* @static
*/
function logFilter({ all, master, slave_ids, instance_ids, max_level }) {
return info => {
// Note: reversed to filter out undefined levels
if (max_level && !(levels[info.level] <= levels[max_level])) {
return false;
}
if (all) {
return true;
}
if (master && info.slave_id === undefined) {
return true;
}
if (
slave_ids
&& info.slave_id !== undefined
&& info.instance_id === undefined
&& slave_ids.includes(info.slave_id)
) {
return true;
}
if (instance_ids && info.instance_id !== undefined && instance_ids.includes(info.instance_id)) {
return true;
}
return false;
};
}
/**
* Filter object for querying logs
* @typedef {Object} module:lib/logging_utils~QueryLogFilter
* @property {number} [limit=100] - Maximum number of entries to return.
* @property {string} [order="asc"] -
* Return entries in ascending ("asc") date order or desceding ("desc")
* date order.
* @property {string} [max_level] -
* Maximum log level to include. Higher levels are more verbose.
* @property {boolean} [all] -
* Include log entries from master, all slaves and all instances.
* @property {boolean} [master] -
* Include log entries from the master server.
* @property {Array<number>} [slave_ids] -
* Include log entries for the given slaves and instances of those
* slaves by id.
* @property {Array<number>} [instance_ids] -
* Include log entries for the given instances by id.
*/
/**
* Query log directory
*
* @param {string} logDirectory - path to directory with logs.
* @param {module:lib/logging_utils~QueryLogFilter} filter -
* Filter to limit logs by.
* @param {module:lib/logging_utils.LogIndex=} index -
* Index to speed up query with.
* @returns {Array<Object>} log entries matching the filter
*/
async function queryLog(logDirectory, filter, index) {
let files = (await fs.readdir(logDirectory)).filter(entry => logFileGlob.test(entry));
filter = {
limit: 100,
order: "asc",
...filter,
};
files.sort();
if (filter.order === "desc") {
files.reverse();
}
const includeEntry = logFilter(filter);
let log = [];
for (let file of files) {
if (index && !index.filterIncludesFile(file, filter)) {
continue;
}
let filePath = path.join(logDirectory, file);
let fileStream;
let lineStream;
if (filter.order === "asc") {
lineStream = new libStream.LineSplitter({ readableObjectMode: true });
fileStream = fs.createReadStream(filePath);
} else {
lineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
fileStream = await libStream.createReverseReadStream(filePath);
}
lineStream.on("data", line => {
let info;
try {
info = JSON.parse(line);
} catch (err) {
info = { level: "info", message: line.toString("utf8") };
}
if (includeEntry(info)) {
log.push(info);
if (log.length >= filter.limit) {
lineStream.destroy();
fileStream.destroy();
}
}
});
fileStream.pipe(lineStream);
try {
await finished(lineStream);
} catch (err) {
if (log.length < filter.limit || err.code !== "ERR_STREAM_PREMATURE_CLOSE") {
throw err;
}
}
if (log.length >= filter.limit) {
break;
}
}
return log;
}
async function migrateLogs(log, directory, pattern) {
const dateRegExp = /^\d{4}-\d{2}-\d{2}/;
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
let fileStream = fs.createReadStream(log);
fileStream.pipe(lineStream);
let currentDate = null;
let output = null;
async function switchOutput(newOutputPath) {
if (output) {
output.end();
await finished(output);
}
output = fs.createWriteStream(newOutputPath, { flags: "ax" });
try {
await events.once(output, "open");
// eslint-disable-next-line no-console
console.log(`writing ${newOutputPath}`);
} catch (err) {
if (err.code === "EEXIST") {
// eslint-disable-next-line no-console
console.warn(`Discarding logs for ${newDate} due to target logfile already existing`);
output = null;
} else {
throw err;
}
}
}
for await (let line of lineStream) {
let info = {};
try {
info = JSON.parse(line);
} catch (err) {
// Ignore
}
if (typeof info.timestamp === "string" && dateRegExp.test(info.timestamp)) {
let newDate = info.timestamp.slice(0, 10);
// Only update date forward in time.
if (newDate !== currentDate && (!currentDate || newDate > currentDate)) {
await switchOutput(path.join(directory, pattern.replace("%DATE%", newDate)));
currentDate = newDate;
}
}
if (output) {
if (!output.write(Buffer.concat([line, Buffer.from("\n")]))) {
await events.once(output, "drain");
}
}
}
}
module.exports = {
TerminalFormat,
LinkTransport,
LogIndex,
logFilter,
queryLog,
migrateLogs,
// for testing only
_formatServerOutput: formatServerOutput,
};
+10 -5
View File
@@ -1,27 +1,32 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.2",
"repository": "https://github.com/clusterio/factorioClusterio",
"version": "2.0.0-alpha.13",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"browser": "browser.js",
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"ajv": "^6.12.6",
"chalk": "^4.1.0",
"fast-deep-equal": "^3.1.3",
"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",
"set-blocking": "^2.0.0",
"triple-beam": "^1.3.0",
"winston": "^3.3.3",
"winston-transport": "^4.4.0",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
+431 -54
View File
@@ -4,6 +4,7 @@
*/
"use strict";
const helpers = require("./helpers");
/**
* Conceptual base for master and instance plugins.
@@ -38,7 +39,7 @@ class BaseInstancePlugin {
/**
* Instance the plugin started for
* @type {module:slave/slave~Instance}
* @type {module:slave/src/Instance}
*/
this.instance = instance;
@@ -48,9 +49,21 @@ class BaseInstancePlugin {
* With the exepction of accessing the slave's config you should
* avoid ineracting with the slave object directly.
*
* @type {module:slave/slave~Slave}
* @type {module:slave/src/Slave}
*/
this.slave = slave;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = instance.logger.child({ plugin: this.info.name });
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
@@ -65,7 +78,7 @@ class BaseInstancePlugin {
* changed.
*
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed on.
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
@@ -117,34 +130,34 @@ class BaseInstancePlugin {
* stderr. The output is a parsed form of the line and contains the
* following properties:
*
* @param {Object} output - parsed server output.
* @param {string} output.source -
* @param {Object} parsed - parsed server output.
* @param {string} parsed.source -
* Where the message came from, one of "stdout" and "stderr".
* @param {string} output.format -
* @param {string} parsed.format -
* Timestamp format, one of "date", "seconds" and "none".
* @param {string=} output.time -
* @param {string=} parsed.time -
* Timestamp of the message. Not present if format is "none".
* @param {string} output.type -
* @param {string} parsed.type -
* Type of message, one of "log", "action" and "generic".
* @param {string=} output.level -
* @param {string=} parsed.level -
* Log level for "log" type. i.e "Info" normally.
* @param {string=} output.file - File reported for "log" type.
* @param {string=} output.action -
* @param {string=} parsed.file - File reported for "log" type.
* @param {string=} parsed.action -
* Kind of action for "action" type. i.e "CHAT" for chat.
* @param {string} output.message - Main content of the line.
* @param {string} parsed.message - Main content of the line.
* @param {string} line - raw line of server output.
*/
async onOutput(output) { }
async onOutput(parsed, line) { }
/**
* Called when an event on the master connection happens
*
* The event param may be one of connect, drop and close and has the
* following meaning:
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the master has been established, or
* the existing one that have previously been dropped is re-established.
* Invoked when a new connection to the master has been established.
*
* ##### drop
*
@@ -156,6 +169,11 @@ class BaseInstancePlugin {
* memory on the slave and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when the connection that had previously dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the master has been closed. This
@@ -163,7 +181,7 @@ class BaseInstancePlugin {
* send any messages that goes to or via the master server after the
* connection has been closed and before a new one is established.
*
* @param {string} event - one of connect, drop, and close
* @param {string} event - one of connect, drop, resume and close
*/
onMasterConnectionEvent(event) { }
@@ -177,7 +195,7 @@ class BaseInstancePlugin {
* Plugins must stop sending messages to the master, or are forwarded
* via the master server after the prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareMasterDisconnect(connection) { }
@@ -188,10 +206,81 @@ class BaseInstancePlugin {
* 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.type - Either "join", "leave" or "import".
* @param {string} event.name - Name of the player that joined/left.
* @param {string=} event.reason -
* Only present for type "leave". Reason for player leaving the
* game, one of the possible reasons in defines.disconnect_reason
* or "server_quit" if the server exits while the player is online.
*/
async onPlayerEvent(event) { }
/**
* Send RCON message to instance
*
* Send a message or command to the server which can potentially be
* executed out of order in relation to other RCON commands.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendRcon(message, expectEmpty=false) {
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
}
/**
* Send serially ordered RCON message to instance
*
* Send a message or command to the server which will not be executed
* out of order in relation to other RCON commands sent with this
* method. The ordering applies per plugin, and two plugins sending
* commands with this method may execute out of order in relation to
* each other.
*
* This should not be called before onStart or after onStop. A simple
* way to achieve this is to check that the instance status is running
* before sending commands. Note that the instance status is set to
* running some time after `onStart` and some time before `onStop` is
* invoked.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean=} [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {Promise<string>} response from server.
*/
async sendOrderedRcon(message, expectEmpty=false) {
let promise = new Promise((resolve, reject) => {
this._pendingRconMessages.push({resolve, reject, message, expectEmpty});
});
if (!this._sendingRconMessages) {
this._sendPendingRconMessages();
}
return await promise;
}
async _sendPendingRconMessages() {
this._sendingRconMessages = true;
while (this._pendingRconMessages.length) {
let task = this._pendingRconMessages.shift();
try {
let result = await this.sendRcon(task.message, task.expectEmpty);
task.resolve(result);
} catch (err) {
task.reject(err);
}
}
this._sendingRconMessages = false;
}
}
/**
@@ -206,10 +295,24 @@ class BaseInstancePlugin {
* @static
*/
class BaseMasterPlugin {
constructor(info, master, metrics) {
constructor(info, master, metrics, logger) {
this.info = info;
/**
* Master server
* @type {module:master/src/Master}
*/
this.master = master;
this.metrics = metrics;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
}
/**
@@ -220,31 +323,69 @@ class BaseMasterPlugin {
/**
* Called when the status of an instance changes
*
* Invoked when the master server has received notice from a slave that
* the running status of an instance has changed. The possible statuses
* that can be notified about are:
* - `stopped`: Instance is no longer running.
* Invoked when the master server has changed the status of an instance
* or received notice from a slave that the status of an instance has
* changed. The possible statuses that can be notified about are:
* - `unassigned:`: Instance is no longer asssigned to a slave.
* - `unknown`: Slave assigned to instance is offline.
* - `stopped`: Instance is no longer running or was just assigned to a
* slave.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance startup completed and is now running.
* - `stopping`: Instance is in the processing stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting item
* icons and locale data.
* - `deleted`: Instance was deleted.
*
* On master startup all known instances gets the status `unknown` if
* they are assigned to a slave, when the slave then connects to the
* 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`.
* checking if prev equals `unknown`. If the slave disconnects the
* instances will again get the `unknown` status.
*
* When instances are created on the master they will notify of a status
* change with prev set to null. While the status of new instances is
* in most cases `unassigned` it's possible for the created instance to
* start with any state in some slave connection corner cases.
*
* Note that it's possible for status change notification to get lost in
* the case of network outages if reconnect fails to re-establish the
* session between the master server and the slave.
*
* @param {Object} instance - the instance that changed.
* @param {string} prev - the previous status of the instance.
* @param {?string} prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance, prev) { }
/**
* Called when the value of a master config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on the master.
*
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
async onMasterConfigFieldChanged(group, field, prev) { }
/**
* Called when the value of an instance config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on an instance.
*
* @param {Object} instance - The instance the config changed on.
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance, group, field, prev) { }
/**
* Called before collecting Prometheus metrics
*
@@ -270,13 +411,12 @@ class BaseMasterPlugin {
/**
* Called when an event on a slave connection happens
*
* The event param may be one of connect, drop and close and has the
* following meaning:
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the slave has been established, or
* an existing one that had previously been dropped is re-established.
* Invoked when a new connection to the slave has been established.
*
* ##### drop
*
@@ -288,16 +428,58 @@ class BaseMasterPlugin {
* memory on the master and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when a connection that had previously been dropped is
* re-established
*
* ##### close
*
* Invoked when the connection to the slave has been closed.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, and close
* @param {string} event - one of connect, drop, resume and close
*/
onSlaveConnectionEvent(connection, event) { }
/**
* Called when an avent on a control connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the control has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the master and a
* control. Plugins should respond to this event by throtteling
* messages it is sending to the given control connection to an absolute
* minimum.
*
* Messages sent over a dropped control connection will get queued up in
* memory on the master and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when a connection that had previously been dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the control has been closed.
*
* @param {module:master/src/ControlConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, resume, and close.
*/
onControlConnectionEvent(connection, event) { }
/**
* Called when a slave is preparing to disconnect from the master
*
@@ -307,7 +489,7 @@ class BaseMasterPlugin {
* Plugins must stop sending messages to the slave in question after the
* prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareSlaveDisconnect(connection) { }
@@ -320,8 +502,12 @@ class BaseMasterPlugin {
*
* @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.type - Either "join", "leave" or "import".
* @param {string} event.name - Name of the player that joined/left.
* @param {string=} event.reason -
* Only present for type "leave". Reason for player leaving the
* game, one of the possible reasons in defines.disconnect_reason
* or "server_quit" if the server exits while the player is online.
*/
async onPlayerEvent(instance, event) { }
@@ -336,7 +522,7 @@ class BaseMasterPlugin {
* @param {Object} data - Data ta pass with the event.
*/
broadcastEventToSlaves(event, data={}) {
for (let slaveConnection of this.master.slaveConnections.values()) {
for (let slaveConnection of this.master.wsServer.slaveConnections.values()) {
if (
!slaveConnection.connector.closing
&& (!event.plugin || slaveConnection.plugins.has(event.plugin))
@@ -358,11 +544,20 @@ class BaseMasterPlugin {
* @static
*/
class BaseControlPlugin {
constructor(info) {
constructor(info, logger) {
/**
* The plugin's own info module
*/
this.info = info;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
}
/**
@@ -383,32 +578,205 @@ class BaseControlPlugin {
async addCommands(rootCommand) { }
}
/**
* Plugin supplied login form
* @typedef {Object} module:lib/plugin~LoginForm
* @property {string} name -
* Internal name of the login form, this should start with the
* plugin name followed by a dot.
* @property {string} title -
* Name displayed above this form in the login window.
* @property {React.Component} Component -
* React component that's rendered for this login form. This is
* supplied the setToken function via its props which should be
* called when an authentication token is aquired via this
* form.
*/
/**
* Plugin supplied pages
* @typedef {Object} module:lib/plugin~Page
* @property {string} path - URL path to this page.
* @property {string=} sidebarPath -
* If present and this path matches one of the pages in the sidebar it
* will cause that sidebar entry to be highlighted as active.
* @property {string=} sidebarGroup -
* If present group this entry under a group of the given name in the
* sidebar.
* @property {string=} sidebarName -
* If present creates an entry in the sidebar for this page with the
* given text.
* @property {ReactNode} content -
* A react node which is rendered when this page is navigated to.
* Should render a PageLayout.
*/
/**
* Base class for web interface plugins
*
* @static
*/
class BaseWebPlugin {
constructor(container, packageData, info, logger) {
/**
* Webpack container for this plugin
*/
this.container = container;
/**
* Contents of the plugin's package.json file
* @type {Object}
*/
this.package = packageData;
/**
* The plugin's own info module
*/
this.info = info;
/**
* Control link to the master server, not available until the
* connect event in onMasterConnectionEvent is signaled.
* @type {?module:lib/link.Link}
*/
this.control = null;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
/**
* List of login forms provided by this plugin
*
* @type {Array<module:lib/plugin~LoginForm>}
*/
this.loginForms = [];
/**
* List of pages provided by this plugin
*
* @type {Array<module:lib/plugin~Page>}
*/
this.pages = [];
/**
* Extra react component to add to core components
*
* Interface to augment core components of the web UI. Setting a
* component as one of the supported properties of this object will
* cause the web UI to render it when displaying that component,
* usually at the end. Each component will receive a `plugin` param
* which is the instance of the web plugin that contained the
* component extra.
*
* @type {object}
* @property {React.ComponentType} MasterPage -
* Placed at the end of the master page.
* @property {React.ComponentType} SlavesPage -
* Placed at the end of the slaves list page.
* @property {React.ComponentType} SlaveViewPage -
* Placed at the end of each slave page. Takes a `slave` param
* which is the slave the page is displayed for.
* @property {React.ComponentType} InstancesPage -
* Placed at the end of the instance list page.
* @property {React.ComponentType} InstanceViewPage -
* Placed at the end of each instance page. Takes an `instance`
* param which is the instance the page is displayed for.
* @property {React.ComponentType} UsersPage -
* Placed at the end of the users list page.
* @property {React.ComponentType} UserViewPage -
* Placed at the end of each user page. Takes a `user` param
* which is the user object the page is displayed for.
* @property {React.ComponentType} RolesPage -
* Placed at the end of the roles list page.
* @property {React.ComponentType} RoleViewPage -
* Placed at the end of each role page. Takes a `role` param
* which is the role object the page is displayed for.
*/
this.componentExtra = {};
}
/**
* Called immediately after the class is instantiated.
*/
async init() { }
/**
* Called when an event on the master connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the master has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the control link
* and the master. Plugins should respond to this event by throtteling
* messages it is sending to the master to an absolute minimum.
*
* Messages sent over a dropped master connection will get queued up in
* memory in the browser and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when the connection that had previously dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the master has been closed. This
* typically means the master server has shut down. Plugins should not
* send any messages that goes to or via the master server after the
* connection has been closed and before a new one is established.
*
* @param {string} event - one of connect, drop, resume and close
*/
onMasterConnectionEvent(event) { }
}
/**
* Attach plugin messages
*
* Attaches all messages defined in the `.message` property of the
* `pluginInfo` passed with handlers taken from `pluin`.
* Attaches all messages defined in the `.info.message` property of the
* `plugin` passed with handlers taken from the `pluin`.
*
* @param {module:lib/link.Link} link - Link to attach handlers to.
* @param {object} pluginInfo - Plugin info object.
* @param {module:lib/plugin.BasePlugin} plugin -
* The instance of the plugin to use handlers from.
* The instance of the plugin attach messages and handlers from.
*/
function attachPluginMessages(link, pluginInfo, plugin) {
let messageDefinitions = pluginInfo.messages || [];
function attachPluginMessages(link, plugin) {
let messageDefinitions = plugin.info.messages || [];
for (let [name, messageFormat] of Object.entries(messageDefinitions)) {
if (messageFormat.plugin !== pluginInfo.name) {
throw new Error(`Type of ${name} message must start with "${pluginInfo.name}:"`);
if (messageFormat.plugin !== plugin.info.name) {
throw new Error(`Type of ${name} message must start with "${plugin.info.name}:"`);
}
let handler = name + messageFormat.handlerSuffix;
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
try {
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async function(message, format) {
return await plugin[handler](message, format, this);
});
} else {
messageFormat.attach(link, async (message, format) => await plugin[handler](message, format, link));
}
} catch (err) {
if (err.code === "MISSING_LINK_HANDLER") {
err.plugin = plugin.info.name;
err.handler = handler;
}
throw err;
}
}
}
@@ -426,12 +794,20 @@ async function invokeHook(plugins, hook, ...args) {
let results = [];
for (let [name, plugin] of plugins) {
try {
let result = await plugin[hook](...args);
if (result !== undefined) {
// Use an object to detect if the hook failed on timeout or tried to return a value looking like an error
const timeout = {};
let result = await helpers.timeout(
plugin[hook](...args),
15000,
timeout
);
if (result === timeout) {
throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`);
} else if (result !== undefined) {
results.push(result);
}
} catch (err) {
console.error(`Ignoring error from plugin ${name} in ${hook}:`, err);
plugin.logger.error(`Ignoring error from plugin ${name} in ${hook}:\n${err.stack}`);
}
}
return results;
@@ -442,6 +818,7 @@ module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
BaseWebPlugin,
attachPluginMessages,
invokeHook,
+47 -1
View File
@@ -1,10 +1,11 @@
/* eslint-disable node/global-require */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
*/
"use strict";
const libErrors = require("@clusterio/lib/errors");
const libErrors = require("./errors");
const path = require("path");
@@ -47,6 +48,51 @@ async function loadPluginInfos(pluginList) {
return plugins;
}
/**
* Load master plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadMasterPluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.masterEntrypoint));
return entrypoint.MasterPlugin;
}
/**
* Load instance plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadInstancePluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint));
return entrypoint.InstancePlugin;
}
/**
* Load control plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadControlPluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
return entrypoint.ControlPlugin;
}
module.exports = {
loadPluginInfos,
loadMasterPluginClass,
loadInstancePluginClass,
loadControlPluginClass,
};
+1166 -53
View File
File diff suppressed because it is too large. Load diff
+31 -3
View File
@@ -3,7 +3,8 @@
* @module lib/schema
*/
"use strict";
const ajv = new (require("ajv"))({
const Ajv = require("ajv");
const ajv = new Ajv({
verbose: true,
format: "full",
extendRefs: "fail",
@@ -97,10 +98,35 @@ const serverHandshake = ajv.compile({
"type": { const: "ready" },
"data": {
additionalProperties: false,
required: ["session_token", "heartbeat_interval"],
required: ["session_token", "session_timeout", "heartbeat_interval"],
properties: {
"session_token": { type: "string" },
"session_timeout": { type: "number" },
"heartbeat_interval": { type: "number" },
"account": {
type: "object",
additionalProperties: false,
required: ["name", "roles"],
properties: {
"name": { type: "string" },
"roles": {
type: "array",
items: {
additionalProperties: false,
type: "object",
required: ["name", "id", "permissions"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"permissions": {
type: "array",
items: { type: "string" },
},
},
},
},
},
},
},
},
},
@@ -110,9 +136,10 @@ const serverHandshake = ajv.compile({
"type": { const: "continue" },
"data": {
additionalProperties: false,
required: ["last_seq", "heartbeat_interval"],
required: ["last_seq", "session_timeout", "heartbeat_interval"],
properties: {
"last_seq": { type: ["integer", "null"] },
"session_timeout": { type: "number" },
"heartbeat_interval": { type: "number" },
},
},
@@ -151,6 +178,7 @@ const clientHandshake = ajv.compile({
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"public_address": { type: "string" },
"plugins": {
type: "object",
additionalProperties: { type: "string" },
+31 -17
View File
@@ -3,12 +3,19 @@
* @module lib/shared_commands
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
const libFileOps = require("./file_ops");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
function print(...content) {
// eslint-disable-next-line no-console
console.log(...content);
}
/**
* Yargs plugin command
*
@@ -48,37 +55,37 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
let pluginInfo;
try {
// eslint-disable-next-line node/global-require
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
console.error(`Unable to import plugin info from ${args.path}`);
console.error(err);
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
process.exitCode = 1;
return;
}
if (pluginList.has(pluginInfo.name)) {
console.error(`Plugin with the same ${pluginInfo.name} already exists`);
logger.error(`Plugin with the same ${pluginInfo.name} already exists`);
process.exitCode = 1;
return;
}
pluginList.set(pluginInfo.name, pluginPath);
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
console.log(`Added ${pluginInfo.name}`);
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
if (!pluginList.delete(args.name)) {
console.error(`Plugin with name ${args.name} does not exist`);
logger.error(`Plugin with name ${args.name} does not exist`);
process.exitCode = 1;
return;
}
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
console.log(`Removed ${args.name}`);
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Removed ${args.name}`);
} else if (command === "list") {
for (let [pluginName, pluginPath] of pluginList) {
console.log(`${pluginName} - ${pluginPath}`);
print(`${pluginName} - ${pluginPath}`);
}
}
}
@@ -94,7 +101,11 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
*/
function configCommand(yargs) {
yargs
.command("set <field> [value]", "Set config field")
.command("set <field> [value]", "Set config field", yargs => {
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
})
.command("show <field>", "Show value of the given config field")
.command("list", "List all configuration fields and their values")
.demandCommand(1, "You need to specify a command to run")
@@ -119,32 +130,35 @@ async function handleConfigCommand(args, instance, configPath) {
for (let GroupClass of instance.constructor.groups.values()) {
for (let def of GroupClass.definitions.values()) {
let value = instance.get(def.fullName);
console.log(`${def.fullName} ${JSON.stringify(value)}`);
print(`${def.fullName} ${JSON.stringify(value)}`);
}
}
} else if (command === "show") {
try {
console.log(instance.get(args.field));
print(instance.get(args.field));
} catch (err) {
if (err instanceof libConfig.InvalidField) {
console.error(err.message);
logger.error(err.message);
} else {
throw err;
}
}
} else if (command === "set") {
if (args.value === undefined) {
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = null;
}
try {
instance.set(args.field, args.value);
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
console.error(err.message);
logger.error(err.message);
} else {
throw err;
}
+168
View File
@@ -0,0 +1,168 @@
"use strict";
const assert = require("assert");
const fs = require("fs-extra");
const stream = require("stream");
/**
* Transform splitting a stream into lines
*
* Splits up a stream of chunks by newlines and passes on the lines one by
* one removing the line feed and optional carriage return from the lines.
*
* @example
* let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
* lineStream.on("data", line => { console.log(line.toString("utf8")); });
* let fileStream = fs.createReadStream(path);
* fileStream.pipe(lineStream);
*
* @memberof module:lib/stream
*/
class LineSplitter extends stream.Transform {
constructor(options) {
super(options);
this._partial = null;
}
_transform(chunk, encoding, callback) {
if (this._partial) {
chunk = Buffer.concat([this._partial, chunk]);
this._partial = null;
}
while (chunk.length) {
let end = chunk.indexOf("\n");
if (end === -1) {
this._partial = chunk;
break;
}
let next = end + 1;
// Eat carriage return as well if present
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
end -= 1;
}
let line = chunk.slice(0, end);
chunk = chunk.slice(next);
this.push(line);
}
callback();
}
_flush(callback) {
if (this._partial) {
this.push(this._partial);
this._partial = null;
}
callback();
}
}
/**
* Transform splitting a reverse stream into lines
*
* Splits up a reverse stream of chunks created by {@link
* module:lib/stream.createReverseReadStream} by newlines and passes on the
* lines one by one in reverse order removing the line feed and optional
* carriage return from the lines.
*
* @example
* let revLineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
* revLineStream.on("data", line => { console.log(line.toString("utf8")); });
* let revFileStream = await libStream.createReverseReadStream(path);
* revFileStream.pipe(revLineStream);
*
* @memberof module:lib/stream
*/
class ReverseLineSplitter extends stream.Transform {
constructor(options) {
super(options);
this._partial = null;
}
_transform(chunk, encoding, callback) {
if (this._partial) {
chunk = Buffer.concat([chunk, this._partial]);
this._partial = null;
}
while (chunk.length) {
let next = chunk.lastIndexOf("\n", -2);
if (next === -1) {
this._partial = chunk;
break;
}
let end = chunk.length - 1;
if (chunk[end] === "\n".charCodeAt(0)) {
end -= 1;
// Eat carriage return as well if present
if (end - next > 0 && chunk[end] === "\r".charCodeAt(0)) {
end -= 1;
}
}
let line = chunk.slice(next + 1, end + 1);
chunk = chunk.slice(0, next + 1);
this.push(line);
}
callback();
}
_flush(callback) {
if (this._partial) {
let end = this._partial.length - 1;
if (this._partial[end] === "\n".charCodeAt(0)) {
end -= 1;
// Eat carriage return as well if present
if (end > 0 && this._partial[end] === "\r".charCodeAt(0)) {
end -= 1;
}
}
this.push(this._partial.slice(0, end + 1));
this._partial = null;
}
callback();
}
}
/**
* Create a file read stream running in reverse
*
* Like fs.createReadStream but provides data chunks starting from the end
* of the file and progressing towards the start of the file. The chunks
* are not reversed, thus the file content can be recreated by concatenating
* the chunks together in the reverse order they are read.
*
* @param {string} path -
* Path to file to open. May be anything accepted by fs.open.
* @param {object} options -
* Options to pass to fs.createReadStream. Values passed for `fd` and
* `fs` will be ignored.
* @memberof module:lib/stream
*/
async function createReverseReadStream(path, options) {
const fileFd = await fs.open(path, "r");
const fileSize = (await fs.fstat(fileFd)).size;
let filePosition = fileSize;
const reverseFs = {
read(fd, buffer, offset, length, position, callback) {
assert(fd === fileFd);
assert(position === undefined);
length = Math.min(length, filePosition);
filePosition -= length;
return fs.read(fd, buffer, offset, length, filePosition, callback);
},
open() { assert(false); },
close(fd, callback) { return fs.close(fd, callback); },
};
return fs.createReadStream("", { ...options, fd: fileFd, fs: reverseFs });
}
module.exports = {
LineSplitter,
ReverseLineSplitter,
createReverseReadStream,
};
+154 -9
View File
@@ -9,6 +9,11 @@
"use strict";
const jwt = require("jsonwebtoken");
const libErrors = require("./errors");
const PlayerStats = require("./PlayerStats");
/**
* Represents a permission that can be granted
* @static
@@ -102,7 +107,7 @@ class Role {
function ensureDefaultAdminRole(roles) {
let admin = roles.get(0);
if (!admin) {
admin = new Role({ id: 0, name: "Admin", description: "Cluster wide administrator." });
admin = new Role({ id: 0, name: "Cluster Admin", description: "Cluster wide administrator." });
roles.set(0, admin);
}
admin.permissions.add("core.admin");
@@ -192,6 +197,28 @@ class User {
}
}
}
/**
* Per instance statistics for the player this user account is tied to.
* @type {Map<number, module:lib/PlayerStats>}
*/
this.instanceStats = new Map(
(serializedUser.instance_stats ? serializedUser.instance_stats : []).map(
([id, stats]) => [id, new PlayerStats(stats)]
)
);
/**
* Combined statistics for the player this user account is tied to.
* @type {module:lib/PlayerStats}
*/
this.playerStats = this._calculatePlayerStats();
/**
* True if this user object has been removed from the cluster.
* @type {boolean}
*/
this.isDeleted = false;
}
serialize() {
@@ -223,6 +250,10 @@ class User {
serialized.ban_reason = this.banReason;
}
if (this.instanceStats.size) {
serialized.instance_stats = [...this.instanceStats].map(([id, stats]) => [id, stats.toJSON()]);
}
return serialized;
}
@@ -233,7 +264,7 @@ class User {
* @returns {string} JWT access token for the user.
*/
createToken(secret) {
return jwt.sign({ aud: "user", user: this.name }, secret);
return jwt.sign({ aud: "user", user: this.name }, Buffer.from(secret, "base64"));
}
/**
@@ -254,7 +285,8 @@ class User {
* "Permission denied" error is thrown.
*
* @param {string} permission - The permission to check for.
* @throws {Error} if the user does noh have the given permission.
* @throws {Error} If the given permission does not exist.
* @throws {module:lib/errors.PermissionError} if the user does noh have the given permission.
*/
checkPermission(permission) {
if (!permissions.has(permission)) {
@@ -267,7 +299,7 @@ class User {
}
}
throw new Error("Permission denied");
throw new libErrors.PermissionError("Permission denied");
}
notifyJoin(instance_id) {
@@ -281,6 +313,26 @@ class User {
User.onlineUsers.delete(this);
}
}
recalculatePlayerStats() {
this.playerStats = this._calculatePlayerStats();
}
_calculatePlayerStats() {
let playerStats = new PlayerStats();
for (let instanceStats of this.instanceStats.values()) {
if (!playerStats.lastJoinAt || instanceStats.lastJoinAt > playerStats.lastJoinAt) {
playerStats.lastJoinAt = instanceStats.lastJoinAt;
}
if (!playerStats.lastLeaveAt || instanceStats.lastLeaveAt > playerStats.lastLeaveAt) {
playerStats.lastLeaveAt = instanceStats.lastLeaveAt;
playerStats.lastLeaveReason = instanceStats.lastLeaveReason;
}
playerStats.joinCount += instanceStats.joinCount;
playerStats.onlineTimeMs += instanceStats.onlineTimeMs;
}
return playerStats;
}
}
/**
* Set of users currently online in the cluster.
@@ -302,12 +354,28 @@ definePermission({
grantByDefault: true,
});
definePermission({
name: "core.master.get_config",
title: "Get master config",
description: "Get the config of master.",
});
definePermission({
name: "core.master.update_config",
title: "Modify master config",
description: "Modify the master config or entries of the master config.",
});
definePermission({
name: "core.slave.list",
title: "List slaves",
description: "Get the full list of slaves in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.slave.subscribe",
title: "Subscribe to slave updates",
description: "Subscribe to be notified on updates on the details of slaves.",
grantByDefault: true,
});
definePermission({
name: "core.slave.generate_token",
title: "Generate slave token",
@@ -319,12 +387,24 @@ definePermission({
description: "Create slave configs via the master server.",
});
definePermission({
name: "core.instance.get",
title: "Get instance",
description: "Get the details of an instance in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.list",
title: "List instances",
description: "Get the full list of instances in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.subscribe",
title: "Subscribe to instance updates",
description: "Subscribe to be notified on updates on the details and status of instances.",
grantByDefault: true,
});
definePermission({
name: "core.instance.create",
title: "Create instance",
@@ -346,20 +426,60 @@ definePermission({
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.",
name: "core.instance.save.list",
title: "List saves",
description: "List the saves currently on the instance.",
});
definePermission({
name: "core.instance.create_save",
title: "Create new instance save",
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
name: "core.instance.save.create",
title: "Create new save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.rename",
title: "Rename save",
description: "Rename savegames on instances.",
});
definePermission({
name: "core.instance.save.copy",
title: "Copy save",
description: "Create copies of savegames on instances.",
});
definePermission({
name: "core.instance.save.delete",
title: "Delete save",
description: "Delete savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.transfer",
title: "Transfer save",
description: "Transfer savegames between instances.",
});
definePermission({
name: "core.instance.save.download",
title: "Download save",
description: "Download savegames from 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.extract_players",
title: "Extract player stats from running save",
description: "Run extraction to create a user for each player in the save and set the online time from the save.",
});
definePermission({
name: "core.instance.start",
title: "Start instance",
@@ -375,6 +495,11 @@ definePermission({
title: "Stop instance",
description: "Stop instances.",
});
definePermission({
name: "core.instance.kill",
title: "Kill instance",
description: "Terminate running instances without saving or cleanup.",
});
definePermission({
name: "core.instance.delete",
title: "Delete instance",
@@ -415,11 +540,21 @@ definePermission({
description: "Permanently delete roles.",
});
definePermission({
name: "core.user.get",
title: "Get user",
description: "Get the details of a user in the cluster.",
});
definePermission({
name: "core.user.list",
title: "List users",
description: "Get the full list of users in the cluster.",
});
definePermission({
name: "core.user.subscribe",
title: "Subscribe to user updates",
description: "Subscribe to be notified on updates on the details and status of users.",
});
definePermission({
name: "core.user.create",
title: "Create users",
@@ -450,6 +585,16 @@ definePermission({
title: "Delete user",
description: "Delete users and all data stored for them.",
});
definePermission({
name: "core.log.follow",
title: "Follow cluster log",
description: "Receive new entries in the cluster log. Required to see instance console.",
});
definePermission({
name: "core.log.query",
title: "Query cluster log",
description: "Query past entries in the cluster log. Required to see past entries in instance console.",
});
definePermission({
name: "core.debug.dump_ws",
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+31 -52
View File
@@ -1,68 +1,55 @@
Clusterio Master Server
=======================
# Clusterio Master Server
Communication hub for Clusterio clusters. The master server forwards
data between Clusterio slaves connected to it and allows the cluster to
be remotely managed through WebSocket connections to it either by using
the included web interface or the Clusterio ctl command line utility.
Communication hub for Clusterio clusters.
The master server forwards data between Clusterio slaves connected to it and allows the cluster to be remotely managed through WebSocket connections to it either by using the included web interface or the Clusterio ctl command line utility.
Usage
-----
# Usage
npx clusteriomaster <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the master server. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the master server.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
master server. Defaults to `config-master.json` and will be created
if it does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for the master server.
Defaults to `config-master.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the master server. The
available plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
Configure plugins available to be loaded by the master server.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusteriomaster plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -72,20 +59,17 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the master server configuration offline. This should only be
used when the master server is stopped, otherwise the config read might
be out of date and config changes will be overwritten when the master
server shuts down.
Manage the master server configuration offline.
This should only be used when the master server is stopped, otherwise the config read might be out of date and config changes will be overwritten when the master server shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration entries.
See docs/configuration.md in the main repositiory for the available configuration entries.
#### `config show <config-entry>`
@@ -105,22 +89,19 @@ Commands for setting up initial admin acount and access to the cluster.
#### `bootstrap create-admin <name>`
Creates a new user account with the given name if it does not already
exist in the cluster and grant it full cluster admin permission. You
should be using your own Factorio multiplayer username here.
Creates a new user account with the given name if it does not already exist in the cluster and grant it full cluster admin permission.
You should be using your own Factorio multiplayer username here.
#### `bootstrap generate-user-token <name>`
Generate an access token for logging in and possibly managing the
cluster from the given user account. This can be used both in the web
interface and as the `master_token` to connect with clusterioctl.
Generate an access token for logging in and possibly managing the cluster from the given user account.
This can be used both in the web interface and as the `master_token` to connect with clusterioctl.
#### `bootstrap create-ctl-config <name>`
Creates a clusterioctl config for the given user with url and token set
up for connecting to the cluster.
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
### `run`
@@ -130,6 +111,4 @@ Runs the master server.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+263 -1677
View File
File diff suppressed because it is too large. Load diff
+11 -18
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.13",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
"prepare": "webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -16,37 +16,30 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.2",
"body-parser": "^1.18.2",
"@clusterio/lib": "^2.0.0-alpha.13",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"express": "^4.16.2",
"express-fileupload": "1.2.0",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"node-forge": "^0.10.0",
"set-blocking": "^2.0.0",
"webpack-cli": "^4.9.2",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.5",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"css-loader": "^4.3.0",
"fast-deep-equal": "^3.1.3",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"@clusterio/web_ui": "^2.0.0-alpha.13",
"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": "^5.73.0",
"webpack-dev-middleware": "^5.3.1",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+89
View File
@@ -0,0 +1,89 @@
"use strict";
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
/**
* Base class for master server connections
*
* @extends module:lib/link.Link
* @alias module:master/src/BaseConnection
*/
class BaseConnection extends libLink.Link {
constructor(target, connector, master) {
/** @member {module:master/src/WsServerConnector} module:master/src/BaseConnection#connector */
super("master", target, connector);
this._master = master;
this._disconnecting = false;
libLink.attachAllMessages(this);
for (let masterPlugin of this._master.plugins.values()) {
libPlugin.attachPluginMessages(this, masterPlugin);
}
}
async forwardRequestToInstance(message, request) {
return await this._master.forwardRequestToInstance(request, message.data);
}
async forwardEventToInstance(message, event) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) { return; }
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) { return; }
let connection = this._master.wsServer.slaveConnections.get(slaveId);
if (!connection || connection.closing) { return; }
if (event.plugin && !connection.plugins.has(event.plugin)) { return; }
event.send(connection, message.data);
}
async broadcastEventToSlaves(message, event) {
for (let slaveConnection of this._master.wsServer.slaveConnections.values()) {
// Do not broadcast back to the source
if (slaveConnection === this) { continue; }
if (slaveConnection.connector.closing) { continue; }
if (event.plugin && !slaveConnection.plugins.has(event.plugin)) { continue; }
event.send(slaveConnection, message.data);
}
}
async broadcastEventToInstance(message, event) {
await this.broadcastEventToSlaves(message, event);
}
async prepareDisconnectRequestHandler(message, request) {
await libPlugin.invokeHook(this._master.plugins, "onPrepareSlaveDisconnect", this);
this._disconnecting = true;
this.connector.setClosing();
return await super.prepareDisconnectRequestHandler(message, request);
}
async disconnect(code, reason) {
this.connector.setClosing();
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
}
}
await this.connector.close(code, reason);
}
/**
* True if the link is connected, not in the dropped state and not in
* the process of disconnecting.
* @type {boolean}
*/
get connected() {
return !this._disconnecting && this.connector.connected;
}
}
module.exports = BaseConnection;
+737
View File
@@ -0,0 +1,737 @@
"use strict";
const jwt = require("jsonwebtoken");
const events = require("events");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libPlugin = require("@clusterio/lib/plugin");
const libPrometheus = require("@clusterio/lib/prometheus");
const libUsers = require("@clusterio/lib/users");
const BaseConnection = require("./BaseConnection");
const routes = require("./routes");
const lastQueryLogTime = new libPrometheus.Gauge(
"clusterio_master_last_query_log_duration_seconds",
"Time in seconds the last log query took to execute."
);
/**
* Represents the connection to a control link
*
* @extends module:master/src/BaseConnection
* @alias module:master/src/ControlConnection
*/
class ControlConnection extends BaseConnection {
constructor(registerData, connector, master, user) {
super("control", connector, master);
this._agent = registerData.agent;
this._version = registerData.version;
/**
* The user making this connection.
* @type {module:lib/users.User}
*/
this.user = user;
this.slaveSubscriptions = {
all: false,
slave_ids: [],
};
this.instanceSubscriptions = {
all: false,
instance_ids: [],
};
this.saveListSubscriptions = {
all: false,
instance_ids: [],
};
this.userSubscriptions = {
all: false,
names: [],
};
this.logTransport = null;
this.logSubscriptions = {
all: false,
master: false,
slave_ids: [],
instance_ids: [],
};
this.ws_dumper = null;
this.connector.on("connect", () => {
this.connector._socket.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
this.connector.on("close", () => {
if (this.logTransport) {
this.logTransport = null;
logger.remove(this.logTransport);
}
if (this.ws_dumper) {
this._master.debugEvents.off("message", this.ws_dumper);
}
});
for (let event of ["connect", "drop", "resume", "close"]) {
this.connector.on(event, () => {
for (let masterPlugin of this._master.plugins.values()) {
masterPlugin.onControlConnectionEvent(this, event);
}
});
}
}
async getMasterConfigRequestHandler() {
return { serialized_config: this._master.config.serialize("control") };
}
async setMasterConfigFieldRequestHandler(message) {
this._master.config.set(message.data.field, message.data.value, "control");
}
async setMasterConfigPropRequestHandler(message) {
let { field, prop, value } = message.data;
this._master.config.setProp(field, prop, value, "control");
}
async listSlavesRequestHandler(message) {
let list = [];
for (let slave of this._master.slaves.values()) {
list.push({
agent: slave.agent,
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this._master.wsServer.slaveConnections.has(slave.id),
});
}
return { list };
}
async setSlaveSubscriptionsRequestHandler(message) {
this.slaveSubscriptions = message.data;
}
slaveUpdated(slave, update) {
if (
this.slaveSubscriptions.all
|| this.slaveSubscriptions.slave_ids.includes(slave.id)
) {
libLink.messages.slaveUpdate.send(this, update);
}
}
generateSlaveToken(slaveId) {
return jwt.sign(
{ aud: "slave", slave: slaveId },
Buffer.from(this._master.config.get("master.auth_secret"), "base64")
);
}
async generateSlaveTokenRequestHandler(message) {
let slaveId = message.data.slave_id;
if (slaveId === null) {
slaveId = Math.random() * 2**31 | 0;
}
return { token: this.generateSlaveToken(slaveId) };
}
async createSlaveConfigRequestHandler(message) {
let slaveConfig = new libConfig.SlaveConfig("control");
await slaveConfig.init();
slaveConfig.set("slave.master_url", this._master.getMasterUrl());
if (message.data.id !== null) {
slaveConfig.set("slave.id", message.data.id);
}
if (message.data.name !== null) {
slaveConfig.set("slave.name", message.data.name);
}
if (message.data.generate_token) {
this.user.checkPermission("core.slave.generate_token");
slaveConfig.set("slave.master_token", this.generateSlaveToken(slaveConfig.get("slave.id")));
}
return { serialized_config: slaveConfig.serialize() };
}
async getInstanceRequestHandler(message) {
let id = message.data.id;
let instance = this._master.getRequestInstance(id);
return {
id,
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
};
}
async listInstancesRequestHandler(message) {
let list = [];
for (let instance of this._master.instances.values()) {
list.push({
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
return { list };
}
async setInstanceSubscriptionsRequestHandler(message) {
this.instanceSubscriptions = message.data;
}
instanceUpdated(instance) {
if (
this.instanceSubscriptions.all
|| this.instanceSubscriptions.instance_ids.includes(instance.config.get("instance.id"))
) {
libLink.messages.instanceUpdate.send(this, {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
}
// XXX should probably add a hook for slave reuqests?
async createInstanceRequestHandler(message) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(message.data.serialized_config);
let instanceId = instanceConfig.get("instance.id");
if (this._master.instances.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${this._master.config.get("master.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${this._master.config.get("master.name")}`,
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"username": "",
"token": "",
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
"max_upload_slots": 5,
"ignore_player_limit_for_returning_players": false,
"allow_commands": "admins-only",
"autosave_interval": 10,
"autosave_slots": 5,
"afk_autokick_interval": 0,
"auto_pause": false,
"only_admins_can_pause_the_game": true,
"autosave_only_on_server": true,
...instanceConfig.get("factorio.settings"),
};
instanceConfig.set("factorio.settings", settings);
let instance = { config: instanceConfig, status: "unassigned" };
this._master.instances.set(instanceId, instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, null);
this._master.addInstanceHooks(instance);
}
async deleteInstanceRequestHandler(message, request) {
let instance = this._master.getRequestInstance(message.data.instance_id);
if (instance.config.get("instance.assigned_slave") !== null) {
await this.forwardRequestToInstance(message, request);
}
this._master.instances.delete(message.data.instance_id);
let prev = instance.status;
instance.status = "deleted";
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
}
async getInstanceConfigRequestHandler(message) {
let instance = this._master.getRequestInstance(message.data.instance_id);
return {
serialized_config: instance.config.serialize("control"),
};
}
async updateInstanceConfig(instance) {
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId) {
let connection = this._master.wsServer.slaveConnections.get(slaveId);
if (connection) {
await libLink.messages.assignInstance.send(connection, {
instance_id: instance.config.get("instance.id"),
serialized_config: instance.config.serialize("slave"),
});
}
}
}
async setInstanceConfigFieldRequestHandler(message) {
let instance = this._master.getRequestInstance(message.data.instance_id);
if (message.data.field === "instance.assigned_slave") {
throw new libErrors.RequestError("instance.assigned_slave must be set through the assign-slave interface");
}
if (message.data.field === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new libErrors.RequestError("Setting instance.id is not supported");
}
instance.config.set(message.data.field, message.data.value, "control");
await this.updateInstanceConfig(instance);
}
async setInstanceConfigPropRequestHandler(message) {
let instance = this._master.getRequestInstance(message.data.instance_id);
let { field, prop, value } = message.data;
instance.config.setProp(field, prop, value, "control");
await this.updateInstanceConfig(instance);
}
async assignInstanceCommandRequestHandler(message, request) {
let { slave_id, instance_id } = message.data;
let instance = this._master.getRequestInstance(instance_id);
// Check if target slave is connected
let newSlaveConnection;
if (slave_id !== null) {
newSlaveConnection = this._master.wsServer.slaveConnections.get(slave_id);
if (!newSlaveConnection) {
// The case of the slave not getting the assign instance message
// still have to be handled, so it's not a requirement that the
// target slave be connected to the master while doing the
// assignment, but it is IMHO a better user experience if this
// is the case.
throw new libErrors.RequestError("Target slave is not connected to the master server");
}
}
// Unassign from currently assigned slave if it is connected.
let currentAssignedSlave = instance.config.get("instance.assigned_slave");
if (currentAssignedSlave !== null && slave_id !== currentAssignedSlave) {
let oldSlaveConnection = this._master.wsServer.slaveConnections.get(currentAssignedSlave);
if (oldSlaveConnection && !oldSlaveConnection.connector.closing) {
await libLink.messages.unassignInstance.send(oldSlaveConnection, { instance_id });
}
}
// Assign to target
instance.config.set("instance.assigned_slave", slave_id);
if (slave_id !== null) {
await libLink.messages.assignInstance.send(newSlaveConnection, {
instance_id,
serialized_config: instance.config.serialize("slave"),
});
}
}
async setSaveListSubscriptionsRequestHandler(message) {
this.saveListSubscriptions = message.data;
}
saveListUpdate(data) {
if (
this.saveListSubscriptions.all
|| this.saveListSubscriptions.instance_ids.includes(data.instance_id)
) {
libLink.messages.saveListUpdate.send(this, data);
}
}
async downloadSaveRequestHandler(message) {
let { instance_id, save } = message.data;
let stream = await routes.createProxyStream(this._master.app);
stream.filename = save;
let ready = new Promise((resolve, reject) => {
stream.events.on("source", resolve);
stream.events.on("timeout", () => reject(
new libErrors.RequestError("Timed out establishing stream from slave")
));
});
ready.catch(() => {});
await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save,
});
await ready;
return { stream_id: stream.id };
}
async transferSaveRequestHandler(message, request) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
if (copy) {
this.user.checkPermission("core.instance.save.copy");
} else if (source_save !== target_save) {
this.user.checkPermission("core.instance.save.rename");
}
if (instance_id === target_instance_id) {
throw new libErrors.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._master.getRequestInstance(instance_id);
let targetInstance = this._master.getRequestInstance(target_instance_id);
let sourceSlaveId = sourceInstance.config.get("instance.assigned_slave");
let targetSlaveId = targetInstance.config.get("instance.assigned_slave");
if (sourceSlaveId === null) {
throw new libErrors.RequestError("Source instance is not assigned a slave");
}
if (targetSlaveId === null) {
throw new libErrors.RequestError("Target instance is not assigned a slave");
}
// Let slave handle request if source and target is on the same slave.
if (sourceSlaveId === targetSlaveId) {
return await this.forwardRequestToInstance(message, request);
}
// Check connectivity
let sourceSlaveConnection = this._master.wsServer.slaveConnections.get(sourceSlaveId);
if (!sourceSlaveConnection || sourceSlaveConnection.closing) {
throw new libErrors.RequestError("Source slave is not connected to the master server");
}
let targetSlaveConnection = this._master.wsServer.slaveConnections.get(targetSlaveId);
if (!targetSlaveConnection || targetSlaveConnection.closing) {
throw new libErrors.RequestError("Target slave is not connected to the master server");
}
// Create stream to proxy from target to source
let stream = await routes.createProxyStream(this._master.app);
stream.events.on("timeout", () => {
if (stream.source) {
stream.source.destroy();
}
stream.events.emit("error", new libErrors.RequestError("Timed out establishing transfer stream"));
});
// Ignore errors if not listening for them to avoid crash.
stream.events.on("error", () => { /* ignore */ });
// Establish push from source slave to stream, this is done first to
// ensure the file size is known prior to the target slave pull.
await Promise.all([
this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save: source_save,
}),
events.once(stream.events, "source"),
]);
// Establish pull from target slave to stream and wait for completion.
let result = await this._master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: target_instance_id,
stream_id: stream.id,
filename: target_save,
});
// Delete source save if this is not a copy
if (!copy) {
await this._master.forwardRequestToInstance(libLink.messages.deleteSave, {
instance_id,
save: source_save,
});
}
return { save: result.save };
}
async setLogSubscriptionsRequestHandler(message) {
this.logSubscriptions = message.data;
this.updateLogSubscriptions();
}
updateLogSubscriptions() {
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
if (all || master || slave_ids.length || instance_ids.length) {
if (!this.logTransport) {
this.logTransport = new libLoggingUtils.LinkTransport({ link: this });
this._master.clusterLogger.add(this.logTransport);
}
this.logTransport.filter = libLoggingUtils.logFilter(this.logSubscriptions);
} else if (this.logTransport) {
this._master.clusterLogger.remove(this.logTransport);
this.logTransport = null;
}
}
async queryLogRequestHandler(message) {
let setDuration = lastQueryLogTime.startTimer();
let { all, master, slave_ids, instance_ids } = message.data;
let log;
if (!all && master && !slave_ids.length && !instance_ids.length) {
log = await this._master.queryMasterLog(message.data);
} else {
log = await this._master.queryClusterLog(message.data);
}
setDuration();
return { log };
}
async listPermissionsRequestHandler(message) {
let list = [];
for (let permission of libUsers.permissions.values()) {
list.push({
name: permission.name,
title: permission.title,
description: permission.description,
});
}
return { list };
}
async listRolesRequestHandler(message) {
let list = [];
for (let role of this._master.userManager.roles.values()) {
list.push({
id: role.id,
name: role.name,
description: role.description,
permissions: [...role.permissions],
});
}
return { list };
}
async createRoleRequestHandler(message) {
let lastId = Math.max.apply(null, [...this._master.userManager.roles.keys()]);
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._master.userManager.roles.set(id, new libUsers.Role({ id, ...message.data }));
return { id };
}
async updateRoleRequestHandler(message) {
let { id, name, description, permissions } = message.data;
let role = this._master.userManager.roles.get(id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${id} does not exist`);
}
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._master.rolePermissionsUpdated(role);
}
async grantDefaultRolePermissionsRequestHandler(message) {
let role = this._master.userManager.roles.get(message.data.id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${message.data.id} does not exist`);
}
role.grantDefaultPermissions();
this._master.rolePermissionsUpdated(role);
}
async deleteRoleRequestHandler(message) {
let id = message.data.id;
let role = this._master.userManager.roles.get(id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${id} does not exist`);
}
this._master.userManager.roles.delete(id);
for (let user of this._master.userManager.users.values()) {
user.roles.delete(role);
this._master.userPermissionsUpdated(user);
}
}
async getUserRequestHandler(message) {
let name = message.data.name;
let user = this._master.userManager.users.get(name);
if (!user) {
throw new libErrors.RequestError(`User ${name} does not exist`);
}
return {
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
ban_reason: user.banReason,
instances: [...user.instances],
player_stats: user.playerStats,
instance_stats: [...user.instanceStats],
};
}
async listUsersRequestHandler(message) {
let list = [];
for (let user of this._master.userManager.users.values()) {
list.push({
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
instances: [...user.instances],
player_stats: user.playerStats,
});
}
return { list };
}
async setUserSubscriptionsRequestHandler(message) {
this.userSubscriptions = message.data;
}
userUpdated(user) {
if (
this.userSubscriptions.all
|| this.userSubscriptions.names.includes(user.name)
) {
libLink.messages.userUpdate.send(this, {
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
ban_reason: user.banReason,
instances: [...user.instances],
player_stats: user.playerStats,
instance_stats: [...user.instanceStats],
is_deleted: user.isDeleted,
});
}
}
async createUserRequestHandler(message) {
let user = this._master.userManager.createUser(message.data.name);
this._master.userUpdated(user);
}
async updateUserRolesRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
let resolvedRoles = new Set();
for (let roleId of message.data.roles) {
let role = this._master.userManager.roles.get(roleId);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${roleId} does not exist`);
}
resolvedRoles.add(role);
}
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(user);
this._master.userUpdated(user);
}
async setUserAdminRequestHandler(message) {
let { name, create, admin } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, admin }}, libLink.messages.adminlistUpdate);
}
async setUserBannedRequestHandler(message) {
let { name, create, banned, reason } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isBanned = banned;
user.banReason = reason;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, banned, reason }}, libLink.messages.banlistUpdate);
}
async setUserWhitelistedRequestHandler(message) {
let { name, create, whitelisted } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this._master.userUpdated(user);
this.broadcastEventToSlaves({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate);
}
async deleteUserRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
user.isDeleted = true;
this._master.userManager.users.delete(message.data.name);
this._master.userUpdated(user);
if (user.is_admin) {
this.broadcastEventToSlaves({ data: { name, admin: false }}, libLink.messages.adminlistUpdate);
}
if (user.is_whitelisted) {
this.broadcastEventToSlaves({ data: { name, whitelisted: false }}, libLink.messages.whitelistUpdate);
}
if (user.is_banned) {
this.broadcastEventToSlaves({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate);
}
}
async debugDumpWsRequestHandler(message) {
this.ws_dumper = data => {
if (this.connector.connected) {
libLink.messages.debugWsMessage.send(this, data);
}
};
this.connector._socket.clusterio_ignore_dump = true;
this._master.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
+108
View File
@@ -0,0 +1,108 @@
"use strict";
const http = require("http");
const https = require("https");
/**
* Gracefully close a Node.js HTTP(S) server
*
* Helper class to properly close down a Node.js HTTP(S) server. This is
* necessary due to connections being able to linger around forever with the
* keep-alive logic in HTTP.
*
* @example
* // during server initialization
* const server = http.createServer();
* const serverCloser = new HttpCloser(server);
*
* // in the shutdown logic
* await serverCloser.close();
*
* @alias module:master/src/HttpCloser
*/
class HttpCloser {
/**
* Server to attach this closer to.
* @param {https.Server | http.Server} server - HTTP(S) to attach to
*/
constructor(server) {
this._server = server;
this._sockets = new Set();
this._responses = new Set();
if (this._server instanceof http.Server) {
this._server.on("connection", this._addSocket.bind(this));
} else if (this._server instanceof https.Server) {
this._server.on("secureConnection", this._addSocket.bind(this));
} else {
throw Error("server must be an instance of either http.Server or https.Server.");
}
this._server.on("request", this._addRequest.bind(this));
}
_addSocket(socket) {
this._sockets.add(socket);
socket.once("close", () => {
this._sockets.delete(socket);
});
}
_addRequest(request, response) {
this._responses.add(response);
response.once("close", () => this._responses.delete(response));
response.once("finish", () => this._responses.delete(response));
}
/**
* Gracefully close down the attached HTTP(S) server.
*
* Closes open connections that do not have a request in flight, signals
* connections to pending requests should close, and initiates the
* close operation on the attached server.
*
* If there's requests in progress that still haven't been completed
* after the timeout value passed they will be aborted.
*
* @param {number} timeout -
* Time in ms to wait for connections to close.
*/
async close(timeout = 5000) {
let activeSockets = new Set();
for (let response of this._responses) {
if (!response.headersSent) {
response.setHeader("Connection", "close");
} else {
let socket = response.socket;
response.once("finish", () => {
socket.end();
});
}
activeSockets.add(response.socket);
}
for (let socket of this._sockets) {
if (!activeSockets.has(socket)) {
socket.end();
}
}
return new Promise((resolve, reject) => {
let timer = setTimeout(() => {
for (let socket of this._sockets) {
// Ideally a RST would be sent here to indicate the the
// transfer failed, but we can't reliably send that from
// Node.js, see nodejs/node#27428.
socket.destroy();
}
}, timeout);
this._server.close(err => {
clearTimeout(timer);
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
};
module.exports = HttpCloser;
+706
View File
@@ -0,0 +1,706 @@
"use strict";
const compression = require("compression");
const events = require("events");
const express = require("express");
const fs = require("fs-extra");
const http = require("http");
const https = require("https");
const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libLink = require("@clusterio/lib/link");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const { logger } = require("@clusterio/lib/logging");
const HttpCloser = require("./HttpCloser");
const metrics = require("./metrics");
const routes = require("./routes");
const UserManager = require("./UserManager");
const WsServer = require("./WsServer");
const hitCounter = new libPrometheus.Counter(
"clusterio_master_http_hits_total",
"How many HTTP requests in total have been received"
);
/**
* Manages all master related operations
* @alias module:master/src/Master
*/
class Master {
constructor(clusterLogger, pluginInfos, configPath, config) {
this.clusterLogger = clusterLogger;
/**
* Mapping of plugin name to info objects for known plugins
* @type {Map<string, Object>}
*/
this.pluginInfos = pluginInfos;
this.configPath = configPath;
/**
* Master config.
* @type {module:lib/config.MasterConfig}
*/
this.config = config;
this.app = express();
this.app.locals.master = this;
this.app.locals.streams = new Map();
/**
* Mapping of slave id to slave info
* @type {Map<number, Object>}
*/
this.slaves = null;
/**
* Mapping of instance id to instance info
* @type {Map<number, Object>}
*/
this.instances = null;
/**
* User and roles manager for the cluster
* @type {module:master/src/UserManager}
*/
this.userManager = null;
this.httpServer = null;
this.httpServerCloser = null;
this.httpsServer = null;
this.httpsServerCloser = null;
/**
* Mapping of plugin name to loaded plugin
* @type {Map<string, module:lib/plugin.BaseMasterPlugin>}
*/
this.plugins = new Map();
/**
* WebSocket server
* @type {module:master/src/WsServer}
*/
this.wsServer = new WsServer(this);
this.debugEvents = new events.EventEmitter();
this._events = new events.EventEmitter();
// Possible states are new, starting, running, stopping, stopped
this._state = "new";
this._shouldStop = false;
this.devMiddleware = null;
}
async start(args) {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
}
this._state = "starting";
try {
await this._startInternal(args);
} catch (err) {
await this._stopInternal();
this._state = "stopped";
this._events.emit("stop");
throw err;
}
if (this._shouldStop) {
this.stop();
}
}
async _startInternal(args) {
this.logDirectory = args.logDirectory;
this.clusterLogIndex = await libLoggingUtils.LogIndex.load(path.join(this.logDirectory, "cluster"));
this.clusterLogBuildInterval = setInterval(() => {
this.clusterLogIndex.buildIndex().catch(
err => logger.error(`Error building cluster log index:\n${err.stack}`)
);
}, 600e3);
// Start webpack development server if enabled
if (args.dev || args.devPlugin) {
logger.warn("Webpack development mode enabled");
/* eslint-disable node/global-require, node/no-unpublished-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfigs = [];
if (args.dev) {
webpackConfigs.push(require("../webpack.config")({}));
}
if (args.devPlugin) {
let devPlugins = new Map();
for (let name of args.devPlugin) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new libErrors.StartupError(`No plugin named ${name}`);
}
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
}
this.app.locals.devPlugins = devPlugins;
}
/* eslint-enable node/global-require, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
this.app.use(this.devMiddleware);
}
let databaseDirectory = this.config.get("master.database_directory");
await fs.ensureDir(databaseDirectory);
this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json"));
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
this.exportManifest = await Master.loadJsonObject(path.join(databaseDirectory, "export_manifest.json"));
this.config.on("fieldChanged", (group, field, prev) => {
libPlugin.invokeHook(this.plugins, "onMasterConfigFieldChanged", group, field, prev);
});
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
// Make sure we're actually going to listen on a port
let httpPort = this.config.get("master.http_port");
let httpsPort = this.config.get("master.https_port");
let bindAddress = this.config.get("master.bind_address") || "";
if (!httpPort && !httpsPort) {
logger.fatal("Error: at least one of http_port and https_port must be configured");
process.exitCode = 1;
return;
}
let tls_cert = this.config.get("master.tls_certificate");
let tls_key = this.config.get("master.tls_private_key");
if (httpsPort && (!tls_cert || !tls_key)) {
throw new libErrors.StartupError(
"tls_certificate and tls_private_key must be configure in order to use https_port"
);
}
Master.addAppRoutes(this.app, this.pluginInfos);
if (!args.dev) {
let manifest = await Master.loadJsonObject(path.join(__dirname, "..", "dist", "web", "manifest.json"));
if (!manifest["main.js"]) {
logger.error("Missing main.js entry in dist/web/manifest.json");
}
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
}
// Load plugins
await this.loadPlugins();
this.wsServer = new WsServer(this);
// Only start listening for connections after all plugins have loaded
if (httpPort) {
this.httpServer = http.createServer(this.app);
this.httpServerCloser = new HttpCloser(this.httpServer);
await this.listen(this.httpServer, httpPort, bindAddress);
logger.info(`Listening for HTTP on port ${this.httpServer.address().port}`);
}
if (httpsPort) {
let certificate, privateKey;
try {
certificate = await fs.readFile(tls_cert);
privateKey = await fs.readFile(tls_key);
} catch (err) {
throw new libErrors.StartupError(
`Error loading ssl certificate: ${err.message}`
);
}
this.httpsServer = https.createServer({
key: privateKey,
cert: certificate,
}, this.app);
this.httpsServerCloser = new HttpCloser(this.httpsServer);
await this.listen(this.httpsServer, httpsPort, bindAddress);
logger.info(`Listening for HTTPS on port ${this.httpsServer.address().port}`);
}
logger.info("Started master");
this._state = "running";
}
/**
* Stops the master server.
*
* Save data and bring down the active connections. This is the reverse
* of start and is valid at any point in time.
*/
async stop() {
if (this._state === "starting") {
this._shouldStop = true;
await events.once(this._events, "stop");
return;
} else if (this._state === "running") {
await this._stopInternal();
}
this._state = "stopped";
this._events.emit("stop");
}
async _stopInternal() {
// This function should never throw.
this._state = "stopping";
logger.info("Stopping master");
clearInterval(this.clusterLogBuildInterval);
if (this.clusterLogIndex) {
await this.clusterLogIndex.save();
}
logger.info("Saving config");
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
}
let databaseDirectory = this.config.get("master.database_directory");
if (this.slaves) {
await Master.saveSlaves(path.join(databaseDirectory, "slaves.json"), this.slaves);
}
if (this.instances) {
await Master.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances);
}
if (this.userManager) {
await this.userManager.save(path.join(databaseDirectory, "users.json"));
}
await libPlugin.invokeHook(this.plugins, "onShutdown");
if (this.wsServer) {
await this.wsServer.stop();
}
let stopTasks = [];
logger.info("Stopping HTTP(S) server");
if (this.httpServer && this.httpServer.listening) { stopTasks.push(this.httpServerCloser.close()); }
if (this.httpsServer && this.httpsServer.listening) { stopTasks.push(this.httpsServerCloser.close()); }
await Promise.all(stopTasks);
logger.info("Goodbye");
}
static async loadSlaves(filePath) {
let serialized;
try {
serialized = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
// TODO: Remove after release.
if (serialized.length && !(serialized[0] instanceof Array)) {
return new Map(); // Discard old format.
}
return new Map(serialized);
}
static async saveSlaves(filePath, slaves) {
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
}
static async loadInstances(filePath) {
logger.info(`Loading ${filePath}`);
let instances = new Map();
try {
let serialized = JSON.parse(await fs.readFile(filePath));
for (let serializedConfig of serialized) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(serializedConfig);
let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown";
let instance = { config: instanceConfig, status };
instances.set(instanceConfig.get("instance.id"), instance);
}
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
return instances;
}
static async saveInstances(filePath, instances) {
let serialized = [];
for (let instance of instances.values()) {
serialized.push(instance.config.serialize());
}
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static async loadJsonObject(filePath, throwOnMissing = false) {
let manifest = {};
try {
manifest = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (!throwOnMissing && err.code !== "ENOENT") {
throw err;
}
}
return manifest;
}
async updateExportManifest(newManifest) {
let databaseDirectory = this.config.get("master.database_directory");
await libFileOps.safeOutputFile(
path.join(databaseDirectory, "export_manifest.json"), JSON.stringify(newManifest, null, 4)
);
this.exportManifest = newManifest;
}
/**
* Query master log
*
* @param {module:lib/logging_utils~QueryLogFilter} filter -
* Filter to limit entries with. Note that only the master log can
* be queried from this function.
* @returns {Array<Object>} log entries matching the filter
*/
async queryMasterLog(filter) {
return await libLoggingUtils.queryLog(
path.join(this.logDirectory, "master"), filter,
);
}
/**
* Query cluster log
*
* @param {module:lib/logging_utils~QueryLogFilter} filter -
* Filter to limit entries with.
* @returns {Array<Object>} log entries matching the filter
*/
async queryClusterLog(filter) {
return await libLoggingUtils.queryLog(
path.join(this.logDirectory, "cluster"), filter, this.clusterLogIndex,
);
}
static addAppRoutes(app, pluginInfos) {
app.use((req, res, next) => {
hitCounter.inc();
next();
});
app.use(compression());
// Set folder to serve static content from (the website)
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
app.use("/static", express.static(path.join(__dirname, "..", "dist", "web", "static"), staticOptions));
app.use("/static", express.static("static", staticOptions)); // Used for data export files
// Add API routes
routes.addRouteHandlers(app);
// Add routes for the web interface
for (let route of routes.webRoutes) {
app.get(route, Master.serveWeb(route));
}
for (let pluginInfo of pluginInfos) {
for (let route of pluginInfo.routes || []) {
app.get(route, Master.serveWeb(route));
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web", "static");
app.use("/static", express.static(webPath, staticOptions));
}
}
slaveUpdated(slave) {
let update = {
agent: slave.agent,
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this.wsServer.slaveConnections.has(slave.id),
};
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.slaveUpdated(slave, update);
}
}
/**
* Get instance by ID for a request
*
* @param {number} instanceId - ID of instance to get.
* @returns {object} instance
* @throws {module:lib/errors.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId) {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
addInstanceHooks(instance) {
instance.config.on("fieldChanged", (group, field, prev) => {
if (group.name === "instance" && field === "name") {
this.instanceUpdated(instance);
}
libPlugin.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, group, field, prev);
});
this.instanceUpdated(instance);
}
instanceUpdated(instance) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.instanceUpdated(instance);
}
}
saveListUpdate(data) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.saveListUpdate(data);
}
}
userUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.userUpdated(user);
}
}
/**
* Notify connected control clients under the given user that the
* permissions for this user may have changed.
* @param {module:lib/users.User} user - User permisions updated for.
*/
userPermissionsUpdated(user) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user === user) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
/**
* Notify connected control clients with the given role that the
* permissions may have changed.
* @param {module:lib/users.Role} role - Role permisions updated for.
*/
rolePermissionsUpdated(role) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.user.roles.has(role)) {
libLink.messages.accountUpdate.send(controlConnection, {
"roles": [...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
});
}
}
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
try {
let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
pluginInfo.manifest = await Master.loadJsonObject(require.resolve(manifestPath), true);
} catch (err) {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
continue;
}
let MasterPluginClass = libPlugin.BaseMasterPlugin;
try {
if (pluginInfo.masterEntrypoint) {
MasterPluginClass = await libPluginLoader.loadMasterPluginClass(pluginInfo);
}
let masterPlugin = new MasterPluginClass(pluginInfo, this, metrics, logger);
await masterPlugin.init();
this.plugins.set(pluginInfo.name, masterPlugin);
} catch (err) {
throw new libErrors.PluginError(pluginInfo.name, err);
}
logger.info(`Loaded plugin ${pluginInfo.name}`);
}
}
/**
* Calls listen on server capturing any errors that occurs
* binding to the port. Also adds handler for WebSocket
* upgrade event.
*
* @param {module:net.Server} server - Server to start the listening on.
* @param {*} args - Arguments to the .listen() call on the server.
* @returns {Promise} promise that resolves the server is listening.
*/
listen(server, ...args) {
return new Promise((resolve, reject) => {
server.on("upgrade", (req, socket, head) => {
logger.verbose("handling WebSocket upgrade");
this.wsServer.handleUpgrade(req, socket, head);
});
function wrapError(err) {
reject(new libErrors.StartupError(
`Server listening failed: ${err.message}`
));
}
server.once("error", wrapError);
server.listen(...args, () => {
server.off("error", wrapError);
resolve();
});
});
}
/**
* Returns the URL needed to connect to the master server.
*
* @returns {string} master URL.
*/
getMasterUrl() {
return Master.calculateMasterUrl(this.config);
}
static calculateMasterUrl(config) {
let url = config.get("master.external_address");
if (!url) {
if (config.get("master.https_port")) {
url = `https://localhost:${config.get("master.https_port")}/`;
} else {
url = `http://localhost:${config.get("master.http_port")}/`;
}
}
return url;
}
/**
* Servers the web interface with the root path set apropriately
*
* @param {string} route - route the interface is served under.
* @returns {function} Experess.js route handler.
*/
static serveWeb(route) {
// The depth is is the number of slashes in the route minus one, but due
// to lenient matching on trailing slashes in the route we need to
// compensate if the request path contains a slash but not the route,
// and vice versa.
let routeDepth = (route.match(/\//g) || []).length - 1 - (route.slice(-1) === "/");
return function(req, res, next) {
let depth = routeDepth + (req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
let staticRoot = webRoot;
let mainBundle;
if (res.app.locals.mainBundle) {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
mainBundle = stats.toJson().assetsByChunkName["main"];
}
fs.readFile(path.join(__dirname, "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content
.replace(/__CLUSTER_NAME__/g, res.app.locals.master.config.get("master.name"))
.replace(/__WEB_ROOT__/g, webRoot)
.replace(/__STATIC_ROOT__/g, staticRoot)
.replace(/__MAIN_BUNDLE__/g, mainBundle)
);
}).catch(err => {
next(err);
});
};
}
/**
* Forward the given request to the slave contaning the instance
*
* Finds the slave which the instance with the given instance ID is
* currently assigned to and forwards the request to that instance.
* The request data must have an instance_id parameter.
*
* @param {module:lib/link.Request} request - The request to send.
* @param {object} data - Data to pass with the request.
* @param {number} data.instance_id -
* ID of instance to send this request towards
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.getRequestInstance(data.instance_id);
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
}
let connection = this.wsServer.slaveConnections.get(slaveId);
if (!connection) {
throw new libErrors.RequestError("Slave containing instance is not connected");
}
if (request.plugin && !connection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`);
}
return await request.send(connection, data);
}
}
module.exports = Master;
+200
View File
@@ -0,0 +1,200 @@
"use strict";
const libConfig = require("@clusterio/lib/config");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const PlayerStats = require("@clusterio/lib/PlayerStats");
const BaseConnection = require("./BaseConnection");
/**
* Represents the connection to a slave
*
* @extends module:master/src/BaseConnection
* @alias module:master/src/SlaveConnection
*/
class SlaveConnection extends BaseConnection {
constructor(registerData, connector, master) {
super("slave", connector, master);
this._agent = registerData.agent;
this._id = registerData.id;
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._master.slaves.set(this._id, {
agent: this._agent,
id: this._id,
name: this._name,
version: this._version,
public_address: registerData.public_address,
plugins: registerData.plugins,
});
for (let event of ["connect", "drop", "resume", "close"]) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._master.plugins.values()) {
plugin.onSlaveConnectionEvent(this, event);
}
});
}
this.connector.on("close", () => {
// Update status to unknown for instances on this slave.
for (let instance of this._master.instances.values()) {
if (instance.config.get("instance.assigned_slave") !== this._id) {
continue;
}
let prev = instance.status;
instance.status = "unknown";
this._master.instanceUpdated(instance);
libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
}
});
}
/**
* ID of the slave this connection is connected to
*
* @returns {number} slave ID.
*/
get id() {
return this._id;
}
async instanceStatusChangedEventHandler(message, event) {
let instance = this._master.instances.get(message.data.instance_id);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the slave it originated from if it was
// reassigned or deleted while the connection to the slave it was
// originally on was down at the time.
if (!instance || instance.config.get("instance.assigned_slave") !== this._id) {
logger.warn(`Got bogus update for instance id ${message.data.instance_id}`);
return;
}
// We may receive status changed where the status hasn't changed
// from our perspective if the connection was down at the time it
// changed. Slaves also send status updates on assignInstance which
// for hacky reason is also used to push config changes and
// restablish status after a connection loss.
if (instance.status === message.data.status) {
return;
}
let prev = instance.status;
instance.status = message.data.status;
instance.game_port = message.data.game_port;
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
}
async updateInstancesRequestHandler(message) {
// Push updated instance configs
for (let instance of this._master.instances.values()) {
if (instance.config.get("instance.assigned_slave") === this._id) {
await libLink.messages.assignInstance.send(this, {
instance_id: instance.config.get("instance.id"),
serialized_config: instance.config.serialize("slave"),
});
}
}
// Assign instances the slave has but master does not
for (let instanceData of message.data.instances) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(instanceData.serialized_config, "slave");
let masterInstance = this._master.instances.get(instanceConfig.get("instance.id"));
if (masterInstance) {
// Check if this instance is assigned somewhere else.
if (masterInstance.config.get("instance.assigned_slave") !== this._id) {
await libLink.messages.unassignInstance.send(this, {
instance_id: masterInstance.config.get("instance.id"),
});
continue;
}
// Already have this instance, update state instead
if (masterInstance.status !== instanceData.status) {
let prev = masterInstance.status;
masterInstance.status = instanceData.status;
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", masterInstance, prev);
}
continue;
}
instanceConfig.set("instance.assigned_slave", this._id);
let newInstance = { config: instanceConfig, status: instanceData.status };
this._master.instances.set(instanceConfig.get("instance.id"), newInstance);
this._master.addInstanceHooks(newInstance);
await libLink.messages.assignInstance.send(this, {
instance_id: instanceConfig.get("instance.id"),
serialized_config: instanceConfig.serialize("slave"),
});
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", newInstance, null);
}
// Push lists to make sure they are in sync.
let adminlist = [];
let banlist = [];
let whitelist = [];
for (let user of this._master.userManager.users.values()) {
if (user.isAdmin) {
adminlist.push(user.name);
}
if (user.isBanned) {
banlist.push([user.name, user.banReason]);
}
if (user.isWhitelisted) {
whitelist.push(user.name);
}
}
libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist });
}
async saveListUpdateEventHandler(message) {
this._master.saveListUpdate(message.data);
}
async logMessageEventHandler(message) {
this._master.clusterLogger.log({
...message.data.info,
slave_id: this._id,
slave_name: this._name,
});
}
async playerEventEventHandler(message) {
let { instance_id, name, type, stats } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
user = this._master.userManager.createUser(name);
}
if (type === "join") {
user.notifyJoin(instance_id);
} else if (type === "leave") {
user.notifyLeave(instance_id);
}
user.instanceStats.set(instance_id, new PlayerStats(stats));
user.recalculatePlayerStats();
this._master.userUpdated(user);
delete message.data.stats;
let instance = this._master.instances.get(instance_id);
await libPlugin.invokeHook(this._master.plugins, "onPlayerEvent", instance, message.data);
}
}
module.exports = SlaveConnection;
+89
View File
@@ -0,0 +1,89 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("@clusterio/lib/file_ops");
const libUsers = require("@clusterio/lib/users");
/**
* Manages users and roles
* @alias module:master/src/UserManager
*/
class UserManager {
constructor(config) {
this._config = config;
this.roles = null;
this.users = null;
}
async load(filePath) {
let loadedRoles = new Map();
let loadedUsers = new Map();
try {
let content = JSON.parse(await fs.readFile(filePath));
for (let serializedRole of content.roles) {
let role = new libUsers.Role(serializedRole);
loadedRoles.set(role.id, role);
}
for (let serializedUser of content.users) {
let user = new libUsers.User(serializedUser, loadedRoles);
loadedUsers.set(user.name, user);
}
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
// Create default roles if loading failed
libUsers.ensureDefaultAdminRole(loadedRoles);
libUsers.ensureDefaultPlayerRole(loadedRoles);
}
this.roles = loadedRoles;
this.users = loadedUsers;
}
async save(filePath) {
if (!this.roles || !this.users) {
return;
}
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.serialize());
}
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.serialize());
}
let serialized = {
users: serializedUsers,
roles: serializedRoles,
};
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
/**
* Create a new user
*
* Creates a new user and add it to the user database.
*
* @param {string} name - Name of the user to create.
* @returns {module:lib/users.User} newly created user.
*/
createUser(name) {
if (this.users.has(name)) {
throw new Error(`User '${name}' already exists`);
}
let defaultRoleId = this._config.get("master.default_role_id");
let user = new libUsers.User({ name, roles: [defaultRoleId] }, this.roles);
this.users.set(name, user);
return user;
}
}
module.exports = UserManager;
+313
View File
@@ -0,0 +1,313 @@
"use strict";
const jwt = require("jsonwebtoken");
const WebSocket = require("ws");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const libPrometheus = require("@clusterio/lib/prometheus");
const libSchema = require("@clusterio/lib/schema");
const ControlConnection = require("./ControlConnection");
const packageVersion = require("../package").version;
const SlaveConnection = require("./SlaveConnection");
const WsServerConnector = require("./WsServerConnector");
const wsMessageCounter = new libPrometheus.Counter(
"clusterio_master_websocket_message_total",
"How many messages have been received over WebSocket on the master server",
{ labels: ["direction"] }
);
const wsConnectionsCounter = new libPrometheus.Counter(
"clusterio_master_websocket_connections_total",
"How many WebSocket connections have been initiated on the master server"
);
const wsRejectedConnectionsCounter = new libPrometheus.Counter(
"clusterio_master_websocket_rejected_connections_total",
"How many WebSocket connections have been rejected during the handshake on the master server"
);
/**
* WebSocket server
* @alias module:master/src/WsServer
*/
class WsServer {
constructor(master) {
this.master = master;
this.stopAcceptingNewSessions = false;
/** @type {Array<module:master/src/ControlConnection>} */
this.controlConnections = [];
/** @type {Map<number, module:master/src/SlaveConnection>} */
this.slaveConnections = new Map();
/** @type {Map<number, module:master/src/WsServerConnector>} */
this.activeConnectors = new Map();
/** @type {Set<module:ws>} */
this.pendingSockets = new Set();
// Unique string for the session token audience
this.sessionAud = `session-${Date.now()}`;
this.nextSessionId = 1;
/** @type {module:ws.Server} */
this.wss = new WebSocket.Server({
noServer: true,
path: "/api/socket",
});
this.wss.on("connection", (socket, req) => this.handleConnection(socket, req));
}
async stop() {
this.stopAcceptingNewSessions = true;
let disconnectTasks = [];
for (let controlConnection of this.controlConnections) {
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
}
for (let slaveConnection of this.slaveConnections.values()) {
disconnectTasks.push(slaveConnection.disconnect(1001, "Server Quit"));
}
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
for (let task of disconnectTasks) {
try {
await task;
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`);
}
}
}
for (let socket of this.pendingSockets) {
socket.close(1001, "Server Quit");
}
}
handleUpgrade(req, socket, head) {
// For reasons that defy common sense, the connection event has
// to be emitted explictly when using noServer.
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss.emit("connection", ws, req);
});
}
handleConnection(socket, req) {
logger.verbose(`WsServer | new connection from ${req.socket.remoteAddress}`);
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message) => {
wsMessageCounter.labels("in").inc();
if (!socket.clusterio_ignore_dump) {
this.master.debugEvents.emit("message", { direction: "in", content: message });
}
});
let originalSend = socket.send;
socket.send = (...args) => {
wsMessageCounter.labels("out").inc();
if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) {
this.master.debugEvents.emit("message", { direction: "out", content: args[0] });
}
return originalSend.call(socket, ...args);
};
// Start connection handshake.
let loadedPlugins = {};
for (let [name, plugin] of this.master.plugins) {
loadedPlugins[name] = plugin.info.version;
}
socket.send(JSON.stringify({ seq: null, type: "hello", data: {
version: packageVersion,
plugins: loadedPlugins,
}}));
this.attachHandler(socket, req);
}
attachHandler(socket, req) {
this.pendingSockets.add(socket);
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.terminate();
this.pendingSockets.delete(socket);
}, 30*1000);
socket.once("message", (message) => {
clearTimeout(timeoutId);
this.pendingSockets.delete(socket);
this.handleHandshake(
message, socket, req
).catch(err => {
logger.error(`
+------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
});
});
}
async handleHandshake(message, socket, req) {
try {
message = JSON.parse(message);
} catch (err) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid JSON`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Invalid JSON");
return;
}
if (!libSchema.clientHandshake(message)) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Bad handshake");
return;
}
let { type, data } = message;
if (type === "resume") {
let connector;
try {
let payload = jwt.verify(
data.session_token,
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
connector = this.activeConnectors.get(payload.sid);
if (!connector) {
throw new Error();
}
} catch (err) {
socket.send(JSON.stringify({ seq: null, type: "invalidate", data: {}}));
this.attachHandler(socket, req);
return;
}
connector.continue(socket, data.last_seq);
return;
}
if (this.stopAcceptingNewSessions) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress}, server is shutting down`);
wsRejectedConnectionsCounter.inc();
socket.close(1001, "Shutting down");
return;
}
// Validate token
let user;
try {
if (type === "register_slave") {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: "slave" }
);
if (tokenPayload.slave !== data.id) {
throw new Error("missmatched slave id");
}
} else if (type === "register_control") {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: "user" }
);
user = this.master.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
user.checkPermission("core.control.connect");
}
} catch (err) {
logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
return;
}
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
);
let sessionTimeout = this.master.config.get("master.session_timeout");
let heartbeatInterval = this.master.config.get("master.heartbeat_interval");
let connector = new WsServerConnector(sessionId, sessionTimeout, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(sessionId);
});
let additionalReadyData = {};
if (type === "register_slave") {
let connection = this.slaveConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
await connection.disconnect(1008, "Registered from another connection");
}
logger.verbose(`WsServer | registered slave ${data.id} version ${data.version}`);
connection = new SlaveConnection(data, connector, this.master);
connector.on("close", () => {
if (this.slaveConnections.get(data.id) === connection) {
this.slaveConnections.delete(data.id);
this.master.slaveUpdated(this.master.slaves.get(data.id));
} else {
logger.warn("Unlisted SlaveConnection closed");
}
});
this.slaveConnections.set(data.id, connection);
this.master.slaveUpdated(this.master.slaves.get(data.id));
} else if (type === "register_control") {
logger.verbose(`WsServer | registered control from ${req.socket.remoteAddress}`);
let connection = new ControlConnection(data, connector, this.master, user);
connector.on("close", () => {
let index = this.controlConnections.indexOf(connection);
if (index !== -1) {
this.controlConnections.splice(index, 1);
} else {
logger.warn("Unlisted ControlConnection closed");
}
});
this.controlConnections.push(connection);
additionalReadyData["account"] = {
name: user.name,
roles: [...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
})),
};
}
connector.ready(socket, sessionToken, additionalReadyData);
}
}
module.exports = WsServer;
+213
View File
@@ -0,0 +1,213 @@
"use strict";
const events = require("events");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
/**
* Connector for master server connections
*
* @extends module:lib/link.WebSocketBaseConnector
* @alias module:master/src/WsServerConnector
*/
class WsServerConnector extends libLink.WebSocketBaseConnector {
constructor(sessionId, sessionTimeout, heartbeatInterval) {
super(sessionTimeout);
this._socket = null;
this._sessionId = sessionId;
this._heartbeatInterval = heartbeatInterval;
this._timeoutId = null;
// The following states are used in the server connector
// closed: Connection is closed
// connected: Connection is online
// resuming: Waiting for client to resume.
}
_reset() {
clearTimeout(this._timeoutId);
this._timeoutId = null;
super._reset();
}
/**
* Send ready over the socket
*
* Sends the ready message over the socket to initiate the session.
*
* @param {Object} socket - WebSocket connection to client.
* @param {string} sessionToken -
* the session token to send to the client.
* @param {Object=} additionalData -
* extra properties to send along the ready message.
*/
ready(socket, sessionToken, additionalData) {
this._socket = socket;
this._socket.send(JSON.stringify({
seq: null,
type: "ready",
data: {
session_token: sessionToken,
session_timeout: this._sessionTimeout,
heartbeat_interval: this._heartbeatInterval,
...additionalData,
},
}));
this._state = "connected";
this._attachSocketHandlers();
this.emit("connect");
}
/**
* Continue connection with the given socket
*
* Terminates the current socket and contiunes the session over the
* socket given from the message sequence given.
*
* @param {module:net.Socket} socket - New socket to continue on.
* @param {number} lastSeq - The last message the client received.
*/
continue(socket, lastSeq) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. Terminate it if so.
if (this._state === "connected") {
this._socket.terminate();
this._socket.once("close", () => this.continue(socket, lastSeq));
return;
}
this._socket = socket;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
this._socket.send(JSON.stringify({
seq: null,
type: "continue",
data: {
last_seq: this._lastReceivedSeq,
session_timeout: this._sessionTimeout,
heartbeat_interval: this._heartbeatInterval,
},
}));
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
}
this.emit("resume");
}
_timedOut() {
logger.verbose("Connector | Connection timed out");
this._reset();
this.emit("close");
}
_attachSocketHandlers() {
this.startHeartbeat();
this._socket.on("close", (code, reason) => {
logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`);
this.stopHeartbeat();
if (this._closing) {
this._reset();
this.emit("close");
} else {
this._state = "resuming";
this.emit("drop");
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
}
});
this._socket.on("error", err => {
// It's assumed that close is always called by ws
logger.verbose("Connector | Error:", err);
});
this._socket.on("open", () => {
logger.verbose("Connector | Open");
});
this._socket.on("ping", data => {
logger.verbose(`Connector | Ping (data: ${data}`);
});
this._socket.on("pong", data => {
logger.verbose(`Connector | Pong (data: ${data}`);
});
// Handle messages
this._socket.on("message", data => {
let message = JSON.parse(data);
if (["connected", "closing"].includes(this._state)) {
if (message.seq !== null) {
this._lastReceivedSeq = message.seq;
}
if (message.type === "heartbeat") {
this._processHeartbeat(message);
} else {
this.emit("message", message);
}
} else {
throw new Error(`Received message in unexpected state ${this._state}`);
}
});
}
/**
* Close the connection with the given reason.
*
* Sends a close frame and disconnects the connector.
*
* @param {number} code - WebSocket close code.
* @param {string} reason - WebSocket close reason.
*/
async close(code, reason) {
if (this._state === "closed") {
return;
}
if (this._state === "resuming") {
this._reset();
this.emit("close");
return;
}
this._closing = true;
this._socket.close(code, reason);
await events.once(this, "close");
}
/**
* Notify the connection is expected to close
*
* Sets the internal flag to signal not to wait for the client to
* reconnect and resume the connection if the client closes it. This
* will close the connection if it's currently waiting for the client to
* resume it.
*/
setClosing() {
if (this._state === "resuming") {
this._reset();
this.emit("close");
return;
}
this._closing = true;
}
}
module.exports = WsServerConnector;
-45
View File
@@ -1,45 +0,0 @@
"use strict";
const forge = require("node-forge");
const fs = require("fs-extra");
const util = require("util");
const hrtime = process.hrtime.bigint;
module.exports = async function({
bits = 2048,
sslCertPath,
sslPrivKeyPath,
doLogging = false,
} = {}){
function log(t) {
if(doLogging) console.log("src/generate_ssl_cert.js | "+t);
}
log("Generating SSL certificate with "+bits+" bits at "+sslCertPath);
let startNs = hrtime();
let keypair = await util.promisify(forge.pki.rsa.generateKeyPair)({ bits: bits, e: 0x10001 });
log("Generated 2048bit keypair in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
startNs = hrtime();
let cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(Date.now()+(1000*60*60*24*365)); // valid for one year
// XXX This should probably be limited to the actual hostname
let attrs = [{ name: "commonName", value: "*" }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// self-sign certificate
cert.sign(keypair.privateKey);
// Write certificate and private key to pem files
await fs.outputFile(sslPrivKeyPath, forge.pki.privateKeyToPem(keypair.privateKey));
await fs.outputFile(sslCertPath, forge.pki.certificateToPem(cert));
log("Generated certificate in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
};
+9
View File
@@ -0,0 +1,9 @@
// Shared metrics
"use strict";
const libPrometheus = require("@clusterio/lib/prometheus");
exports.endpointHitCounter = new libPrometheus.Counter(
"clusterio_master_http_endpoint_hits_total",
"How many requests a particular HTTP endpoint has gotten",
{ labels: ["route"] }
);
+465 -6
View File
@@ -1,16 +1,475 @@
"use strict";
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libHash = require("@clusterio/lib/hash");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPrometheus = require("@clusterio/lib/prometheus");
const { logger } = require("@clusterio/lib/logging");
const { endpointHitCounter } = require("./metrics");
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult, sourceResult) {
let receivedSamples = new Map(sourceResult.samples);
for (let [suffix, suffixSamples] of destinationResult.samples) {
if (receivedSamples.has(suffix)) {
suffixSamples.push(...receivedSamples.get(suffix));
receivedSamples.delete(suffix);
}
}
for (let entry of receivedSamples) {
sourceResult.samples.push(entry);
}
}
// Prometheus polling endpoint
async function getMetrics(req, res, next) {
endpointHitCounter.labels(req.route.path).inc();
let results = [];
let pluginResults = await libPlugin.invokeHook(req.app.locals.master.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
}
}
let requests = [];
let timeout = req.app.locals.master.config.get("master.metrics_timeout") * 1000;
for (let slaveConnection of req.app.locals.master.wsServer.slaveConnections.values()) {
if (!slaveConnection.connected) {
continue;
}
requests.push(libHelpers.timeout(
libLink.messages.getMetrics.send(slaveConnection).catch(err => {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`Unexpected error gathering metrics from slave:\n${err.stack}`);
}
return null;
}),
timeout, null
));
}
for await (let result of await libPrometheus.defaultRegistry.collect()) {
results.push(result);
}
let resultMap = new Map();
for (let response of await Promise.all(requests)) {
if (!response) {
// TODO: Log timeout occured?
continue;
}
for (let result of response.results) {
if (!resultMap.has(result.metric.name)) {
resultMap.set(result.metric.name, result);
} else {
// Merge metrics received by multiple slaves
mergeSamples(resultMap.get(result.metric.name), result);
}
}
}
for (let result of resultMap.values()) {
results.push(libPrometheus.deserializeResult(result));
}
let text = await libPrometheus.exposition(results);
res.set("Content-Type", libPrometheus.exposition.contentType);
res.send(text);
}
function getPlugins(req, res) {
let plugins = [];
for (let pluginInfo of req.app.locals.master.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.master.plugins.has(name);
let enabled = loaded && req.app.locals.master.config.group(pluginInfo.name).get("load_plugin");
let web = {};
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)];
web.main = stats.toJson().assetsByChunkName[name];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, requirePath: pluginInfo.requirePath });
}
res.send(plugins);
}
function validateSlaveToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
jwt.verify(
token,
Buffer.from(req.app.locals.master.config.get("master.auth_secret"), "base64"),
{ audience: "slave" }
);
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
function validateUserToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.master.config.get("master.auth_secret"), "base64"),
{ audience: "user" }
);
let user = req.app.locals.master.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
res.locals.user = user;
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
// Handle an uploaded export package.
async function uploadExport(req, res) {
endpointHitCounter.labels(req.route.path).inc();
if (req.get("Content-Type") !== "application/zip") {
res.sendStatus(415);
return;
}
let data = [];
for await (let chunk of req) {
data.push(chunk);
}
data = Buffer.concat(data);
let zip = await JSZip.loadAsync(data);
data = null;
// This is hardcoded to prevent path expansion attacks
let exportFiles = [
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
];
let manifest = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
let { name, ext } = path.posix.parse(filePath);
let hash = await libHash.hashStream(file.nodeStream());
manifest[name] = `static/${name}.${hash}${ext}`;
await libFileOps.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
await res.app.locals.master.updateExportManifest(manifest);
res.sendStatus(200);
}
async function getExportManifest(req, res) {
endpointHitCounter.labels(req.route.path).inc();
res.json(res.app.locals.master.exportManifest);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
let stream = {
id,
flowing: false,
size: null,
mime: null,
filename: null,
events: new events.EventEmitter(),
timeout: setTimeout(() => {
stream.events.emit("timeout");
}, app.locals.master.config.get("master.proxy_stream_timeout") * 1000),
};
stream.events.on("close", () => {
clearTimeout(stream.timeout);
app.locals.streams.delete(id);
});
stream.events.on("timeout", () => {
stream.events.emit("close");
});
app.locals.streams.set(id, stream);
return stream;
}
async function putStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
return;
}
stream.source = req;
stream.mime = req.get("Content-Type");
stream.size = req.get("Content-Length");
stream.events.emit("source");
stream.events.on("close", () => {
if (!stream.flowing) {
req.resume();
res.sendStatus(500);
} else {
res.sendStatus(200);
}
});
}
async function getStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
return;
}
function startStream() {
res.append("Content-Type", stream.mime);
if (stream.size) {
res.append("Content-Length", stream.size);
}
if (stream.filename) {
res.append("Content-Disposition", `attachment; filename="${stream.filename}"`);
} else {
res.append("Content-Disposition", "attachment");
}
stream.source.pipe(res);
stream.flowing = true;
res.on("finish", () => {
stream.events.emit("close");
});
clearTimeout(stream.timeout);
}
if (stream.source) {
startStream();
} else {
stream.events.on("source", startStream);
stream.events.on("timeout", () => {
res.sendStatus(500);
});
}
}
const zipMimes = [
"application/zip",
"application/x-zip-compressed",
];
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
} catch (err) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type");
let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let mimeType = match[1].toLowerCase();
let tasks = [];
let errors = [];
let requestErrors = [];
let saves = [];
async function handleFile(instanceId, stream, filename, streamMime) {
let proxyStream = await createProxyStream(req.app);
proxyStream.source = stream;
proxyStream.mime = streamMime;
let timeout = new Promise((_, reject) => {
proxyStream.events.on("timeout", () => {
stream.resume();
reject(new Error("Timed out establishing stream to slave"));
});
});
try {
let result = await Promise.race([
req.app.locals.master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: instanceId,
stream_id: proxyStream.id,
filename,
}),
timeout,
]);
saves.push(result.save);
} catch (err) {
proxyStream.events.emit("close");
logger.error(`Error uploading save: ${err.message}`);
errors.push(err.message);
stream.resume();
}
}
if (mimeType === "multipart/form-data") {
await new Promise(resolve => {
let fields = {};
let busboy = new Busboy({ headers: req.headers });
busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => {
if (fields.instanceId === undefined) {
requestErrors.push("instance_id must come before files uploaded");
}
if (!zipMimes.includes(fileMime)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(fields.instanceId, stream, filename, fileMime));
});
busboy.on("field", (fieldname, value, fieldnameTruncated, valueTruncated, transferEncoding, valueMime) => {
if (fieldname === "instance_id") {
fields.instanceId = Number.parseInt(value, 10);
if (Number.isNaN(fields.instanceId)) {
requestErrors.push("invalid instance_id");
}
}
});
busboy.on("finish", resolve);
busboy.on("error", (err) => {
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(busboy);
});
} else if (zipMimes.includes(mimeType)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
let instanceId = Number.parseInt(req.query.instance_id, 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("Missing or invalid instance_id parameter");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(instanceId, req, filename, mimeType));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ saves });
}
function addRouteHandlers(app) {
app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.put("/api/upload-export",
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.get("/api/export-manifest", (req, res, next) => getExportManifest(req, res, next).catch(next));
app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req, res, next) => uploadSave(req, res).catch(next)
);
}
// Routes used in the web interface and served by the master server
module.exports = [
const webRoutes = [
"/",
"/instances",
"/instances/:id/view",
"/master",
"/slaves",
"/slaves/:id/view",
"/instances",
"/instances/:id/view",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/permissions",
"/permissions/:id/view",
"/settings",
"/plugins",
"/plugins/:name/view",
];
module.exports = {
addRouteHandlers,
webRoutes,
createProxyStream,
};
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more