Compare commits

..
Author SHA1 Message Date
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
242 changed files with 18933 additions and 6697 deletions

No files matched your search

+84 -11
View File
@@ -1,5 +1,8 @@
"use strict";
module.exports = {
"plugins": [
"node",
],
"env": {
"node": true,
"commonjs": true,
@@ -18,10 +21,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 +36,44 @@ module.exports = {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
],
@@ -46,7 +89,6 @@ module.exports = {
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
@@ -81,10 +123,8 @@ module.exports = {
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"global-require": "error",
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
@@ -160,7 +200,6 @@ module.exports = {
],
},
],
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -172,19 +211,14 @@ module.exports = {
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "error",
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
@@ -194,7 +228,6 @@ module.exports = {
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["yargs"] }],
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
"no-ternary": "off",
@@ -279,5 +312,45 @@ module.exports = {
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "error",
"node/handle-callback-err": "error",
"node/no-callback-literal": "error",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-extraneous-import": "error",
"node/no-extraneous-require": "error",
"node/no-missing-import": "error",
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unsupported-features/es-builtins": "error",
"node/no-unsupported-features/es-syntax": "error",
"node/no-unsupported-features/node-builtins": "error",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": "error",
},
};
-159
View File
@@ -1,159 +0,0 @@
name: Docker
on:
push:
# Publish `master` as Docker `latest` image.
branches:
- '**'
# Publish `v1.2.3` tags as releases.
tags:
- v*
# Run tests for any PRs.
pull_request:
release:
types: [released, prereleased]
env:
# TODO: Change variable to your image's name.
IMAGE_NAME: clusterio
jobs:
# Build dependencies and clusterio image
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_final
- name: Export docker image
run: docker save -o clusterio_docker_image $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image
path: clusterio_docker_image
# Build dependencies and clusterio image with test tooling
build_testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_testing
- name: Export docker image
run: docker save -o clusterio_docker_image_testing $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image_testing
path: clusterio_docker_image_testing
# Upload clusterio_lib and subspace_storage versions
upload_mods:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download docker image
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Create container
run: docker create --name clusterio_container $IMAGE_NAME
- name: Move mods out of container
run: docker cp clusterio_container:/clusterio/sharedMods ./mods
- name: Remove container
run: docker rm clusterio_container
- name: List extracted mods
run: ls mods
- name: Upload mods
uses: actions/upload-artifact@master
with:
name: mods
path: mods
# Run tests.
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
test:
needs: build_testing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Download docker image
uses: actions/download-artifact@master
with:
name: clusterio_docker_image_testing
path: .
- name: ls
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/factorioClusterio && ls /home/runner/work/factorioClusterio/factorioClusterio
- name: Load docker image
run: docker load -i clusterio_docker_image_testing
- name: Run tests
run: |
if [ -f docker-compose.test.yml ]; then
docker-compose --file docker-compose.test.yml run unit_test
else
docker build . --file Dockerfile
fi
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
# Ensure test job passes before pushing image.
needs: [test, build]
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v2
- name: Download artifact
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Log into registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: |
IMAGE_ID=ghcr.io/${{ github.actor }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "master" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION
+51
View File
@@ -0,0 +1,51 @@
# 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
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: npm i --no-optional
- 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: npx lerna bootstrap --hoist
- 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
- run: npx lerna run build
- name: Run tests
if: ${{ matrix.node-version != '12.x' }}
run: npm test
- name: Run coverage
if: ${{ matrix.node-version == '12.x' }}
run: npm 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: actions/setup-node@v2
with:
node-version: 16.x
- run: npm i --no-optional
- run: npx lerna bootstrap --hoist
- run: npm run lint
+1 -3
View File
@@ -1,7 +1,6 @@
# Logs
logs
*.log
npm-debug.log*
*debug.log*
# Runtime data
pids
@@ -31,7 +30,6 @@ dist/
node_modules
Factorio*
factorio*
/factorio
/instances
/sharedMods
-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 --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.6/subspace_storage_1.99.8.zip
- npx lerna run build
- npm run-script ci-cover
- codecov -f coverage/lcov.info
- npm run-script lint
+113 -170
View File
@@ -1,60 +1,50 @@
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.
- 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.
- 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 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
@@ -62,190 +52,143 @@ Version 2.0.0
- 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.
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
- Instance names can no longer be invalid Windows file names.
- Removed config management from the command line and the server manager.
- Moved ejs templates into views folder and changed their extension to
.ejs. Breaks playerManager.
- Moved ejs templates into views folder and changed their extension to .ejs.
Breaks playerManager.
- Mods are no longer copied from the per instance instanceMods directory.
If you need per instance mods you can now place them directly in the mods
directory inside the instance folder.
If you need per instance mods you can now place them directly in the mods directory inside the instance folder.
- Hotpatch scenarios and code loading is no longer compatible with Clusterio.
Breaks playerManager, serverSelect, and tranTeleports.
- Removed getLua and getCommand from lib/clusterTools. If you need to run
more than the most trivial of code in commands use the save patcher and
add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface. Breaks
the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface. Breaks
playerManager and external tools sending commands.
- Removed getLua and getCommand from lib/clusterTools.
If you need to run more than the most trivial of code in commands use the save patcher and add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface.
Breaks the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface.
Breaks playerManager and external tools sending commands.
- Removed broken serverManager plugin.
- Removed fields info, time, rconPort, rconPassword, serverPort, unique,
mods, instanceName, playerCount, mac, and meta from the slaves in the
slave database.
- Removed getInstanceName and lib/clusterTools. Breaks playerManager,
and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints. Breaks web
interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints. Breaks
researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake. Breaks
playerManager, trainTeleports, serverSelect, and discordChat
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the slaves in the slave database.
- Removed getInstanceName and lib/clusterTools.
Breaks playerManager, and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints.
Breaks web interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints.
Breaks researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake.
Breaks playerManager, trainTeleports, serverSelect, and discordChat
- Changed the format of database/slaves.json.
- Removed the output file subscription system. Breaks inventoryImports,
playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins. The onOutput
hook provides parsed output instead.
- Removed the output file subscription system.
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins.
The onOutput hook provides parsed output instead.
- Removed the onLoadFinish hook from master plugins.
- Moved plugins from the sharedPlugins directory to plugins directory.
- Implemented a new plugin system that replaces the old. Breaks all plugins.
- Implemented a new plugin system that replaces the old.
Breaks all plugins.
- Removed express metric http_request_duration_milliseconds metric.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total,
socket_io_disconnect_total, socket_io_events_received_total,
socket_io_events_sent_total, socket_io_recieve_bytes, and
socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added
clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to
clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added
clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total, socket_io_disconnect_total, socket_io_events_received_total, socket_io_events_sent_total, socket_io_recieve_bytes, and socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Renamed clusterio_endpoint_hit_gauge to clusterio_http_enpoint_hits_total
- Renamed clusterio_statistics_gauge to
clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_statistics_gauge to clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_nn_dole_gauge to clusterio_subspace_storage_nn_dole_gauge.
- Renamed clusterio_dole_factor_gauge to
clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_dole_factor_gauge to clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_import_gauge to clusterio_subspace_storage_import_total.
- Renamed clusterio_export_gauge to clusterio_subspace_storage_export_total.
- Renamed clusterio_master_inventory_gauge to
clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers,
disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and
disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option. The RCON is instead limited to 5
concurrent commands.
- Removed allowRemoteCommandExecution config option. Remote commands are
always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line
arguments from the master server.
- Implemented a new config system that replaces the old. Breaks all plugins.
- Renamed clusterio_master_inventory_gauge to clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers, disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option.
The RCON is instead limited to 5 concurrent commands.
- Removed allowRemoteCommandExecution config option.
Remote commands are always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server.
- Implemented a new config system that replaces the old.
Breaks all plugins.
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
- Renamed clusterioMod plugin to subspace_storage.
- Master no longer creates secret-api-token.txt.
- Removed automatic creation of self-signed TLS certificate.
- 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
+4 -5
View File
@@ -1,10 +1,9 @@
FROM node:12 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/factorioClusterioMod.git
WORKDIR /factorioClusterioMod
RUN git checkout clusterio-2.0 \
&& npm install \
RUN git clone https://github.com/clusterio/subspace_storage.git
WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:12 as clusterio_builder
@@ -24,7 +23,7 @@ RUN npm install \
#RUN npm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
COPY --from=subspace_storage_builder /factorioClusterioMod/dist/ /clusterio/sharedMods/
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
# Build submodules (web UI, libraries, plugins etc)
RUN npx lerna run build
+165 -198
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,153 @@ 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 system. 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. Create a new directory and run the Clusterio installer:
mkdir clusterio
cd clusterio
npm init "@clusterio"
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation then download the headless build of Factorio and unpack it:
wget -O factorio.tar.xz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.xz
To specify a version of Factorio to download replace "latest" in the URL with a version number like "1.0.0".
4. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
**Ubuntu with Docker**
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,129 +182,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 listen for HTTP on port 8080. 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.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 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 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.
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.
### Setting up an admin account
## Managing Factorio mods
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).
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 bootstrap create-admin <username>
npx clusteriomaster bootstrap create-ctl-config <username>
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 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.
Better remote management of mods is a planned feature.
## Setting up shared storage
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.
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:
@@ -322,7 +285,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:
+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.
+36 -70
View File
@@ -1,55 +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.
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.
@@ -58,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");
@@ -87,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.
+139 -206
View File
@@ -1,54 +1,51 @@
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.
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 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.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide
to setting up HTTPS with Clusterio.
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
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.
@@ -56,122 +53,116 @@ 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
Path to TLS certificate to use for the HTTPS server when
master.https_port is configured.
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
Path to TLS private key to use for the HTTPS server when
master.https_port is configured.
Path to TLS private key to use for the HTTPS server when master.https_port is configured.
Defaults to "database/certificates/cert.key".
### 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 45
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.
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
Immutable 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".
@@ -185,164 +176,140 @@ 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.
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.
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.
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
Defaults to false.
### factorio.version
Version of Factorio to use for this instance. Can be a version like
"1.0.0" or special string "latest" meaning the latest version of
Factorio found on the slave.
Version of Factorio to use for this instance.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the slave.
Defaults to "latest"
Defaults to "latest".
### factorio.game_port
UDP port to run the Factorio server on. If null a random port in the
dynamyc range assigned each time the instance starts.
UDP port to run the Factorio server on.
If null a random port in the dynamyc range assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on. If null
a random port in the dynamic range is assigned each time the instance
starts.
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_password
Password to use for the RCON interafec. If null a random secure
password is generated and used each time the instance starts.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
Defaults to null
Defaults to null.
### factorio.enable_save_patching
Whether to use save patching or not. When enabled lua code is patched
into the game save before starting it. Most plugins require the use of
save patching to function. Turning it off makes it possible to use
Clusterio to run and manage regular vanilla games and scenarios not
compatible with Clusterio.
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Defaults to true
Defaults to true.
### factorio.enable_whitelist
Turn on the whitelist on the server.
Defaults to false
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the
Factorio server. The settings in `data/server-settings.example.json` of
the Factorio installation is used as the base and properties specified
here overrides the properties there.
Object with the settings to put into `server-settings.json` for the Factorio server.
The settings in `data/server-settings.example.json` of the Factorio installation is used as the base and properties specified here overrides the properties there.
Changes to the following properties will be applied live if the instance
is running while it is changed: `afk_autokick_interval`,
`allow_commands`, `autosave_interval`, `autosave_only_on_server`,
`description`, `ignore_player_limit_for_returning_players`,
`max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`,
`name`, `only_admins_can_pause_the_game`, `game_password`,
`require_user_verification`, `tags`, `visibility`.
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.
Pass the `--verbose` flag to Factorio when starting the server.
This prints more messages to the log.
Defaults to false
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.
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:
@@ -356,86 +323,59 @@ Sample output when enabled:
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
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.
### factorio.max_concurrent_commands
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.
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.
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.
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.
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
Defaults to 5.
### <plugin_name>.enabled
### <plugin_name>.load_plugin
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.
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
Defaults to true.
Control Configuration
---------------------
## Control Configuration
### control.master_url
@@ -446,30 +386,23 @@ 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.
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.
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.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.reconnect_delay
### control.max_reconnect_delay
Duration in seconds to wait before attempting to reconnect with the
master server after the connection is dropped.
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 2.
Defaults to 60.
+169 -129
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,205 +21,193 @@ 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 managed through Lerna.
In order to get the development environment up and running you will need to run the following commands:
npm install
npx lerna bootstrap
npx lerna bootstrap --hoist
npx lerna run build
This installs dependencies needed by tests and links the packages up so
they work from the git work tree.
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
node packages/create --dev
To add plugins to use in the development environment use
node packages/ctl plugin add ./path/to/plugin
It's important that the path starts with `./`.
The `plugins/` directory contains the core plugins for the project and can be added using the above mentioned command.
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
npx clusteriomaster -> node packages/master
npx clusterioslave -> node packages/slave
npx clusterioctl -> node packages/ctl
Note that to use `clusterioctl` you will have to create a control config first:
node packages/master bootstrap create-ctl-config [admin-user]
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
For web development on the master there are also the following flags.
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
These options can be combined and multiple plugins can be actived for dev mode.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `npx lerna run build` before you start the master without these flags again.
### Starting a Feature Branch
Development is done exclusively in feature branches. This means the
first thing to do before starting any development at all is to make a
new branch starting from the master or 1.2.x branch of the main
repository. To do this check out that branch from the main repository
(substitute master for 1.2.x if you're working on that branch.)
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
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("@clusterio/lib/link");
const libLuaTools = require("@clusterio/lib/lua_tools");
@@ -232,24 +218,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 +246,59 @@ gets very long break them out on their own line(s). For example:
// code
}
})
### Markdown
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
For example:
```md
# A markdown File
Sentence of text which end in a period.
Each sentence is on it's own line.
And there's two spaces before the next heading.
## Subheading
### Sub-subheading
Headings use the `# heading` format.
```
## Pushing releases to npm
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
```sh
git switch master
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`, otherwise they will be auto commited!
2. Run a lerna version to create and tag a new version.
This will push a commit to the master branch.
```sh
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
```
3. Run a full build in every package to update the dist/ files.
```sh
npx lerna run build
```
4. Publish the packages with lerna.
```sh
npx lerna publish from-package
```
+104 -198
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,45 +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
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.
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.
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. Like with scenarios for Clusterio the [event_handler
interface](#event_handler-interface) has to be used for any event
subscriptions in 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.
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
{
@@ -204,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
@@ -276,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.
@@ -307,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.
@@ -348,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)
@@ -369,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 a Cluster 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.
+21 -21
View File
@@ -1,23 +1,23 @@
{
"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/slave.js",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
],
"excludePattern": "node_modules"
}
}
+122 -63
View File
@@ -1,39 +1,90 @@
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.
### 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 +97,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 +130,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 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.
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 +181,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.
+29 -69
View File
@@ -1,54 +1,32 @@
Setting up TLS
==============
# 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.
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.
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
---------------------
## 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`.
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.
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.
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.
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/>
@@ -59,54 +37,36 @@ flavour of Apache config organization.
</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.
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
-----------------------------------
## 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.
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
--------------------------------------------
## 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`.
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:
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:
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:
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.
+403 -525
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,7 +1,8 @@
{
"packages": [
"packages/*",
"plugins/*"
"plugins/*",
"test"
],
"version": "2.0.0-alpha.4"
"version": "2.0.0-alpha.12"
}
+8 -18
View File
@@ -2,7 +2,7 @@
"name": "root",
"private": true,
"scripts": {
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test",
"test": "mocha --check-leaks -R spec --exclude \"**/node_modules/**\" --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
@@ -18,24 +18,14 @@
]
},
"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",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.3",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"nyc": "^14.1.1",
"phin": "^3.4.1",
"webpack": "^5.0.0"
"eslint": "^7.25.0",
"eslint-plugin-node": "^11.1.0",
"jsdoc": "^3.6.6",
"lerna": "^4.0.0",
"mocha": "^8.4.0",
"nyc": "^15.1.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
# Clusterio installer
Installer for Clusterio.
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
+634
View File
@@ -0,0 +1,634 @@
#!/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 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 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=nogroup
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
[Install]
WantedBy=multi-user.target
`);
}
}
if (["standalone", "slave"].includes(mode)) {
if (process.platform === "win32") {
await 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=nogroup
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("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();
}
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.12",
"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.
+513 -28
View File
@@ -13,6 +13,11 @@ const events = require("events");
const path = require("path");
const winston = require("winston");
const setBlocking = require("set-blocking");
const phin = require("phin");
const os = require("os");
const child_process = require("child_process");
const stream = require("stream");
const util = require("util");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
@@ -24,6 +29,10 @@ const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
const libFactorio = require("@clusterio/lib/factorio");
const libFileOps = require("@clusterio/lib/file_ops");
const finished = util.promisify(stream.finished);
function print(...content) {
@@ -31,6 +40,60 @@ function print(...content) {
console.log(...content);
}
async function getEditor(argsEditor) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || -1
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
async function configToKeyVal(data) {
let final = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
filtered[index] = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = filtered[index][1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[finalIndex] = part;
}
}
return final;
}
async function serializedConfigToString(serializedConfig, configGroup, disallowedList) {
let allConfigElements = "";
for (let group of serializedConfig.groups) {
for (let [name, value] of Object.entries(group.fields)) {
if (`${group.name}.${name}` in disallowedList) {
continue;
}
let desc = "";
try {
desc += configGroup.groups.get(group.name)._definitions.get(name).description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${group.name}.${name} = ${value}\n\n`;
}
}
return allConfigElements;
}
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
const masterConfigCommands = new libCommand.CommandTree({
@@ -101,9 +164,88 @@ masterConfigCommands.add(new libCommand.Command({
await libLink.messages.setMasterConfigProp.send(control, request);
},
}));
masterConfigCommands.add(new libCommand.Command({
definition: ["edit [editor]", "Edit master configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args, control) {
let response = await libLink.messages.getMasterConfig.send(control);
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === -1) {
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl master config edit <editor of choice>"`);
}
let allConfigElements = await serializedConfigToString(response.serialized_config, libConfig.MasterConfig, {});
await fs.writeFile(tmpFile, allConfigElements, (err) => {
if (err) {
throw err;
}
});
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await libLink.messages.setMasterConfigField.send(control, {
field: index,
value: final[index],
});
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
await fs.unlink(tmpFile, (err) => {
if (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
});
},
}));
masterCommands.add(masterConfigCommands);
const masterPluginCommands = new libCommand.CommandTree({
name: "plugin", alias: ["p"], description: "master plugin inspection",
});
masterPluginCommands.add(new libCommand.Command({
definition: ["list", "List plugins on master"],
handler: async function(args, control) {
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/plugins";
let response = await phin({
url,
parse: "json",
core: { ca: control.tlsCa },
});
print(asTable(response.body));
},
}));
masterCommands.add(masterPluginCommands);
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
slaveCommands.add(new libCommand.Command({
definition: [["list", "l"], "List slaves connected to the master"],
@@ -115,10 +257,11 @@ slaveCommands.add(new libCommand.Command({
slaveCommands.add(new libCommand.Command({
definition: ["generate-token", "Generate token for a slave", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true });
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id" });
}],
handler: async function(args, control) {
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: args.id });
let slaveId = typeof args.id === "number" ? args.id : null;
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId });
print(response.token);
},
}));
@@ -184,7 +327,7 @@ instanceCommands.add(new libCommand.Command({
}
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize("master");
let response = await libLink.messages.createInstance.send(control, { serialized_config });
await libLink.messages.createInstance.send(control, { serialized_config });
},
}));
@@ -277,6 +420,79 @@ instanceConfigCommands.add(new libCommand.Command({
await libLink.messages.setInstanceConfigProp.send(control, request);
},
}));
instanceConfigCommands.add(new libCommand.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === -1) {
throw new libErrors.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl master config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_slave": 0, "factorio.settings": 0};
let allConfigElements = await serializedConfigToString(
response.serialized_config,
libConfig.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements, (err) => {
if (err) {
throw err;
}
});
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: instanceId,
field: index,
value: final[index],
});
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
await fs.unlink(tmpFile, (err) => {
if (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
});
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new libCommand.Command({
@@ -294,19 +510,243 @@ instanceCommands.add(new libCommand.Command({
},
}));
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" });
async function loadMapSettings(args) {
let seed = args.seed !== undefined ? args.seed : null;
let mapGenSettings = null;
let mapSettings = null;
if (args.mapExchangeString) {
let parsed = libFactorio.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse(await fs.readFile(args.mapGenSettings));
}
if (args.mapSettings) {
mapSettings = JSON.parse(await fs.readFile(args.mapSettings));
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
const instanceSaveCommands = new libCommand.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
instanceSaveCommands.add(new libCommand.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId });
for (let entry of response.list) {
entry.mtime = new Date(entry.mtime_ms).toLocaleString();
delete entry.mtime_ms;
}
print(asTable(response.list));
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.createSave.send(control, {
await libLink.messages.createSave.send(control, {
instance_id: instanceId,
name: args.name,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
});
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.renameSave.send(control, {
instance_id: instanceId,
old_name: args.oldName,
new_name: args.newName,
});
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.copySave.send(control, {
instance_id: instanceId,
source: args.source,
destination: args.destination,
});
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args, control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new libErrors.CommandError("Save name must end with .zip");
}
// phin doesn't support streaming requests :(
let content = await fs.readFile(args.filepath);
let instanceId = await libCommand.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", instanceId);
url.searchParams.append("filename", filename);
let result = await phin({
url, method: "POST",
headers: {
"X-Access-Token": control.config.get("control.master_token"),
"Content-Type": "application/zip",
},
core: { ca: control.tlsCa },
data: content,
parse: "json",
});
for (let error of result.body.errors || []) {
logger.error(error);
}
for (let requestError of result.body.request_errors || []) {
logger.error(requestError);
}
if (result.body.saves && result.body.saves.length) {
logger.info(`Successfully uploaded as ${result.body.saves[0]}`);
}
if ((result.body.errors || []).length || (result.body.request_errors || []).length) {
throw new libErrors.CommandError("Uploading save failed");
}
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(args, control) {
let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance);
let result = await libLink.messages.transferSave.send(control, {
instance_id: sourceInstanceId,
source_save: args.sourceSave,
target_instance_id: targetInstanceId,
target_save: args.targetSave || args.sourceSave,
copy: args.copy,
});
print(`Transferred as ${result.save} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let result = await libLink.messages.downloadSave.send(control, {
instance_id: instanceId,
save: args.save,
});
let url = new URL(control.config.get("control.master_url"));
url.pathname += `api/stream/${result.stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: control.tlsCa },
stream: true,
});
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(writeStream);
await finished(writeStream);
let filename = await libFileOps.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
}));
instanceSaveCommands.add(new libCommand.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.deleteSave.send(control, {
instance_id: instanceId,
save: args.save,
});
},
}));
instanceCommands.add(instanceSaveCommands);
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" });
@@ -314,7 +754,7 @@ instanceCommands.add(new libCommand.Command({
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.exportData.send(control, {
await libLink.messages.exportData.send(control, {
instance_id: instanceId,
});
},
@@ -331,7 +771,7 @@ instanceCommands.add(new libCommand.Command({
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.startInstance.send(control, {
await libLink.messages.startInstance.send(control, {
instance_id: instanceId,
save: args.save || null,
});
@@ -344,15 +784,23 @@ instanceCommands.add(new libCommand.Command({
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.loadScenario.send(control, {
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await libLink.messages.loadScenario.send(control, {
instance_id: instanceId,
scenario: args.scenario || null,
scenario: args.scenario,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
});
control.keepOpen = args.keepOpen;
},
@@ -365,7 +813,20 @@ instanceCommands.add(new libCommand.Command({
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.stopInstance.send(control, {
await libLink.messages.stopInstance.send(control, {
instance_id: instanceId,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
await libLink.messages.killInstance.send(control, {
instance_id: instanceId,
});
},
@@ -376,7 +837,7 @@ instanceCommands.add(new libCommand.Command({
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.deleteInstance.send(control, {
await libLink.messages.deleteInstance.send(control, {
instance_id: await libCommand.resolveInstance(control, args.instance),
});
},
@@ -628,6 +1089,8 @@ logCommands.add(new libCommand.Command({
"slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null },
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
@@ -644,8 +1107,14 @@ logCommands.add(new libCommand.Command({
slave_ids,
instance_ids,
max_level: args.maxLevel,
limit: args.limit,
order: args.start ? "asc" : "desc",
});
if (!args.start) {
result.log.reverse();
}
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
@@ -675,13 +1144,13 @@ debugCommands.add(new libCommand.Command({
* @private
*/
class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, tlsCa, token) {
super(url, reconnectDelay, tlsCa);
constructor(url, maxReconnectDelay, tlsCa, token) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
register() {
logger.verbose("SOCKET | registering control");
logger.verbose("Connector | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterioctl",
@@ -698,10 +1167,20 @@ 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>}
@@ -718,6 +1197,14 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
async saveListUpdateEventHandler() { }
async setLogSubscriptions({
all = false,
master = false,
@@ -739,8 +1226,6 @@ class Control extends libLink.Link {
}
async shutdown() {
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
@@ -749,7 +1234,7 @@ class Control extends libLink.Link {
}
}
await this.connector.close(1001, "Control Quit");
await this.connector.close(1000, "Control Quit");
}
}
@@ -876,7 +1361,7 @@ async function startControl() {
await controlConfig.init();
} else {
throw err;
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
@@ -913,11 +1398,11 @@ async function startControl() {
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) {
@@ -932,7 +1417,7 @@ async function startControl() {
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line no-process-exit
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
@@ -978,10 +1463,10 @@ I version of clusterio. Expect things to break. I
startControl().catch(err => {
if (!(err instanceof libErrors.StartupError)) {
logger.fatal(`
+----------------------------------------------------------------+
| Unexpected error occured while starting control, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
+5 -4
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.4",
"version": "2.0.0-alpha.12",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,13 +15,14 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.4",
"@clusterio/lib": "^2.0.0-alpha.12",
"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"
},
+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;
+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 information on Clusterio.
See [the Clusterio repository](https://github.com/clusterio/clusterio) for more information on Clusterio.
+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";
+7 -3
View File
@@ -5,8 +5,12 @@ 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 buildMod(args, info) {
if (args.build) {
@@ -42,7 +46,7 @@ async function buildMod(args, info) {
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);
@@ -79,7 +83,7 @@ async function build(args) {
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let [, name, version] = match;
let name = match[1];
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
@@ -90,7 +94,7 @@ async function build(args) {
}
if (info.variants) {
for (let [variant, variantOverrides] of Object.entries(info.variants)) {
for (let variantOverrides of info.variants) {
let variantInfo = {
...info,
...variantOverrides,
+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
+3 -3
View File
@@ -4,7 +4,7 @@
const events = require("events");
const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("@clusterio/lib/helpers");
const { basicType } = require("../helpers");
/**
@@ -813,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,
});
+24 -17
View File
@@ -15,7 +15,7 @@ MasterGroup.groupName = "master";
MasterGroup.define({
name: "name",
title: "Name",
description: "Name of the cluster",
description: "Name of the cluster.",
type: "string",
initial_value: "Your Cluster",
});
@@ -83,28 +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: 45,
initial_value: 15,
});
MasterGroup.define({
name: "connector_shutdown_timeout",
title: "Connector Shutdown Timeout",
description: "Timeout in seconds for connectors to properly disconnect on shutdown",
name: "session_timeout",
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
type: "number",
initial_value: 30,
initial_value: 60,
});
MasterGroup.define({
name: "metrics_timeout",
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from slaves",
description: "Timeout in seconds for metrics gathering from slaves.",
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
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,
@@ -177,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();
@@ -351,11 +358,11 @@ ControlGroup.define({
optional: true,
});
ControlGroup.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: 2,
initial_value: 60,
});
ControlGroup.finalize();
@@ -369,9 +376,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`
);
}
+5 -3
View File
@@ -1,11 +1,13 @@
/* eslint-disable global-require */
/**
* Configuration System
* @author Hornwitser
* @module lib/config
*/
"use strict";
const classes = require("./classes");
const definitions = require("./definitions");
module.exports = {
...require("./classes"),
...require("./definitions"),
...classes,
...definitions,
};
+4 -3
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");
/**
@@ -78,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));
}
/**
@@ -138,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,
};
+45 -6
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;
@@ -303,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")));
}
}
@@ -357,6 +374,28 @@ async function exportData(server) {
throw new Error("No items got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (!match) {
continue;
}
let modVersion = `${match[2]}.${match[3]}.${match[4]}`;
let normalizedVersion =
`${Number.parseInt(match[2], 10)}.${Number.parseInt(match[3], 10)}.${Number.parseInt(match[4], 10)}`
;
if (modVersion === normalizedVersion) {
continue;
}
if (modVersions.get(match[1]) === normalizedVersion) {
modVersions.set(match[1], modVersion);
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let locale = await exportLocale(server, modVersions, modOrder, "en");
+9 -4
View File
@@ -1,12 +1,17 @@
/* eslint-disable global-require */
/**
* Library for interfacing with Factorio Servers and saves.
* @module lib/factorio
* @author Hornwitser
*/
"use strict";
const exchange_string = require("./exchange_string");
const export_ = require("./export");
const patch = require("./patch");
const server = require("./server");
module.exports = {
...require("./export"),
...require("./patch"),
...require("./server"),
...exchange_string,
...export_,
...patch,
...server,
};
+4 -2
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 = {
@@ -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 = {
+110 -120
View File
@@ -6,12 +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 { logger } = require("@clusterio/lib/logging");
const { escapeRegExp } = require("@clusterio/lib/helpers");
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");
/**
@@ -193,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
*
@@ -375,7 +301,7 @@ const outputHeuristics = [
// Message indicating the server is done starting up
{
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
filter: { type: "log", message: /^(update|map)Tick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function() {
this._notifyGameReady().catch((err) => { this.emit("error", err); });
},
@@ -483,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
@@ -543,23 +470,25 @@ class FactorioServer extends events.EventEmitter {
this._gameReady = false;
if (options.stripPaths) {
let chars = new Set(this.writePath("temp"));
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(
`(${escapeRegExp(this.writePath("temp", "currently-playing", path.sep))})|` +
`(${escapeRegExp(this.writePath(path.sep))})|` +
`(\\.\\.\\.[${escapeRegExp(chars)}]*?currently-playing\\${path.sep})`,
// 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;
@@ -572,6 +501,8 @@ class FactorioServer extends events.EventEmitter {
if (this._runningAutosave) {
this.emit("autosave-finished", this._runningAutosave);
this._runningAutosave = null;
} else {
this.emit("save-finished");
}
});
}
@@ -601,7 +532,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");
@@ -681,7 +616,7 @@ 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
@@ -752,37 +687,41 @@ 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(
this.emit("error", new libErrors.EnvironmentError(
"Factorio server unexpectedly shut down. Possible causes:"+
`\n- ${this._unexpected.join("\n- ")}`
));
@@ -825,17 +764,26 @@ class FactorioServer extends events.EventEmitter {
*
* @param {string} name -
* Name of the save to create. Should end with ".zip".
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async create(name) {
async create(name, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "create";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.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"] : []),
],
{
@@ -849,6 +797,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}`);
}
@@ -871,7 +820,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),
@@ -899,17 +848,26 @@ class FactorioServer extends events.EventEmitter {
* argument to start the given scenario.
*
* @param {string} scenario - Name of the scenario to run.
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async startScenario(scenario) {
async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "running";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.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,
@@ -1052,12 +1010,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));
}
/**
@@ -1102,6 +1066,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
*
@@ -1127,13 +1105,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)
);
}
}
}
@@ -1147,6 +1138,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,
};
+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 -2
View File
@@ -1,7 +1,8 @@
/* eslint-disable global-require */
/* eslint-disable node/global-require */
"use strict";
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
// eslint-disable-next-line node/no-extraneous-require
module.exports = require("fast-deep-equal/es6");
} else {
module.exports = require("util").isDeepStrictEqual;
+186 -104
View File
@@ -3,9 +3,10 @@
const events = require("events");
const WebSocket = require("isomorphic-ws");
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const ExponentialBackoff = require("../ExponentialBackoff");
/**
@@ -15,9 +16,14 @@ const { logger } = require("@clusterio/lib/logging");
* @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;
@@ -27,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;
@@ -44,12 +70,15 @@ class WebSocketBaseConnector extends events.EventEmitter {
}
_doHeartbeat() {
this._check(["connected", "closing"]);
this._check("connected");
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
logger.verbose("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({
@@ -62,7 +91,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
_processHeartbeat(message) {
if (!libSchema.heartbeat(message)) {
logger.warn("SOCKET | closing after received invalid heartbeat");
logger.warn("Connector | closing after received invalid heartbeat");
this._socket.close(1002, "Invalid heartbeat");
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,41 +185,32 @@ class WebSocketBaseConnector extends events.EventEmitter {
* @memberof module:lib/link
*/
class WebSocketClientConnector extends WebSocketBaseConnector {
constructor(url, reconnectDelay, tlsCa = null) {
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();
}
/**
@@ -191,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 }));
}
@@ -204,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();
@@ -232,14 +290,14 @@ 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
logger.verbose(`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);
@@ -256,68 +314,89 @@ 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()) {
logger.error("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;
logger.verbose(
`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`
`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 => {
logger.verbose(`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
let code = !event.error ? "" : `, code: ${event.error.code}`;
logger.error(`SOCKET | Error: ${event.message || "unknown 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 ([
@@ -326,22 +405,22 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
"ERR_TLS_CERT_ALTNAME_INVALID",
].includes(event.error.code)) {
this.emit("error", new libErrors.AuthenticationFailed(event.error.message));
this._state = "closing";
this._closing = true;
}
}
};
this._socket.onopen = () => {
logger.verbose("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;
}
@@ -361,17 +440,17 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
_processHandshake(message) {
if (!libSchema.serverHandshake(message)) {
logger.verbose("SOCKET | closing after received invalid handshake");
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") {
logger.verbose(`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) {
logger.verbose("SOCKET | Attempting resume");
logger.verbose("Connector | Attempting resume");
this.sendHandshake("resume", {
session_token: this._sessionToken,
last_seq: this._lastReceivedSeq,
@@ -381,36 +460,39 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
} else if (type === "ready") {
logger.verbose("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") {
logger.verbose("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._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 -4
View File
@@ -1,12 +1,15 @@
/* eslint-disable global-require */
/**
* Library for message based communication between nodes
* @module lib/link
* @author Hornwitser
*/
"use strict";
const link = require("./link");
const messages = require("./messages");
const connectors = require("./connectors");
module.exports = {
...require("./link"),
...require("./messages"),
...require("./connectors"),
...link,
...messages,
...connectors,
};
+4 -5
View File
@@ -1,10 +1,9 @@
// Implementation of Link class
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
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
@@ -135,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;
+256 -22
View File
@@ -1,8 +1,8 @@
// Message definitions for links
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
class MissingLinkHandlerError extends Error {
constructor(type, source, target) {
@@ -331,6 +331,15 @@ messages.setMasterConfigProp = new Request({
},
});
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"],
@@ -341,18 +350,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"],
@@ -363,25 +400,31 @@ messages.listInstances = new Request({
items: {
additionalProperties: false,
required: ["name", "id", "assigned_slave", "status"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
properties: instanceProperties,
},
},
},
});
messages.setInstanceSubscriptions = new Request({
type: "set_instance_subscriptions",
links: ["control-master"],
permission: "core.instance.subscribe",
requestProperties: {
"all": { type: "boolean" },
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.generateSlaveToken = new Request({
type: "generate_slave_token",
links: ["control-master"],
permission: "core.slave.generate_token",
requestProperties: {
"slave_id": { type: "integer" },
"slave_id": { type: ["integer", "null"] },
},
responseProperties: {
"token": { type: "string" },
@@ -467,11 +510,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({
@@ -481,6 +654,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"] },
},
});
@@ -498,6 +674,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"],
@@ -723,6 +906,8 @@ messages.queryLog = new Request({
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
"limit": { type: "integer" },
"order": { enum: ["asc", "desc"] },
},
responseProperties: {
"log": {
@@ -747,7 +932,7 @@ messages.updateInstances = new Request({
properties: {
"serialized_config": { type: "object" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
},
},
@@ -947,6 +1132,30 @@ 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"],
@@ -962,6 +1171,13 @@ messages.logMessage = new Event({
},
});
messages.slaveUpdate = new Event({
type: "slave_update",
links: ["master-control"],
eventProperties: slaveProperties,
});
messages.instanceInitialized = new Event({
type: "instance_initialized",
links: ["instance-slave"],
@@ -976,11 +1192,29 @@ 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.instanceUpdate = new Event({
type: "instance_update",
links: ["master-control"],
eventProperties: instanceProperties,
});
messages.saveListUpdate = new Event({
type: "save_list_update",
links: ["instance-slave", "slave-master", "master-control"],
forwardTo: "master",
eventProperties: {
"instance_id": { type: "integer" },
"list": saveList,
},
});
+395 -2
View File
@@ -1,11 +1,27 @@
/**
* 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("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
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);
/**
@@ -55,6 +71,7 @@ function formatServerOutput(parsed) {
/**
* Formats winston log messages for a character terminal.
* @static
*/
class TerminalFormat {
constructor(options = {}) {
@@ -95,6 +112,7 @@ class TerminalFormat {
/**
* Sends logs over a lib/link connection.
* @static
*/
class LinkTransport extends Transport {
constructor(options) {
@@ -121,10 +139,385 @@ class LinkTransport extends Transport {
}
}
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,
+5 -5
View File
@@ -1,25 +1,25 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.4",
"repository": "https://github.com/clusterio/factorioClusterio",
"version": "2.0.0-alpha.12",
"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",
"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",
+337 -15
View File
@@ -4,6 +4,7 @@
*/
"use strict";
const helpers = require("./helpers");
/**
* Conceptual base for master and instance plugins.
@@ -60,6 +61,9 @@ class BaseInstancePlugin {
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = instance.logger.child({ plugin: this.info.name });
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
@@ -74,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.
*/
@@ -191,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) { }
@@ -207,9 +211,72 @@ class BaseInstancePlugin {
*/
async onPlayerEvent(event) { }
async sendRcon(message, expectEmpty) {
/**
* 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;
}
}
/**
@@ -226,6 +293,11 @@ class BaseInstancePlugin {
class BaseMasterPlugin {
constructor(info, master, metrics, logger) {
this.info = info;
/**
* Master server
* @type {module:master/src/Master}
*/
this.master = master;
this.metrics = metrics;
@@ -247,31 +319,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
*
@@ -323,12 +433,49 @@ class BaseMasterPlugin {
*
* Invoked when the connection to the slave has been closed.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, resume and close
*/
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
*
@@ -338,7 +485,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) { }
@@ -367,7 +514,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))
@@ -423,6 +570,172 @@ 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
*
@@ -446,7 +759,7 @@ function attachPluginMessages(link, plugin) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async (message, format) => await plugin[handler](message, format));
messageFormat.attach(link, async (message, format) => await plugin[handler](message, format, link));
}
} catch (err) {
@@ -473,8 +786,16 @@ 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) {
@@ -489,6 +810,7 @@ module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
BaseWebPlugin,
attachPluginMessages,
invokeHook,
+2 -2
View File
@@ -1,11 +1,11 @@
/* eslint-disable global-require */
/* 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");
+29 -2
View File
@@ -98,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" },
},
},
},
},
},
},
},
},
},
@@ -111,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" },
},
},
@@ -152,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" },
+8 -8
View File
@@ -3,12 +3,12 @@
* @module lib/shared_commands
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const libConfig = require("@clusterio/lib/config");
const { logger } = require("@clusterio/lib/logging");
const libHelpers = require("@clusterio/lib/helpers");
const libConfig = require("./config");
const libFileOps = require("./file_ops");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
function print(...content) {
@@ -55,7 +55,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
let pluginInfo;
try {
// eslint-disable-next-line global-require
// eslint-disable-next-line node/global-require
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
@@ -70,7 +70,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
}
pluginList.set(pluginInfo.name, pluginPath);
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
@@ -80,7 +80,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
return;
}
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Removed ${args.name}`);
} else if (command === "list") {
@@ -155,7 +155,7 @@ async function handleConfigCommand(args, instance, configPath) {
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) {
logger.error(err.message);
+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,
};
+72 -5
View File
@@ -9,6 +9,9 @@
"use strict";
const jwt = require("jsonwebtoken");
const libErrors = require("./errors");
/**
* Represents a permission that can be granted
* @static
@@ -233,7 +236,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 +257,8 @@ class User {
* "Permission denied" error is thrown.
*
* @param {string} permission - The permission to check for.
* @throws {Error} if the user does noh have the given permission.
* @throws {Error} If the given permission does not exist.
* @throws {module:lib/errors.PermissionError} if the user does noh have the given permission.
*/
checkPermission(permission) {
if (!permissions.has(permission)) {
@@ -267,7 +271,7 @@ class User {
}
}
throw new Error("Permission denied");
throw new libErrors.PermissionError("Permission denied");
}
notifyJoin(instance_id) {
@@ -318,6 +322,12 @@ definePermission({
description: "Get the full list of slaves in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.slave.subscribe",
title: "Subscribe to slave updates",
description: "Subscribe to be notified on updates on the details of slaves.",
grantByDefault: true,
});
definePermission({
name: "core.slave.generate_token",
title: "Generate slave token",
@@ -329,12 +339,24 @@ definePermission({
description: "Create slave configs via the master server.",
});
definePermission({
name: "core.instance.get",
title: "Get instance",
description: "Get the details of an instance in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.list",
title: "List instances",
description: "Get the full list of instances in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.subscribe",
title: "Subscribe to instance updates",
description: "Subscribe to be notified on updates on the details and status of instances.",
grantByDefault: true,
});
definePermission({
name: "core.instance.create",
title: "Create instance",
@@ -356,10 +378,50 @@ definePermission({
description: "Assign or reassign instances to a slave.",
});
definePermission({
name: "core.instance.create_save",
title: "Create new instance save",
name: "core.instance.save.list",
title: "List saves",
description: "List the saves currently on the instance.",
});
definePermission({
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
name: "core.instance.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",
@@ -380,6 +442,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",
+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.
+175 -1878
View File
File diff suppressed because it is too large. Load diff
+10 -17
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.4",
"version": "2.0.0-alpha.12",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -16,38 +16,31 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.4",
"body-parser": "^1.18.2",
"@clusterio/lib": "^2.0.0-alpha.12",
"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",
"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.4",
"babel-loader": "^8.1.0",
"css-loader": "^4.3.0",
"@clusterio/web_ui": "^2.0.0-alpha.12",
"fast-deep-equal": "^3.1.3",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"style-loader": "^1.2.1",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-dev-middleware": "^4.1.0",
"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;
+679
View File
@@ -0,0 +1,679 @@
"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.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 listUsersRequestHandler(message) {
let list = [];
for (let user of this._master.userManager.users.values()) {
list.push({
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
instances: [...user.instances],
});
}
return { list };
}
async createUserRequestHandler(message) {
this._master.userManager.createUser(message.data.name);
}
async updateUserRolesRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
let resolvedRoles = new Set();
for (let roleId of message.data.roles) {
let role = this._master.userManager.roles.get(roleId);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${roleId} does not exist`);
}
resolvedRoles.add(role);
}
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(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.broadcastEventToSlaves({ data: { name, admin }}, libLink.messages.adminlistUpdate);
}
async setUserBannedRequestHandler(message) {
let { name, create, banned, reason } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isBanned = banned;
user.banReason = reason;
this.broadcastEventToSlaves({ data: { name, banned, reason }}, libLink.messages.banlistUpdate);
}
async setUserWhitelistedRequestHandler(message) {
let { name, create, whitelisted } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this.broadcastEventToSlaves({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate);
}
async deleteUserRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
if (user.is_admin) {
this.broadcastEventToSlaves({ data: { name, admin: false }}, libLink.messages.adminlistUpdate);
}
if (user.is_whitelisted) {
this.broadcastEventToSlaves({ data: { name, whitelisted: false }}, libLink.messages.whitelistUpdate);
}
if (user.is_banned) {
this.broadcastEventToSlaves({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate);
}
this._master.userManager.users.delete(message.data.name);
}
async debugDumpWsRequestHandler(message) {
this.ws_dumper = data => {
if (this.connector.connected) {
libLink.messages.debugWsMessage.send(this, data);
}
};
this.connector._socket.clusterio_ignore_dump = true;
this._master.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
-1
View File
@@ -1,7 +1,6 @@
"use strict";
const http = require("http");
const https = require("https");
const util = require("util");
/**
* Gracefully close a Node.js HTTP(S) server
+644
View File
@@ -0,0 +1,644 @@
"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) {
for (let name of args.devPlugin) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new libErrors.StartupError(`No plugin named ${name}`);
}
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
config.output.publicPath = `/plugins/${name}/`;
webpackConfigs.push(config);
}
}
/* eslint-enable node/global-require, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, {});
this.app.use(this.devMiddleware);
}
let databaseDirectory = this.config.get("master.database_directory");
await fs.ensureDir(databaseDirectory);
this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json"));
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
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);
// 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));
}
/**
* 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)
app.use(express.static(path.join(__dirname, "..", "dist", "web")));
app.use(express.static("static")); // Used for data export files
// Add API routes
routes.addRouteHandlers(app);
// Add routes for the web interface
for (let route of routes.webRoutes) {
app.get(route, Master.serveWeb(route));
}
for (let pluginInfo of pluginInfos) {
for (let route of pluginInfo.routes || []) {
app.get(route, Master.serveWeb(route));
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web");
app.use(`/plugins/${pluginInfo.name}`, express.static(webPath));
}
}
slaveUpdated(slave) {
let update = {
agent: slave.agent,
version: slave.version,
id: slave.id,
name: slave.name,
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);
}
}
/**
* 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) {
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
continue;
}
let MasterPluginClass = libPlugin.BaseMasterPlugin;
try {
if (pluginInfo.masterEntrypoint) {
MasterPluginClass = await libPluginLoader.loadMasterPluginClass(pluginInfo);
}
let masterPlugin = new MasterPluginClass(pluginInfo, this, metrics, logger);
await masterPlugin.init();
this.plugins.set(pluginInfo.name, masterPlugin);
} catch (err) {
throw new libErrors.PluginError(pluginInfo.name, err);
}
logger.info(`Loaded plugin ${pluginInfo.name}`);
}
}
/**
* Calls listen on server capturing any errors that occurs
* binding to the port. Also adds handler for WebSocket
* upgrade event.
*
* @param {module:net.Server} server - Server to start the listening on.
* @param {*} args - Arguments to the .listen() call on the server.
* @returns {Promise} promise that resolves the server is listening.
*/
listen(server, ...args) {
return new Promise((resolve, reject) => {
server.on("upgrade", (req, socket, head) => {
logger.verbose("handling WebSocket upgrade");
this.wsServer.handleUpgrade(req, socket, head);
});
function wrapError(err) {
reject(new libErrors.StartupError(
`Server listening failed: ${err.message}`
));
}
server.once("error", wrapError);
server.listen(...args, () => {
server.off("error", wrapError);
resolve();
});
});
}
/**
* Returns the URL needed to connect to the master server.
*
* @returns {string} master URL.
*/
getMasterUrl() {
return Master.calculateMasterUrl(this.config);
}
static calculateMasterUrl(config) {
let url = config.get("master.external_address");
if (!url) {
if (config.get("master.https_port")) {
url = `https://localhost:${config.get("master.https_port")}/`;
} else {
url = `http://localhost:${config.get("master.http_port")}/`;
}
}
return url;
}
/**
* Servers the web interface with the root path set apropriately
*
* @param {string} route - route the interface is served under.
* @returns {function} Experess.js route handler.
*/
static serveWeb(route) {
// The depth is is the number of slashes in the route minus one, but due
// to lenient matching on trailing slashes in the route we need to
// compensate if the request path contains a slash but not the route,
// and vice versa.
let routeDepth = (route.match(/\//g) || []).length - 1 - (route.slice(-1) === "/");
return function(req, res, next) {
let depth = routeDepth + (req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
fs.readFile(path.join(__dirname, "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content.replace(/__WEB_ROOT__/g, webRoot));
}).catch(err => {
next(err);
});
};
}
/**
* Forward the given request to the slave contaning the instance
*
* Finds the slave which the instance with the given instance ID is
* currently assigned to and forwards the request to that instance.
* The request data must have an instance_id parameter.
*
* @param {module:lib/link.Request} request - The request to send.
* @param {object} data - Data to pass with the request.
* @param {number} data.instance_id -
* ID of instance to send this request towards
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.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;
+195
View File
@@ -0,0 +1,195 @@
"use strict";
const libConfig = require("@clusterio/lib/config");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const BaseConnection = require("./BaseConnection");
/**
* Represents the connection to a slave
*
* @extends module:master/src/BaseConnection
* @alias module:master/src/SlaveConnection
*/
class SlaveConnection extends BaseConnection {
constructor(registerData, connector, master) {
super("slave", connector, master);
this._agent = registerData.agent;
this._id = registerData.id;
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._master.slaves.set(this._id, {
agent: this._agent,
id: this._id,
name: this._name,
version: this._version,
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 } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
user = this._master.userManager.createUser(name);
}
if (type === "join") {
user.notifyJoin(instance_id);
} else if (type === "leave") {
user.notifyLeave(instance_id);
}
let instance = this._master.instances.get(instance_id);
await libPlugin.invokeHook(this._master.plugins, "onPlayerEvent", instance, message.data);
}
}
module.exports = SlaveConnection;
+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;
+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"] }
);
+435 -2
View File
@@ -1,6 +1,432 @@
"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 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");
plugins.push({ name, version: pluginInfo.version, enabled, loaded });
}
res.send(plugins);
}
function validateSlaveToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
jwt.verify(
token,
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",
];
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
let name = path.posix.basename(filePath);
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
}
res.sendStatus(200);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
let stream = {
id,
flowing: false,
size: null,
mime: null,
filename: null,
events: new events.EventEmitter(),
timeout: setTimeout(() => {
stream.events.emit("timeout");
}, app.locals.master.config.get("master.proxy_stream_timeout") * 1000),
};
stream.events.on("close", () => {
clearTimeout(stream.timeout);
app.locals.streams.delete(id);
});
stream.events.on("timeout", () => {
stream.events.emit("close");
});
app.locals.streams.set(id, stream);
return stream;
}
async function putStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
return;
}
stream.source = req;
stream.mime = req.get("Content-Type");
stream.size = req.get("Content-Length");
stream.events.emit("source");
stream.events.on("close", () => {
if (!stream.flowing) {
req.resume();
res.sendStatus(500);
} else {
res.sendStatus(200);
}
});
}
async function getStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
return;
}
function startStream() {
res.append("Content-Type", stream.mime);
if (stream.size) {
res.append("Content-Length", stream.size);
}
if (stream.filename) {
res.append("Content-Disposition", `attachment; filename="${stream.filename}"`);
} else {
res.append("Content-Disposition", "attachment");
}
stream.source.pipe(res);
stream.flowing = true;
res.on("finish", () => {
stream.events.emit("close");
});
clearTimeout(stream.timeout);
}
if (stream.source) {
startStream();
} else {
stream.events.on("source", startStream);
stream.events.on("timeout", () => {
res.sendStatus(500);
});
}
}
const zipMimes = [
"application/zip",
"application/x-zip-compressed",
];
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
} catch (err) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type");
let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let mimeType = match[1].toLowerCase();
let tasks = [];
let errors = [];
let requestErrors = [];
let saves = [];
async function handleFile(instanceId, stream, filename, streamMime) {
let proxyStream = await createProxyStream(req.app);
proxyStream.source = stream;
proxyStream.mime = streamMime;
let timeout = new Promise((_, reject) => {
proxyStream.events.on("timeout", () => {
stream.resume();
reject(new Error("Timed out establishing stream to slave"));
});
});
try {
let result = await Promise.race([
req.app.locals.master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: instanceId,
stream_id: proxyStream.id,
filename,
}),
timeout,
]);
saves.push(result.save);
} catch (err) {
proxyStream.events.emit("close");
logger.error(`Error uploading save: ${err.message}`);
errors.push(err.message);
stream.resume();
}
}
if (mimeType === "multipart/form-data") {
await new Promise(resolve => {
let fields = {};
let busboy = new Busboy({ headers: req.headers });
busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => {
if (fields.instanceId === undefined) {
requestErrors.push("instance_id must come before files uploaded");
}
if (!zipMimes.includes(fileMime)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(fields.instanceId, stream, filename, fileMime));
});
busboy.on("field", (fieldname, value, fieldnameTruncated, valueTruncated, transferEncoding, valueMime) => {
if (fieldname === "instance_id") {
fields.instanceId = Number.parseInt(value, 10);
if (Number.isNaN(fields.instanceId)) {
requestErrors.push("invalid instance_id");
}
}
});
busboy.on("finish", resolve);
busboy.on("error", (err) => {
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(busboy);
});
} else if (zipMimes.includes(mimeType)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
let instanceId = Number.parseInt(req.query.instance_id, 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("Missing or invalid instance_id parameter");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(instanceId, req, filename, mimeType));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ saves });
}
function addRouteHandlers(app) {
app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.put("/api/upload-export",
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req, res, next) => uploadSave(req, res).catch(next)
);
}
// Routes used in the web interface and served by the master server
module.exports = [
const webRoutes = [
"/",
"/master",
"/slaves",
@@ -11,5 +437,12 @@ module.exports = [
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/settings",
"/plugins",
"/plugins/:name/view",
];
module.exports = {
addRouteHandlers,
webRoutes,
createProxyStream,
};
+14 -13
View File
@@ -1,16 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clusterio</title>
<script>
window.webRoot = "__WEB_ROOT__";
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="__WEB_ROOT__bundle.js"></script>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2a2a2a" />
<title>Clusterio</title>
<script>
window.webRoot = new URL("__WEB_ROOT__", document.location).pathname;
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="__WEB_ROOT__bundle.js"></script>
</body>
</html>
+3 -1
View File
@@ -1 +1,3 @@
import("@clusterio/web_ui/src/bootstrap");
import("@clusterio/web_ui").then(webUi => {
return webUi.bootstrap();
}).catch(console.error);
+3 -1
View File
@@ -12,6 +12,7 @@ module.exports = (env = {}) => merge(common(env), {
contentBase: "./dist/web",
},
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "dist", "web"),
},
@@ -21,10 +22,11 @@ module.exports = (env = {}) => merge(common(env), {
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
"react-router": { singleton: true },
"react-router-dom": { singleton: true },
},
}),
],
+27 -45
View File
@@ -1,70 +1,56 @@
Clusterio Slave
===============
# Clusterio Slave
Node hosting Factorio servers in a Clusterio cluster. Clusterio slaves
connect to the master server and waits for commands from the master
server to start up and stop instances. A cluster can have any number of
slaves in it located on different computers, and each slave can host any
number of instances each of which is a Factorio server that talks with
the rest of the cluster.
Node hosting Factorio servers in a Clusterio cluster.
Clusterio slaves connect to the master server and waits for commands from the master server to start up and stop instances.
A cluster can have any number of slaves in it located on different computers, and each slave can host any number of instances each of which is a Factorio server that talks with the rest of the cluster.
Usage
-----
## Usage
npx clusterioslave <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the slave. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the slave.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
slave. Defaults to `config-slave.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for the slave.
Defaults to `config-slave.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the slave. The available
plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
Configure plugins available to be loaded by the slave.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioslave plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioslave plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -74,19 +60,17 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the slave configuration offline. This should only be used when
the slave is stopped, otherwise the config read might be out of date and
config changes will be overwritten when the slave shuts down.
Manage the slave configuration offline.
This should only be used when the slave is stopped, otherwise the config read might be out of date and config changes will be overwritten when the slave shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
See docs/configuration.md in the main repositiory for the available configuration.
#### `config show <config-entry>`
@@ -106,6 +90,4 @@ Runs the slave.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+40 -40
View File
@@ -4,63 +4,63 @@ local api = {}
local initialized = false
function api.init()
if initialized then
return
end
initialized = true
if initialized then
return
end
initialized = true
if remote.interfaces.clusterio_api then
api.events = remote.call("clusterio_api", "get_events")
else
local null_event = script.generate_event_name()
api.events = {
on_instance_updated = null_event,
on_server_startup = null_event,
}
end
if remote.interfaces.clusterio_api then
api.events = remote.call("clusterio_api", "get_events")
else
local null_event = script.generate_event_name()
api.events = {
on_instance_updated = null_event,
on_server_startup = null_event,
}
end
end
local function call_api(fn, ...)
if remote.interfaces.clusterio_api and remote.interfaces.clusterio_api[fn] then
return remote.call("clusterio_api", fn, ...)
else
return nil
end
if remote.interfaces.clusterio_api and remote.interfaces.clusterio_api[fn] then
return remote.call("clusterio_api", fn, ...)
else
return nil
end
end
function api.get_instance_name()
return call_api("get_instance_name")
return call_api("get_instance_name")
end
function api.get_instance_id()
return call_api("get_instance_id")
return call_api("get_instance_id")
end
function api.send_json(channel, data)
if not remote.interfaces.clusterio_api then
return
end
if not remote.interfaces.clusterio_api then
return
end
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = game.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
-- output about 1 MB of data at a time through stdout.
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
local file_no = remote.call("clusterio_api", "get_file_no")
local file_name = "clst_" .. file_no .. ".json"
game.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
-- output about 1 MB of data at a time through stdout.
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
local file_no = remote.call("clusterio_api", "get_file_no")
local file_name = "clst_" .. file_no .. ".json"
game.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
+22 -22
View File
@@ -1,24 +1,24 @@
{
"name": "clusterio_lib",
"variants": [
{
"version": "0.1.0",
"factorio_version": "0.17"
},
{
"version": "0.1.1",
"factorio_version": "0.18"
},
{
"version": "0.1.2",
"factorio_version": "1.1"
}
],
"title": "Clusterio Library (Alpha)",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
}
"name": "clusterio_lib",
"variants": [
{
"version": "0.1.0",
"factorio_version": "0.17"
},
{
"version": "0.1.1",
"factorio_version": "0.18"
},
{
"version": "0.1.2",
"factorio_version": "1.1"
}
],
"title": "Clusterio Library (Alpha)",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/clusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
local function send_json(channel, data)
data = game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
data = game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
script.on_init(function()
send_json("mod_list", game.active_mods)
send_json("mod_list", game.active_mods)
end)
+79 -76
View File
@@ -1,101 +1,104 @@
local item_types = {
-- Item categories
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"deconstruction-item",
"copy-paste-tool",
"upgrade-item",
"module",
"rail-planner",
"tool",
"armor",
"mining-tool",
"repair-tool",
-- Item categories
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"deconstruction-item",
"copy-paste-tool",
"upgrade-item",
"module",
"rail-planner",
"tool",
"armor",
"mining-tool",
"repair-tool",
-- Fluids
"fluid",
-- Fluids
"fluid",
}
local function is_array(t)
local count = 0
for k in pairs(t) do
if type(k) ~= "number" then
return false
end
count = count + 1
end
local count = 0
for k in pairs(t) do
if type(k) ~= "number" then
return false
end
count = count + 1
end
for i = 1, count do
if not t[i] then
return false
end
end
for i = 1, count do
if not t[i] then
return false
end
end
return count ~= 0
return count ~= 0
end
-- no game.table_to_json here :(
local function table_to_json(data)
if type(data) == "number" then
return string.format("%.17g", data)
if type(data) == "number" then
if data == math.huge then return "1e500" end -- Inf is not representable in JSON, but 1e500 will overflow to it.
if data == -math.huge then return "-1e500" end
if data ~= data then return "0" end -- NaN is not representable in JSON, treat as 0 instead.
return string.format("%.17g", data)
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\u00" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
elseif data == nil then
return "null"
elseif data == nil then
return "null"
elseif data == true then
return "true"
elseif data == false then
return "false"
elseif data == true then
return "true"
elseif data == false then
return "false"
elseif type(data) == "table" then
local r
if is_array(data) then
r = {"["}
for i=1, #data do
r[#r+1] = table_to_json(data[i])
r[#r+1] = ","
end
elseif type(data) == "table" then
local r
if is_array(data) then
r = {"["}
for i=1, #data do
r[#r+1] = table_to_json(data[i])
r[#r+1] = ","
end
-- overwrite last comma, empty table is an object
r[#r] = "]"
-- overwrite last comma, empty table is an object
r[#r] = "]"
else
r = {"{"}
for k, v in pairs(data) do
r[#r+1] = table_to_json(tostring(k))
r[#r+1] = ":"
r[#r+1] = table_to_json(v)
r[#r+1] = ","
end
r[#r + (r[#r] == "," and 0 or 1)] = "}"
end
else
r = {"{"}
for k, v in pairs(data) do
r[#r+1] = table_to_json(tostring(k))
r[#r+1] = ":"
r[#r+1] = table_to_json(v)
r[#r+1] = ","
end
r[#r + (r[#r] == "," and 0 or 1)] = "}"
end
return table.concat(r)
end
return table.concat(r)
end
end
local function send_json(channel, data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
for _, group in ipairs(item_types) do
for _, item in pairs(data.raw[group]) do
send_json("item_export", item)
end
for _, item in pairs(data.raw[group]) do
send_json("item_export", item)
end
end
+6 -6
View File
@@ -1,8 +1,8 @@
{
"name": "export",
"version": "0.0.0",
"title": "Icon exporter",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"description": "Exports item prototypes on stdout during the data stage."
"name": "export",
"version": "0.0.0",
"title": "Icon exporter",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/clusterio",
"description": "Exports item prototypes on stdout during the data stage."
}
+21 -21
View File
@@ -3,39 +3,39 @@ local api = {}
api.events = {
on_instance_updated = script.generate_event_name(),
on_server_startup = script.generate_event_name(),
on_instance_updated = script.generate_event_name(),
on_server_startup = script.generate_event_name(),
}
function api.get_instance_name()
return global.clusterio.instance_name
return global.clusterio.instance_name
end
function api.get_instance_id()
return global.clusterio.instance_id
return global.clusterio.instance_id
end
function api.send_json(channel, data)
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = game.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
-- output about 1 MB of data at a time through stdout.
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
local file_name = "clst_" .. global.clusterio_file_no .. ".json"
game.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
-- output about 1 MB of data at a time through stdout.
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
local file_name = "clst_" .. global.clusterio_file_no .. ".json"
game.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
+36 -36
View File
@@ -5,69 +5,69 @@ local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = function()
if global.clusterio_patch_number ~= clusterio_patch_number then
global.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {})
end
if global.clusterio_patch_number ~= clusterio_patch_number then
global.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {})
end
end
impl.events[api.events.on_server_startup] = function()
if not global.clusterio then
global.clusterio = {
instance_id = nil,
instance_name = nil,
}
end
if not global.clusterio then
global.clusterio = {
instance_id = nil,
instance_name = nil,
}
end
-- Replay will just desync after the save is patched
game.disable_replay()
-- Replay will just desync after the save is patched
game.disable_replay()
end
impl.events[defines.events.on_player_joined_game] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "join", name = player.name })
local player = game.players[event.player_index]
api.send_json("player_event", { type = "join", name = player.name })
end
impl.events[defines.events.on_player_left_game] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "leave", name = player.name })
local player = game.players[event.player_index]
api.send_json("player_event", { type = "leave", name = player.name })
end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
instance_id = new_id,
instance_name = new_name,
})
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
instance_id = new_id,
instance_name = new_name,
})
end
-- This is not part of the add_remote_interface callback to ensure it is
-- available when the clusterio_lib mod is loaded. The reason this is
-- available when the clusterio_lib mod is loaded. The reason this is
-- neccessary is that on_init for newly added mods happen before on_load
-- for existing mods, and the add_remote_interface callback is done in
-- on_load. See https://forums.factorio.com/viewtopic.php?f=25&t=81552 for
-- more details.
remote.add_interface('clusterio_api', {
get_events = function()
return api.events
end,
get_events = function()
return api.events
end,
get_instance_id = function()
return global.clusterio.instance_id
end,
get_instance_id = function()
return global.clusterio.instance_id
end,
get_instance_name = function()
return global.clusterio.instance_name
end,
get_instance_name = function()
return global.clusterio.instance_name
end,
get_file_no = function()
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
return global.clusterio_file_no
end,
get_file_no = function()
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
return global.clusterio_file_no
end,
})
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "clusterio",
"version": "0.0.0",
"dependencies": {},
"load": ["impl.lua"]
"name": "clusterio",
"version": "0.0.0",
"dependencies": {},
"load": ["impl.lua"]
}
+250 -218
View File
@@ -2,272 +2,304 @@
-- Based on code from playerManager and trainTeleports
local serialize = {}
-- 0.17 compatibility
local supports_bar, get_bar, has_bar
if (pcall(function() local mods = script.active_mods end)) then
supports_bar = "supports_bar"
get_bar = "get_bar"
set_bar = "set_bar"
else
supports_bar = "hasbar"
get_bar = "getbar"
set_bar = "setbar"
local function version_to_table(version)
local t = {}
for p in string.gmatch(version, "%d+") do
t[#t + 1] = tonumber(p)
end
return t
end
-- 0.17 compatibility
local supports_bar, get_bar, set_bar, version
if (pcall(function() local mods = script.active_mods end)) then
supports_bar = "supports_bar"
get_bar = "get_bar"
set_bar = "set_bar"
version = version_to_table(script.active_mods.base)
else
supports_bar = "hasbar"
get_bar = "getbar"
set_bar = "setbar"
version = version_to_table("0.17.69")
end
-- returns true if the game version is greater than or equal to the given version
local function version_ge(comp)
comp = version_to_table(comp)
for i=1, 3 do
if comp[i] > version[i] then
return false
elseif comp[i] < version[i] then
return true
end
end
return true
end
local has_create_grid = version_ge("1.1.7")
-- Equipment Grids are serialized into an array of equipment entries
-- where ench entry is a table with the following fields:
-- n: name
-- p: position (array of 2 numbers corresponding to x and y)
-- s: shield (optional)
-- e: energy (optional)
-- n: name
-- p: position (array of 2 numbers corresponding to x and y)
-- s: shield (optional)
-- e: energy (optional)
-- If the equipment is a burner the following is also present:
-- i: burner inventory
-- r: result inventory
-- b: curently burning (optional)
-- f: remaining_burning_fuel (optional)
-- i: burner inventory
-- r: result inventory
-- b: currently burning (optional)
-- f: remaining_burning_fuel (optional)
function serialize.serialize_equipment_grid(grid)
local serialized = {}
local processed = {}
for y = 0, grid.height - 1 do
for x = 0, grid.width - 1 do
local equipment = grid.get({x, y})
if equipment ~= nil then
local pos = equipment.position
local combined_pos = pos.x + pos.y * grid.width + 1
if not processed[combined_pos] then
processed[combined_pos] = true
local entry = {
n = equipment.name,
p = {pos.x, pos.y},
}
if equipment.shield > 0 then entry.s = equipment.shield end
if equipment.energy > 0 then entry.e = equipment.energy end
-- TODO: Test with Industrial Revolution
if equipment.burner then
local burner = equipment.burner
entry.i = serialize.serialize_inventory(burner.inventory)
entry.r = serialize.serialize_inventory(burner.burnt_result_inventory)
if burner.curently_burning then
entry.b = {}
serialize.serialize_item_stack(burner.curently_burning, entry.b)
entry.f = burner.remaining_burning_fuel
end
end
table.insert(serialized, entry)
end
end
end
end
return serialized
local serialized = {}
local processed = {}
for y = 0, grid.height - 1 do
for x = 0, grid.width - 1 do
local equipment = grid.get({x, y})
if equipment ~= nil then
local pos = equipment.position
local combined_pos = pos.x + pos.y * grid.width + 1
if not processed[combined_pos] then
processed[combined_pos] = true
local entry = {
n = equipment.name,
p = {pos.x, pos.y},
}
if equipment.shield > 0 then entry.s = equipment.shield end
if equipment.energy > 0 then entry.e = equipment.energy end
if equipment.burner then
local burner = equipment.burner
entry.i = serialize.serialize_inventory(burner.inventory)
entry.r = serialize.serialize_inventory(burner.burnt_result_inventory)
if burner.currently_burning then
entry.b = burner.currently_burning.name
entry.f = burner.remaining_burning_fuel
end
end
table.insert(serialized, entry)
end
end
end
end
return serialized
end
function serialize.deserialize_equipment_grid(grid, serialized)
grid.clear()
for _, entry in ipairs(serialized) do
local equipment = grid.put({
name = entry.n,
position = entry.p,
})
if equipment then
if entry.s then equipment.shield = entry.s end
if entry.e then equipment.energy = entry.e end
if entry.i then
if entry.b then serialize.deserialize_item_stack(burner.currently_burning, entry.b) end
if entry.f then burner.remaining_burning_fuel = entry.f end
serialize.deserialize_inventory(burner.burnt_result_inventory, entry.r)
serialize.deserialize_inventory(burner.inventory, entry.i)
end
end
end
grid.clear()
for _, entry in ipairs(serialized) do
local equipment = grid.put({
name = entry.n,
position = entry.p,
})
if equipment then
if entry.s then equipment.shield = entry.s end
if entry.e then equipment.energy = entry.e end
if entry.i then
local burner = equipment.burner
if entry.b then burner.currently_burning = entry.b end
if entry.f then burner.remaining_burning_fuel = entry.f end
serialize.deserialize_inventory(burner.burnt_result_inventory, entry.r)
serialize.deserialize_inventory(burner.inventory, entry.i)
end
end
end
end
-- Item stacks are serialized into a table with the following fields:
-- n: name
-- c: count
-- h: health (optional)
-- d: durability (optional)
-- a: ammo count (optional)
-- l: label (optional)
-- g: equipment grid (optional)
-- i: item inventory (optional)
-- n: name
-- c: count
-- h: health (optional)
-- d: durability (optional)
-- a: ammo count (optional)
-- l: label (optional)
-- g: equipment grid (optional)
-- i: item inventory (optional)
-- If the item stack is exportable it has the following property instead
-- e: export string
-- e: export string
-- Label is a table with the following fields:
-- t: label text (optional)
-- c: color (optional)
-- a: allow manual label change
-- t: label text (optional)
-- c: color (optional)
-- a: allow manual label change
function serialize.serialize_item_stack(slot, entry)
if
slot.is_blueprint
or slot.is_blueprint_book
or slot.is_upgrade_item
or slot.is_deconstruction_item
or slot.is_item_with_tags
then
local call_success, call_return = pcall(slot.export_stack)
if not call_success then
print("Error: '" .. call_return .. "' thrown exporting '" .. slot.name .. "'")
else
entry.e = call_return
end
if
slot.is_blueprint
or slot.is_blueprint_book
or slot.is_upgrade_item
or slot.is_deconstruction_item
or slot.is_item_with_tags
then
local call_success, call_return = pcall(slot.export_stack)
if not call_success then
print("Error: '" .. call_return .. "' thrown exporting '" .. slot.name .. "'")
else
entry.e = call_return
end
return
end
return
end
entry.n = slot.name
entry.c = slot.count
if slot.health < 1 then entry.h = slot.health end
if slot.durability then entry.d = slot.durability end
if slot.type == "ammo" then entry.a = slot.ammo end
if slot.is_item_with_label then
local label = {}
if slot.label then label.t = slot.label end
if slot.label_color then label.c = slot.label_color end
label.a = slot.allow_manual_label_change
entry.l = label
end
entry.n = slot.name
entry.c = slot.count
if slot.health < 1 then entry.h = slot.health end
if slot.durability then entry.d = slot.durability end
if slot.type == "ammo" then entry.a = slot.ammo end
if slot.is_item_with_label then
local label = {}
if slot.label then label.t = slot.label end
if slot.label_color then label.c = slot.label_color end
label.a = slot.allow_manual_label_change
entry.l = label
end
if slot.grid then
entry.g = serialize.serialize_equipment_grid(slot.grid)
end
if slot.grid then
entry.g = serialize.serialize_equipment_grid(slot.grid)
end
if slot.is_item_with_inventory then
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
entry.i = serialize.serialize_inventory(sub_inventory)
end
if slot.is_item_with_inventory then
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
entry.i = serialize.serialize_inventory(sub_inventory)
end
end
function serialize.deserialize_item_stack(slot, entry)
if entry.e then
local success = slot.import_stack(entry.e)
if success == 1 then
print("Error: import of '" .. entry.e .. "' succeeded with errors")
elseif success == -1 then
print("Error: import of '" .. entry.e .. "' failed")
end
if entry.e then
local success = slot.import_stack(entry.e)
if success == 1 then
print("Error: import of '" .. entry.e .. "' succeeded with errors")
elseif success == -1 then
print("Error: import of '" .. entry.e .. "' failed")
end
return
end
return
end
local item_stack = {
name = entry.n,
count = entry.c,
}
if entry.h then item_stack.health = entry.h end
if entry.d then item_stack.durability = entry.d end
if entry.a then item_stack.ammo = entry.a end
local item_stack = {
name = entry.n,
count = entry.c,
}
if entry.h then item_stack.health = entry.h end
if entry.d then item_stack.durability = entry.d end
if entry.a then item_stack.ammo = entry.a end
local call_success, call_return = pcall(slot.set_stack, item_stack)
if not call_success then
print("Error: '" .. call_return .. "' thrown setting stack ".. serpent.line(entry))
local call_success, call_return = pcall(slot.set_stack, item_stack)
if not call_success then
print("Error: '" .. call_return .. "' thrown setting stack ".. serpent.line(entry))
elseif not call_return then
print("Error: Failed to set stack " .. serpent.line(entry))
elseif not call_return then
print("Error: Failed to set stack " .. serpent.line(entry))
else
if entry.l then
-- TODO test this with AAI's unit-remote-control
local label = entry.l
if label.t then slot.label = label.t end
if label.c then slot.label_color = label.c end
slot.allow_manual_label_change = label.a
end
if entry.g then
serialize.deserialize_equipment_grid(slot.grid, entry.g)
end
if entry.i then
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
serialize.deserialize_inventory(sub_inventory, entry.i)
end
end
else
if entry.l then
-- TODO test this with AAI's unit-remote-control
local label = entry.l
if label.t then slot.label = label.t end
if label.c then slot.label_color = label.c end
slot.allow_manual_label_change = label.a
end
if entry.g then
if slot.grid then
serialize.deserialize_equipment_grid(slot.grid, entry.g)
elseif slot.type == "item-with-entity-data" and has_create_grid then
slot.create_grid()
serialize.deserialize_equipment_grid(slot.grid, entry.g)
else
print("Error: Attempt to deserialize equipment grid on an unsupported entity")
end
end
if entry.i then
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
serialize.deserialize_inventory(sub_inventory, entry.i)
end
end
end
-- Inventories are serialized into a table with the following fields:
-- i: array of item stack or exportable item entries
-- b: bar position (optional)
-- i: array of item stack or exportable item entries
-- b: bar position (optional)
-- Each item entry has the following fields
-- s: index (optional, equals to previous plus one if not present)
-- r: repeat count (optional)
-- f: slot filter (optional)
-- s: index (optional, equals to previous plus one if not present)
-- r: repeat count (optional)
-- f: slot filter (optional)
-- Pluss all the fields for item stacks (see deserialize_item_stack)
-- It's also possible that the slot is empty but has a slot filter.
function serialize.serialize_inventory(inventory)
local serialized = {}
if inventory[supports_bar]() and inventory[get_bar]() <= #inventory then
serialized.b = inventory[get_bar]()
end
local serialized = {}
if inventory[supports_bar]() and inventory[get_bar]() <= #inventory then
serialized.b = inventory[get_bar]()
end
serialized.i = {}
local previous_index = 0
local previous_serialized = nil
for i = 1, #inventory do
local item = {}
local slot = inventory[i]
if inventory.supports_filters() then
item.f = inventory.get_filter(i)
end
serialized.i = {}
local previous_index = 0
local previous_serialized = nil
for i = 1, #inventory do
local item = {}
local slot = inventory[i]
if inventory.supports_filters() then
item.f = inventory.get_filter(i)
end
if slot.valid_for_read then
serialize.serialize_item_stack(slot, item)
end
if slot.valid_for_read then
serialize.serialize_item_stack(slot, item)
end
if item.n or item.f or item.e then
local item_serialized = game.table_to_json(item)
if item_serialized == previous_serialized then
local previous_item = serialized.i[#serialized.i]
previous_item.r = (previous_item.r or 0) + 1
previous_index = i
if item.n or item.f or item.e then
local item_serialized = game.table_to_json(item)
if item_serialized == previous_serialized then
local previous_item = serialized.i[#serialized.i]
previous_item.r = (previous_item.r or 0) + 1
previous_index = i
else
if i ~= previous_index + 1 then
item.s = i
end
else
if i ~= previous_index + 1 then
item.s = i
end
previous_index = i
previous_serialized = item_serialized
table.insert(serialized.i, item)
end
previous_index = i
previous_serialized = item_serialized
table.insert(serialized.i, item)
end
else
-- Either an empty slot or serilization failed
previous_index = 0
previous_serialized = nil
end
end
else
-- Either an empty slot or serilization failed
previous_index = 0
previous_serialized = nil
end
end
return serialized
return serialized
end
function serialize.deserialize_inventory(inventory, serialized)
if serialized.b and inventory[supports_bar]() then
inventory[set_bar](serialized.b)
end
if serialized.b and inventory[supports_bar]() then
inventory[set_bar](serialized.b)
end
local last_slot_index = 0
for _, entry in ipairs(serialized.i) do
local base_index = entry.s or last_slot_index + 1
local last_slot_index = 0
for _, entry in ipairs(serialized.i) do
local base_index = entry.s or last_slot_index + 1
local repeat_count = entry.r or 0
for offset = 0, repeat_count do
-- XXX what if the inventory is smaller on this instance?
local index = base_index + offset
local slot = inventory[index]
if entry.f then
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
if not call_success then
print("Error: '" .. call_return .. "' thrown setting filter " .. entry.f)
local repeat_count = entry.r or 0
for offset = 0, repeat_count do
-- XXX what if the inventory is smaller on this instance?
local index = base_index + offset
local slot = inventory[index]
if entry.f then
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
if not call_success then
print("Error: '" .. call_return .. "' thrown setting filter " .. entry.f)
elseif not call_return then
print("Error: Failed to set filter " .. entry.f)
end
end
elseif not call_return then
print("Error: Failed to set filter " .. entry.f)
end
end
if entry.n or entry.e then
serialize.deserialize_item_stack(slot, entry)
end
end
last_slot_index = base_index + repeat_count
end
if entry.n or entry.e then
serialize.deserialize_item_stack(slot, entry)
end
end
last_slot_index = base_index + repeat_count
end
end
+5 -4
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.4",
"version": "2.0.0-alpha.12",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,15 +15,16 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.4",
"@clusterio/lib": "^2.0.0-alpha.12",
"fs-extra": "^8.1.0",
"phin": "^3.4.1",
"phin": "^3.5.1",
"pidusage": "^2.0.18",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.5",
"yargs": "^14.2.0"
},
"publishConfig": {
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more