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
200 changed files with 10046 additions and 3152 deletions

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ module.exports = {
},
},
{
"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,
},
-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 -2
View File
@@ -1,7 +1,6 @@
# Logs
logs
*.log
npm-debug.log*
*debug.log*
# Runtime data
pids
-15
View File
@@ -1,15 +0,0 @@
language: node_js
node_js:
- 12
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 --hoist --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
+4 -3
View File
@@ -19,7 +19,7 @@
- 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, upload, download, delete and load saves for instances.
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
### Features
@@ -44,6 +44,7 @@
- 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
@@ -186,8 +187,8 @@
- 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
+119 -136
View File
@@ -4,20 +4,16 @@
<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:
@@ -32,17 +28,17 @@ Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install
* [Introduction](#introduction)
* [Features](#features)
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-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)
@@ -50,7 +46,7 @@ Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install
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).
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.
@@ -76,8 +72,9 @@ 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.
- [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.
There's also plugins developed and maintained by the community:
@@ -90,109 +87,94 @@ 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.
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.
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.
-->
## 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.
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
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.
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 .
### Installing Plugins
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.
## MacOS Setup
**Requirements**
Download and install nodeJS 12 from http://nodejs.org or brew (`brew install node`).
Clusterio runs on Node.js v12 and up.
1. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
```bash
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
```
2. Obtain Factorio:
Install Factorio and copy the factorio.app/Contents in your clusterio install folder as factorio.
If you installed factorio on your mac you can run
```bash
cp -r /Applications/factorio.app/Contents/ factorio
```
## Installing Plugins
Installing plugins to make them work with Clusterio consists of two steps.
First install the package via npm, for example
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
@@ -202,7 +184,7 @@ Then tell clusterio that this plugin exists by adding it as a plugin.
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.
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).
@@ -214,6 +196,11 @@ 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
@@ -226,67 +213,63 @@ You can list the config of the master server with the `npx clusteriomaster confi
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
+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

+16 -14
View File
@@ -86,19 +86,20 @@ 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.
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
@@ -197,12 +198,12 @@ This is used by plugins like Server Select to give the correct address to switch
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
@@ -399,8 +400,9 @@ If you have a self signed certificate on the master server you will need to copy
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.
+35 -13
View File
@@ -41,7 +41,7 @@ If you already cloned the main GitHub repository you can change your clone to po
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!
@@ -54,25 +54,47 @@ In order to get the development environment up and running you will need to run
npm install
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.
To start a cluster from the repo, substitute the commands in the normal readme as follows:
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
For web development on the master there are also the following flags:
Note that to use `clusterioctl` you will have to create a control config first:
`--dev` - Start the master with webpack hot reloading of core components
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.
`--dev-plugin [name]` - Start the master with hot reloading of a plugin
### 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).
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
@@ -113,7 +135,7 @@ If there are more changes you want to have merged or mistakes you need to fix yo
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)
The base usage of rebase assuming you are checked out on your feature branch is the following command
git rebase -i upstream/master
@@ -132,7 +154,7 @@ If you have a pull request that has not yet been merged submitted for this featu
### 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)
Run the following commands
git fetch upstream
git rebase upstream/master
@@ -161,11 +183,11 @@ The format of this log should be self explanatory, please follow it carefully.
## Supported Node.js and Factorio Versions
Clusterio run on Node.js v12 and up, 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 v12 is the default.
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.
The GitHub Actions tests are run against the latest experimental release.
## Code Style
@@ -202,9 +224,9 @@ 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
+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.
+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"
}
}
+91 -1
View File
@@ -10,13 +10,76 @@ The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-c
## Slaves
To be written.
### 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
ctl> instance config edit <instance> [editor]
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
ctl> instace assign <instance> [slave]
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
@@ -74,6 +137,33 @@ Since ban status is a part of the account data the account must exist for this t
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
-225
View File
@@ -1,225 +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.
+6 -6
View File
@@ -49,7 +49,7 @@ Without it the plugin will not recognized by Clusterio.
Here's an example of it:
```js
const libLink = require("@clusterio/lib/link"); // For messages
const { libLink } = require("@clusterio/lib"); // For messages
module.exports = {
name: "foo_frobber",
@@ -502,11 +502,11 @@ This is signaled to `MasterPlugin` classes via the `onPrepareSlaveDisconnect` ho
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` or `closing` property of the connector for the master/slave connection.
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is closing by using the following code:
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `SlaveConnection` class and `Slave` class on the master and slave respectively.
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
```js
if (!this.slave.connector.closing) {
if (this.slave.connected) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
}
```
@@ -517,8 +517,8 @@ The re-establishement of the connection is notified to plugins via the `connect
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onMasterConnectionEvent` for `InstancePlugin` classes and through `onSlaveConnectionEvent` for `MasterPlugin` classes.
This is raised when the connection between the master and slave in question is lost, most likely due to networking issues.
When in the dropped state the slave will keep trying to reconnect to the master server in order to re-establish it, and if successful no events or requests will be lost.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is re-established.
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer for a long time and sent out all at once.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer and sent out all at once the connection is re-estabished.
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
+1 -1
View File
@@ -4,5 +4,5 @@
"plugins/*",
"test"
],
"version": "2.0.0-alpha.7"
"version": "2.0.0-alpha.12"
}
+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"
}
}
+1 -1
View File
@@ -88,4 +88,4 @@ Lists up all configuration entries with their currently configured values.
[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.
+312 -34
View File
@@ -14,6 +14,10 @@ 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");
@@ -28,12 +32,68 @@ 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) {
// eslint-disable-next-line no-console
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({
@@ -104,6 +164,66 @@ 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);
@@ -207,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 });
},
}));
@@ -300,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({
@@ -340,8 +533,11 @@ async function loadMapSettings(args) {
};
}
instanceCommands.add(new libCommand.Command({
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
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) {
@@ -355,8 +551,8 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
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({
@@ -370,7 +566,7 @@ instanceCommands.add(new libCommand.Command({
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,
@@ -380,8 +576,40 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
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({
@@ -418,7 +646,7 @@ instanceCommands.add(new libCommand.Command({
}
for (let requestError of result.body.request_errors || []) {
logger.error(error);
logger.error(requestError);
}
if (result.body.saves && result.body.saves.length) {
@@ -431,8 +659,36 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["download-save <instance> <save>", "Download a save from an instance", (yargs) => {
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" });
}],
@@ -451,12 +707,12 @@ instanceCommands.add(new libCommand.Command({
stream: true,
});
let stream;
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
stream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(stream, "open");
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
@@ -466,8 +722,8 @@ instanceCommands.add(new libCommand.Command({
}
}
}
response.pipe(stream);
await events.once(stream, "finish");
response.pipe(writeStream);
await finished(writeStream);
let filename = await libFileOps.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
@@ -476,8 +732,8 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["delete-save <instance> <save>", "Delete a save from an instance", (yargs) => {
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" });
}],
@@ -489,6 +745,7 @@ instanceCommands.add(new libCommand.Command({
});
},
}));
instanceCommands.add(instanceSaveCommands);
instanceCommands.add(new libCommand.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
@@ -497,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,
});
},
@@ -514,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,
});
@@ -538,7 +795,7 @@ instanceCommands.add(new libCommand.Command({
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
let response = await libLink.messages.loadScenario.send(control, {
await libLink.messages.loadScenario.send(control, {
instance_id: instanceId,
scenario: args.scenario,
seed,
@@ -556,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,
});
},
@@ -567,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),
});
},
@@ -819,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) {
@@ -835,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,
@@ -866,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",
@@ -919,6 +1197,8 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
@@ -946,8 +1226,6 @@ class Control extends libLink.Link {
}
async shutdown() {
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
@@ -956,7 +1234,7 @@ class Control extends libLink.Link {
}
}
await this.connector.close(1001, "Control Quit");
await this.connector.close(1000, "Control Quit");
}
}
@@ -1120,7 +1398,7 @@ 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")
);
@@ -1185,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 {
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.7",
"version": "2.0.0-alpha.12",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.7",
"@clusterio/lib": "^2.0.0-alpha.12",
"as-table": "^1.0.31",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
+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;
+1 -1
View File
@@ -2,4 +2,4 @@
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,
+18 -18
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,35 +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",
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,
@@ -184,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();
@@ -358,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();
@@ -376,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`
);
}
+3 -2
View File
@@ -8,6 +8,7 @@
"use strict";
const fs = require("fs-extra");
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));
}
+44 -5
View File
@@ -2,10 +2,10 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const ini = require("ini");
const Jimp = require("jimp");
const JSZip = require("jszip");
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
/**
@@ -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");
+3 -1
View File
@@ -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 = {
+50 -109
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("../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
*
@@ -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
@@ -561,9 +488,7 @@ class FactorioServer extends events.EventEmitter {
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
this._stdout = new LineSplitter((line) => { this._handleOutput(line, "stdout"); });
this._stderr = new LineSplitter((line) => { this._handleOutput(line, "stderr"); });
this._killed = false;
// Track autosaving
this._runningAutosave = null;
@@ -576,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");
}
});
}
@@ -605,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");
@@ -685,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
@@ -756,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- ")}`
));
@@ -862,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}`);
}
@@ -1074,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));
}
/**
@@ -1163,23 +1105,23 @@ 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 fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
);
}
if (mapSettings) {
await fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
);
}
@@ -1196,6 +1138,5 @@ module.exports = {
_findVersion: findVersion,
_randomDynamicPort: randomDynamicPort,
_generatePassword: generatePassword,
_LineSplitter: LineSplitter,
_parseOutput: parseOutput,
};
+42 -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
*
@@ -68,8 +68,49 @@ async function findUnusedName(directory, name, extension = "") {
}
}
}
/**
* 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,
};
+176 -105
View File
@@ -6,6 +6,7 @@ const WebSocket = require("isomorphic-ws");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const ExponentialBackoff = require("../ExponentialBackoff");
/**
@@ -15,9 +16,14 @@ const { logger } = require("../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;
}
@@ -91,6 +120,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
if (this._heartbeatId) {
clearInterval(this._heartbeatId);
this._heartbeatId = null;
this._lastHeartbeat = null;
}
}
@@ -102,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;
@@ -118,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");
}
/**
@@ -136,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);
}
}
@@ -147,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();
}
/**
@@ -194,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 }));
}
@@ -207,34 +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._lastReceivedSeq = null;
this._sessionToken = null;
this._sendBuffer.length = 0;
this._state = "new";
this._connected = false;
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();
@@ -242,12 +290,12 @@ 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 node/no-process-env
if (process.env.APP_ENV === "browser") {
@@ -266,69 +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._state = "new";
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 ([
@@ -337,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;
}
@@ -372,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,
@@ -392,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", 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();
}
+1 -2
View File
@@ -4,7 +4,6 @@
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const messages = require("./messages");
// 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;
+75
View File
@@ -337,6 +337,7 @@ let slaveProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
"public_address": { type: ["null", "string"] },
};
messages.listSlaves = new Request({
@@ -372,6 +373,7 @@ 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",
@@ -548,6 +550,28 @@ messages.createSave = new Request({
},
});
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"],
@@ -571,6 +595,22 @@ messages.downloadSave = new Request({
},
});
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"],
@@ -634,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"],
@@ -859,6 +906,8 @@ messages.queryLog = new Request({
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
"limit": { type: "integer" },
"order": { enum: ["asc", "desc"] },
},
responseProperties: {
"log": {
@@ -1083,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"],
@@ -1119,11 +1192,13 @@ 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", "stopping", "creating_save", "exporting_data",
]},
"game_port": { type: ["null", "integer"] },
},
});
+393
View File
@@ -1,4 +1,15 @@
/**
* 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");
@@ -6,6 +17,11 @@ const chalk = require("chalk");
const libLink = require("./link");
const libErrors = require("./errors");
const libFileOps = require("./file_ops");
const { levels, logger } = require("./logging");
const libStream = require("./stream");
const finished = util.promisify(stream.finished);
/**
@@ -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,
+3 -3
View File
@@ -1,9 +1,10 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.7",
"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"
],
@@ -14,7 +15,6 @@
"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",
+136 -7
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.
*/
@@ -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;
}
}
/**
@@ -256,6 +323,7 @@ class BaseMasterPlugin {
* 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.
@@ -270,7 +338,8 @@ class BaseMasterPlugin {
* 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
@@ -286,6 +355,19 @@ class BaseMasterPlugin {
*/
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.
*
@@ -294,7 +376,7 @@ class BaseMasterPlugin {
*
* @param {Object} instance - The instance the config changed on.
* @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.
*/
@@ -506,11 +588,14 @@ class BaseControlPlugin {
/**
* Plugin supplied pages
* @typedef {Object} module:Lib/plugin~Page
* @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.
@@ -571,6 +656,42 @@ class BaseWebPlugin {
* @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 = {};
}
/**
@@ -665,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) {
+23 -3
View File
@@ -98,15 +98,33 @@ 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"],
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" },
},
},
},
},
},
},
},
@@ -118,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" },
},
},
@@ -159,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" },
+4 -4
View File
@@ -3,10 +3,10 @@
* @module lib/shared_commands
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
const libFileOps = require("./file_ops");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
@@ -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,
};
+42 -22
View File
@@ -236,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"));
}
/**
@@ -379,34 +379,49 @@ definePermission({
});
definePermission({
name: "core.instance.save.list",
title: "List saves on instance",
title: "List saves",
description: "List the saves currently on the instance.",
});
definePermission({
name: "core.instance.save.create",
title: "Create new instance save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.delete",
title: "Delete instance save",
description: "Delete savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save to instance",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.download",
title: "Download save from instance",
description: "Download savegames from instances.",
});
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",
@@ -427,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 -1
View File
@@ -111,4 +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.
+61 -23
View File
@@ -22,10 +22,12 @@ const setBlocking = require("set-blocking");
const yargs = require("yargs");
const util = require("util");
const winston = require("winston");
require("winston-daily-rotate-file");
const jwt = require("jsonwebtoken");
// homebrew modules
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const libConfig = require("@clusterio/lib/config");
@@ -41,18 +43,18 @@ const UserManager = require("./src/UserManager");
let master;
const slaveMappingGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_slave_mapping",
"Mapping of Slave ID to name",
{
labels: ["slave_id", "slave_name"],
callback: function() {
slaveMappingGauge.clear();
callback: function(gauge) {
gauge.clear();
if (!master || !master.slaves) {
return;
}
for (let [id, slave] of master.slaves) {
slaveMappingGauge.labels({
gauge.labels({
slave_id: String(id),
slave_name: slave.name,
}).set(1);
@@ -61,18 +63,18 @@ const slaveMappingGauge = new libPrometheus.Gauge(
}
);
const instanceMappingGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_instance_mapping",
"Mapping of Instance ID to name and slave",
{
labels: ["instance_id", "instance_name", "slave_id"],
callback: function() {
instanceMappingGauge.clear();
callback: function(gauge) {
gauge.clear();
if (!master || !master.instances) {
return;
}
for (let [id, instance] of master.instances) {
instanceMappingGauge.labels({
gauge.labels({
instance_id: String(id),
instance_name: String(instance.config.get("instance.name")),
slave_id: String(instance.config.get("instance.assigned_slave")),
@@ -82,19 +84,19 @@ const instanceMappingGauge = new libPrometheus.Gauge(
}
);
const wsActiveConnectionsGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_websocket_active_connections",
"How many WebSocket connections are currently open to the master server",
{ callback: function(gauge) { gauge.set(master.wsServer.activeConnectors.size); }}
);
const wsActiveSlavesGauge = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_active_slaves",
"How many slaves are currently connected to the master",
{ callback: function(gauge) { gauge.set(master.wsServer.slaveConnections.size); }}
);
const masterConnectedClientsCount = new libPrometheus.Gauge(
void new libPrometheus.Gauge(
"clusterio_master_connected_clients_count", "How many clients are currently connected to this master server",
{
labels: ["type"], callback: async function(gauge) {
@@ -138,7 +140,10 @@ async function handleBootstrapCommand(args, masterConfig) {
} else if (subCommand === "generate-slave-token") {
// eslint-disable-next-line no-console
console.log(jwt.sign({ aud: "slave", slave: args.id }, masterConfig.get("master.auth_secret")));
console.log(jwt.sign(
{ aud: "slave", slave: args.id },
Buffer.from(masterConfig.get("master.auth_secret"), "base64")
));
} else if (subCommand === "create-ctl-config") {
let admin = userManager.users.get(args.name);
@@ -159,7 +164,7 @@ async function handleBootstrapCommand(args, masterConfig) {
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await fs.outputFile(args.output, content);
await libFileOps.safeOutputFile(args.output, content);
}
}
}
@@ -185,6 +190,12 @@ async function initialize() {
choices: ["none"].concat(Object.keys(levels)),
type: "string",
})
.option("log-directory", {
nargs: 1,
describe: "Directory to place logs in",
default: "logs",
type: "string",
})
.option("config", {
nargs: 1,
describe: "master config file to use",
@@ -223,20 +234,45 @@ async function initialize() {
.argv
;
{
// Migration from alpha-10 single file logs, note that we can't use
// the logger here as it's not initalized yet.
/* eslint-disable no-console */
let clusterLogDirectory = path.join(parameters.args.logDirectory, "cluster");
if (!await fs.pathExists(clusterLogDirectory) && await fs.pathExists("cluster.log")) {
console.log("Migrating cluster log...");
await fs.ensureDir(clusterLogDirectory);
await libLoggingUtils.migrateLogs("cluster.log", clusterLogDirectory, "cluster-%DATE%.log");
console.log("Migration complete, you should delete cluster.log now");
}
let masterLogDirectory = path.join(parameters.args.logDirectory, "master");
if (!await fs.pathExists(masterLogDirectory) && await fs.pathExists("master.log")) {
console.log("Migrating master log...");
await fs.ensureDir(masterLogDirectory);
await libLoggingUtils.migrateLogs("master.log", masterLogDirectory, "master-%DATE%.log");
console.log("Migration complete, you should delete master.log now");
}
/* eslint-enable no-console */
}
// Combined log stream of the whole cluster.
parameters.clusterLogger = winston.createLogger({
format: winston.format.json(),
level: "verbose",
levels,
});
parameters.clusterLogger.add(new winston.transports.File({
filename: "cluster.log",
parameters.clusterLogger.add(new winston.transports.DailyRotateFile({
filename: "cluster-%DATE%.log",
utc: true,
dirname: path.join(parameters.args.logDirectory, "cluster"),
}));
// Log stream for the master server.
logger.add(new winston.transports.File({
logger.add(new winston.transports.DailyRotateFile({
format: winston.format.json(),
filename: "master.log",
filename: "master-%DATE%.log",
dirname: path.join(parameters.args.logDirectory, "master"),
}));
logger.add(new winston.transports.Stream({
stream: parameters.clusterLogger,
@@ -291,7 +327,9 @@ async function initialize() {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
parameters.masterConfig.set("master.auth_secret", bytes.toString("base64"));
await fs.outputFile(parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4));
await libFileOps.safeOutputFile(
parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4)
);
}
if (command === "config") {
@@ -318,7 +356,7 @@ async function startup() {
// add better stack traces on promise rejection
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
let { args, shouldRun, clusterLogger, pluginInfos, masterConfigPath, masterConfig } = await initialize(master);
let { args, shouldRun, clusterLogger, pluginInfos, masterConfigPath, masterConfig } = await initialize();
if (!shouldRun) {
return;
}
@@ -389,10 +427,10 @@ ${err.original.stack}`
);
} else {
logger.fatal(`
+---------------------------------------------------------------+
| Unexpected error occured while starting master, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+---------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting master, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.7",
"version": "2.0.0-alpha.12",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env production",
@@ -19,21 +19,21 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.7",
"@clusterio/lib": "^2.0.0-alpha.12",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"express": "^4.16.2",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"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.7",
"@clusterio/web_ui": "^2.0.0-alpha.12",
"fast-deep-equal": "^3.1.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+12
View File
@@ -16,6 +16,7 @@ class BaseConnection extends libLink.Link {
/** @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);
@@ -57,11 +58,13 @@ class BaseConnection extends libLink.Link {
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) {
@@ -72,6 +75,15 @@ class BaseConnection extends libLink.Link {
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;
+109 -80
View File
@@ -1,12 +1,11 @@
"use strict";
const jwt = require("jsonwebtoken");
const util = require("util");
const winston = require("winston");
const events = require("events");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const { levels, logger } = require("@clusterio/lib/logging");
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");
@@ -106,6 +105,7 @@ class ControlConnection extends BaseConnection {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this._master.wsServer.slaveConnections.has(slave.id),
});
}
@@ -126,7 +126,10 @@ class ControlConnection extends BaseConnection {
}
generateSlaveToken(slaveId) {
return jwt.sign({ aud: "slave", slave: slaveId }, this._master.config.get("master.auth_secret"));
return jwt.sign(
{ aud: "slave", slave: slaveId },
Buffer.from(this._master.config.get("master.auth_secret"), "base64")
);
}
async generateSlaveTokenRequestHandler(message) {
@@ -157,15 +160,13 @@ class ControlConnection extends BaseConnection {
async getInstanceRequestHandler(message) {
let id = message.data.id;
let instance = this._master.instances.get(id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${id} does not exist`);
}
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,
};
}
@@ -177,6 +178,7 @@ class ControlConnection extends BaseConnection {
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,
});
}
@@ -196,6 +198,7 @@ class ControlConnection extends BaseConnection {
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,
});
}
@@ -244,11 +247,7 @@ class ControlConnection extends BaseConnection {
}
async deleteInstanceRequestHandler(message, request) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
if (instance.config.get("instance.assigned_slave") !== null) {
await this.forwardRequestToInstance(message, request);
}
@@ -261,11 +260,7 @@ class ControlConnection extends BaseConnection {
}
async getInstanceConfigRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(message.data.instance_id);
return {
serialized_config: instance.config.serialize("control"),
};
@@ -285,11 +280,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigFieldRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
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");
}
@@ -304,11 +295,7 @@ class ControlConnection extends BaseConnection {
}
async setInstanceConfigPropRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
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);
@@ -316,10 +303,7 @@ class ControlConnection extends BaseConnection {
async assignInstanceCommandRequestHandler(message, request) {
let { slave_id, instance_id } = message.data;
let instance = this._master.instances.get(instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let instance = this._master.getRequestInstance(instance_id);
// Check if target slave is connected
let newSlaveConnection;
@@ -380,7 +364,7 @@ class ControlConnection extends BaseConnection {
});
ready.catch(() => {});
let result = await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save,
@@ -390,34 +374,90 @@ class ControlConnection extends BaseConnection {
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();
}
static 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 (info.slave_id && slave_ids.includes(info.slave_id)) {
return true;
}
if (info.instance_id && instance_ids.includes(info.instance_id)) {
return true;
}
return false;
};
}
updateLogSubscriptions() {
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
if (all || master || slave_ids.length || instance_ids.length) {
@@ -425,7 +465,7 @@ class ControlConnection extends BaseConnection {
this.logTransport = new libLoggingUtils.LinkTransport({ link: this });
this._master.clusterLogger.add(this.logTransport);
}
this.logTransport.filter = this.constructor.logFilter(this.logSubscriptions);
this.logTransport.filter = libLoggingUtils.logFilter(this.logSubscriptions);
} else if (this.logTransport) {
this._master.clusterLogger.remove(this.logTransport);
@@ -434,31 +474,16 @@ class ControlConnection extends BaseConnection {
}
async queryLogRequestHandler(message) {
let transport = new winston.transports.File({ filename: "cluster.log" });
let query = util.promisify((options, callback) => transport.query(options, callback));
// Available options and defaults inferred from reading the source
// rows: number = limit || 10
// limit: number // alias of rows.
// start: number = 0
// until: Date = now
// from: Date = until - 24h
// order: "asc"|"desc" = "desc"
// fields: Array<string>
// level: string
// This interface is junk:
// - The level option filters by exact match
// - No way to add your own filter
// - No way to stream results
// - The log file is read starting from the beginning
let setDuration = lastQueryLogTime.startTimer();
let log = await query({
order: "asc",
limit: Infinity,
from: new Date(0),
});
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);
}
log = log.filter(this.constructor.logFilter(message.data));
setDuration();
return { log };
}
@@ -507,6 +532,7 @@ class ControlConnection extends BaseConnection {
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._master.rolePermissionsUpdated(role);
}
async grantDefaultRolePermissionsRequestHandler(message) {
@@ -516,6 +542,7 @@ class ControlConnection extends BaseConnection {
}
role.grantDefaultPermissions();
this._master.rolePermissionsUpdated(role);
}
async deleteRoleRequestHandler(message) {
@@ -528,6 +555,7 @@ class ControlConnection extends BaseConnection {
this._master.userManager.roles.delete(id);
for (let user of this._master.userManager.users.values()) {
user.roles.delete(role);
this._master.userPermissionsUpdated(user);
}
}
@@ -567,6 +595,7 @@ class ControlConnection extends BaseConnection {
}
user.roles = resolvedRoles;
this._master.userPermissionsUpdated(user);
}
async setUserAdminRequestHandler(message) {
-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
+104 -9
View File
@@ -10,6 +10,9 @@ 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");
@@ -94,7 +97,7 @@ class Master {
}
async start(args) {
if (!this._state === "new") {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
}
@@ -115,6 +118,14 @@ class Master {
}
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");
@@ -151,6 +162,9 @@ class Master {
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);
}
@@ -238,9 +252,13 @@ class Master {
// 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 fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
@@ -296,7 +314,7 @@ class Master {
}
static async saveSlaves(filePath, slaves) {
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
}
static async loadInstances(filePath) {
@@ -328,7 +346,34 @@ class Master {
serialized.push(instance.config.serialize());
}
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
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) {
@@ -366,6 +411,7 @@ class Master {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this.wsServer.slaveConnections.has(slave.id),
};
@@ -378,6 +424,21 @@ class Master {
}
}
/**
* 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") {
@@ -410,6 +471,44 @@ class Master {
}
}
/**
* 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")) {
@@ -524,11 +623,7 @@ class Master {
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.instances.get(data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`);
}
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");
+16
View File
@@ -28,6 +28,7 @@ class SlaveConnection extends BaseConnection {
id: this._id,
name: this._name,
version: this._version,
public_address: registerData.public_address,
plugins: registerData.plugins,
});
@@ -39,6 +40,20 @@ class SlaveConnection extends BaseConnection {
}
});
}
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);
}
});
}
/**
@@ -73,6 +88,7 @@ class SlaveConnection extends BaseConnection {
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);
+2 -1
View File
@@ -1,6 +1,7 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("@clusterio/lib/file_ops");
const libUsers = require("@clusterio/lib/users");
/**
@@ -62,7 +63,7 @@ class UserManager {
users: serializedUsers,
roles: serializedRoles,
};
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
/**
+26 -17
View File
@@ -2,6 +2,7 @@
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");
@@ -64,12 +65,10 @@ class WsServer {
let disconnectTasks = [];
for (let controlConnection of this.controlConnections) {
controlConnection.connector.setTimeout(this.master.config.get("master.connector_shutdown_timeout"));
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
}
for (let slaveConnection of this.slaveConnections.values()) {
slaveConnection.connector.setTimeout(this.master.config.get("master.connector_shutdown_timeout"));
disconnectTasks.push(slaveConnection.disconnect(1001, "Server Quit"));
}
@@ -137,7 +136,7 @@ class WsServer {
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
socket.terminate();
this.pendingSockets.delete(socket);
}, 30*1000);
@@ -148,10 +147,10 @@ class WsServer {
message, socket, req
).catch(err => {
logger.error(`
+----------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
@@ -177,14 +176,14 @@ ${err.stack}`
return;
}
let { seq, type, data } = message;
let { type, data } = message;
if (type === "resume") {
let connector;
try {
let payload = jwt.verify(
data.session_token,
this.master.config.get("master.auth_secret"),
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
@@ -216,7 +215,7 @@ ${err.stack}`
if (type === "register_slave") {
let tokenPayload = jwt.verify(
data.token,
this.master.config.get("master.auth_secret"),
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: "slave" }
);
@@ -227,7 +226,7 @@ ${err.stack}`
} else if (type === "register_control") {
let tokenPayload = jwt.verify(
data.token,
this.master.config.get("master.auth_secret"),
Buffer.from(this.master.config.get("master.auth_secret"), "base64"),
{ audience: "user" }
);
@@ -251,13 +250,15 @@ ${err.stack}`
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId }, this.master.config.get("master.auth_secret")
{ 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(socket, sessionId, heartbeatInterval);
let connector = new WsServerConnector(sessionId, sessionTimeout, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(connector);
this.activeConnectors.delete(sessionId);
});
let additionalReadyData = {};
@@ -265,7 +266,6 @@ ${err.stack}`
let connection = this.slaveConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
connection.connector.setTimeout(15); // Slave connection is likely stalled
await connection.disconnect(1008, "Registered from another connection");
}
@@ -275,6 +275,8 @@ ${err.stack}`
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);
@@ -285,18 +287,25 @@ ${err.stack}`
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(this);
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(sessionToken, additionalReadyData);
connector.ready(socket, sessionToken, additionalReadyData);
}
}
+57 -62
View File
@@ -12,28 +12,24 @@ const { logger } = require("@clusterio/lib/logging");
* @alias module:master/src/WsServerConnector
*/
class WsServerConnector extends libLink.WebSocketBaseConnector {
constructor(socket, sessionId, heartbeatInterval) {
super();
constructor(sessionId, sessionTimeout, heartbeatInterval) {
super(sessionTimeout);
this._socket = socket;
this._socket = null;
this._sessionId = sessionId;
this._heartbeatInterval = heartbeatInterval;
this._timeoutId = null;
// The following states are used in the server connector
// handshake: Waiting for client to (re)connect.
// closed: Connection is closed
// connected: Connection is online
// closing: Connection is in the process of being closed.
// closed: Connection has been closed
this._state = "handshake";
this._timeout = 15 * 60;
// resuming: Waiting for client to resume.
}
_check(expectedStates) {
if (!expectedStates.includes(this._state)) {
throw new Error(
`Expected state ${expectedStates} but state is ${this._state}`
);
}
_reset() {
clearTimeout(this._timeoutId);
this._timeoutId = null;
super._reset();
}
/**
@@ -41,17 +37,20 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
*
* 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(sessionToken, additionalData) {
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,
},
@@ -74,19 +73,10 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
continue(socket, lastSeq) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. If this is the case the connector will have to
// wait for the previous socket to close down before it can continue
// from a new socket, or there will be overlapping events from both.
if (["connected", "closing"].includes(this._state)) {
this._socket.close(4003, "Session Hijacked");
// Correctly waiting for the connector to close and then
// handling all the edge cases like multiple continues happening
// in parallel or connections going stale is far too difficult.
// Kill the connection here and let the client reconnect later
// when the connector is not held up by an active connection.
socket.close(1013, "Session Busy");
socket.terminate();
// stale connection. Terminate it if so.
if (this._state === "connected") {
this._socket.terminate();
this._socket.once("close", () => this.continue(socket, lastSeq));
return;
}
@@ -102,6 +92,7 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
type: "continue",
data: {
last_seq: this._lastReceivedSeq,
session_timeout: this._sessionTimeout,
heartbeat_interval: this._heartbeatInterval,
},
}));
@@ -115,63 +106,44 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
this.emit("resume");
}
setTimeout(timeout) {
if (this._timeoutId) {
clearTimeout(this._timeoutId);
}
this._timeoutId = setTimeout(() => { this._timedOut(); }, timeout * 1000);
this._timeout = timeout;
}
_timedOut() {
logger.verbose("SOCKET | Connection timed out");
this._timeoutId = null;
this._lastReceivedSeq = null;
this._sendBuffer.length = 0;
this._state = "closed";
logger.verbose("Connector | Connection timed out");
this._reset();
this.emit("close");
this.emit("invalidate");
}
_attachSocketHandlers() {
this.startHeartbeat();
this._socket.on("close", (code, reason) => {
logger.verbose(`SOCKET | Close (code: ${code}, reason: ${reason})`);
if (this._state === "closing") {
this._lastReceivedSeq = null;
this._sendBuffer.length = 0;
this._state = "closed";
logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`);
this.stopHeartbeat();
if (this._closing) {
this._reset();
this.emit("close");
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
} else {
this._state = "handshake";
this._state = "resuming";
this.emit("drop");
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._timeout * 1000);
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
}
this.stopHeartbeat();
});
this._socket.on("error", err => {
// It's assumed that close is always called by ws
logger.verbose("SOCKET | Error:", err);
logger.verbose("Connector | Error:", err);
});
this._socket.on("open", () => {
logger.verbose("SOCKET | Open");
logger.verbose("Connector | Open");
});
this._socket.on("ping", data => {
logger.verbose(`SOCKET | Ping (data: ${data}`);
logger.verbose(`Connector | Ping (data: ${data}`);
});
this._socket.on("pong", data => {
logger.verbose(`SOCKET | Pong (data: ${data}`);
logger.verbose(`Connector | Pong (data: ${data}`);
});
// Handle messages
@@ -208,11 +180,34 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
return;
}
this.stopHeartbeat();
this._state = "closing";
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;
+28 -7
View File
@@ -2,12 +2,13 @@
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
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");
@@ -47,7 +48,18 @@ async function getMetrics(req, res, next) {
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()) {
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
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()) {
@@ -101,7 +113,11 @@ function validateSlaveToken(req, res, next) {
}
try {
jwt.verify(token, req.app.locals.master.config.get("master.auth_secret"), { audience: "slave" });
jwt.verify(
token,
Buffer.from(req.app.locals.master.config.get("master.auth_secret"), "base64"),
{ audience: "slave" }
);
} catch (err) {
res.sendStatus(401);
@@ -121,7 +137,7 @@ function validateUserToken(req, res, next) {
try {
let tokenPayload = jwt.verify(
token,
req.app.locals.master.config.get("master.auth_secret"),
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);
@@ -171,7 +187,7 @@ async function uploadExport(req, res) {
}
let name = path.posix.basename(filePath);
await fs.outputFile(path.join("static", "export", name), await file.async("nodebuffer"));
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
}
res.sendStatus(200);
@@ -259,6 +275,11 @@ async function getStream(req, res) {
}
const zipMimes = [
"application/zip",
"application/x-zip-compressed",
];
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
@@ -319,7 +340,7 @@ async function uploadSave(req, res) {
requestErrors.push("instance_id must come before files uploaded");
}
if (fileMime !== "application/zip") {
if (!zipMimes.includes(fileMime)) {
requestErrors.push("invalid file Content-Type");
}
@@ -350,7 +371,7 @@ async function uploadSave(req, res) {
req.pipe(busboy);
});
} else if (mimeType === "application/zip") {
} else if (zipMimes.includes(mimeType)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
+14 -14
View File
@@ -1,17 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<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>
<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 -8
View File
@@ -20,18 +20,13 @@ module.exports = (env = {}) => merge(common(env), {
new webpack.container.ModuleFederationPlugin({
name: "master",
shared: {
"@clusterio/lib/config": { singleton: true },
"@clusterio/lib/errors": { singleton: true },
"@clusterio/lib/helpers": { singleton: true },
"@clusterio/lib/link": { singleton: true },
"@clusterio/lib/logging": { singleton: true },
"@clusterio/lib/plugin": { singleton: true },
"@clusterio/lib/schema": { singleton: true },
"@clusterio/lib/users": { singleton: true },
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
"react-router": { singleton: true },
"react-router-dom": { singleton: true },
},
}),
],
+1 -1
View File
@@ -90,4 +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"]
}
+237 -238
View File
@@ -3,38 +3,38 @@
local serialize = {}
local function version_to_table(version)
local t = {}
for p in string.gmatch(version, "%d+") do
t[#t + 1] = tonumber(p)
end
return t
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, has_bar, version
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)
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")
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
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")
@@ -42,265 +42,264 @@ 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
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
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
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.7",
"version": "2.0.0-alpha.12",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,12 +18,13 @@
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.7",
"@clusterio/lib": "^2.0.0-alpha.12",
"fs-extra": "^8.1.0",
"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": {
+216 -77
View File
@@ -20,9 +20,11 @@ const events = require("events");
const pidusage = require("pidusage");
const setBlocking = require("set-blocking");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
const version = require("./package").version;
const winston = require("winston");
require("winston-daily-rotate-file");
// internal libraries
const libFileOps = require("@clusterio/lib/file_ops");
@@ -39,6 +41,8 @@ const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
const finished = util.promisify(stream.finished);
const instanceRconCommandDuration = new libPrometheus.Histogram(
"clusterio_instance_rcon_command_duration_seconds",
@@ -168,8 +172,8 @@ class Instance extends libLink.Link {
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
};
// Valid statuses are stopped, starting, running, stopping, creating_save and exporting_data.
this._status = "stopped";
this._running = false;
this._loadedSave = null;
this.server = new libFactorio.FactorioServer(
factorioDir, this._dir, serverOptions
@@ -182,7 +186,11 @@ class Instance extends libLink.Link {
});
this.server.on("error", err => {
this.logger.error(`Error in instance ${this.name}:\n${err.stack}`);
if (err instanceof libErrors.EnvironmentError) {
this.logger.error(err.message);
} else {
this.logger.error(`${this.name}:\n${err.stack}`);
}
});
this.server.on("autosave-finished", name => {
@@ -191,6 +199,12 @@ class Instance extends libLink.Link {
});
});
this.server.on("save-finished", () => {
this.sendSaveListUpdate().catch(err => {
this.logger.error(`Error handling save-finished in instance ${this.name}:\n${err.stack}`);
});
});
this.server.on("ipc-player_event", event => {
libLink.messages.playerEvent.send(this, {
instance_id: this.id,
@@ -260,7 +274,9 @@ class Instance extends libLink.Link {
notifyStatus(status) {
this._status = status;
libLink.messages.instanceStatusChanged.send(this, {
instance_id: this.id, status,
instance_id: this.id,
status,
game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null,
});
}
@@ -276,7 +292,6 @@ class Instance extends libLink.Link {
}
notifyExit() {
this._running = false;
this._loadedSave = null;
this.notifyStatus("stopped");
@@ -296,10 +311,6 @@ class Instance extends libLink.Link {
for (let pluginInstance of this.plugins.values()) {
pluginInstance.onExit();
}
this.sendSaveListUpdate().catch(err => {
this.logger.error(`Unexpected error updating save list:\n${err.stack}`);
});
}
async _loadPlugin(pluginInfo, slave) {
@@ -319,6 +330,7 @@ class Instance extends libLink.Link {
await this.server.init();
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
@@ -336,6 +348,7 @@ class Instance extends libLink.Link {
await this._loadPlugin(pluginInfo, this._slave);
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
}
@@ -379,7 +392,7 @@ class Instance extends libLink.Link {
*/
async writeServerSettings() {
let serverSettings = await this.resolveServerSettings(this.config.get("factorio.settings"));
await fs.writeFile(
await libFileOps.safeOutputFile(
this.server.writePath("server-settings.json"),
JSON.stringify(serverSettings, null, 4)
);
@@ -413,7 +426,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_adminlist")) {
this.logger.verbose("Writing server-adminlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._slave.adminlist], null, 4)
);
@@ -421,7 +434,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_banlist")) {
this.logger.verbose("Writing server-banlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._slave.banlist].map(
([username, reason]) => ({ username, reason })
@@ -431,7 +444,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_whitelist")) {
this.logger.verbose("Writing server-whitelist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._slave.whitelist], null, 4)
);
@@ -577,7 +590,6 @@ class Instance extends libLink.Link {
await this.sendSaveListUpdate();
await libPlugin.invokeHook(this.plugins, "onStart");
this._running = true;
this.notifyStatus("running");
}
@@ -602,7 +614,6 @@ class Instance extends libLink.Link {
await libPlugin.invokeHook(this.plugins, "onStart");
this._running = true;
this.notifyStatus("running");
}
@@ -683,16 +694,26 @@ class Instance extends libLink.Link {
* Stop the instance
*/
async stop() {
this._running = false;
if (this._status === "stopped") {
return;
}
this.notifyStatus("stopping");
// XXX this needs more thought to it
if (this.server._state === "running") {
await libPlugin.invokeHook(this.plugins, "onStop");
await this.server.stop();
await this.sendSaveListUpdate();
}
}
async kill() {
if (this._status === "stopped") {
return;
}
await this.server.kill(true);
}
async masterConnectionEventEventHandler(message) {
await libPlugin.invokeHook(this.plugins, "onMasterConnectionEvent", message.data.event);
}
@@ -703,7 +724,7 @@ class Instance extends libLink.Link {
async getMetricsRequestHandler() {
let results = [];
if (this._running) {
if (!["stopped", "stopping"].includes(this._status)) {
let pluginResults = await libPlugin.invokeHook(this.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
@@ -729,6 +750,7 @@ class Instance extends libLink.Link {
saveName = await this.prepareSave(saveName);
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
@@ -750,6 +772,7 @@ class Instance extends libLink.Link {
await this.prepare();
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
@@ -780,12 +803,14 @@ class Instance extends libLink.Link {
} catch (err) {
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
this.server.on("exit", () => this.notifyExit());
let { name, seed, map_gen_settings, map_settings } = message.data;
await this.server.create(name, seed, map_gen_settings, map_settings);
await this.sendSaveListUpdate();
this.logger.info("Successfully created save");
}
@@ -825,6 +850,10 @@ class Instance extends libLink.Link {
await this.stop();
}
async killInstanceRequestHandler() {
await this.kill();
}
async sendRconRequestHandler(message) {
let result = await this.sendRcon(message.data.command);
return { result };
@@ -884,6 +913,11 @@ async function discoverInstances(instancesDir) {
continue;
}
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
let instancePath = path.join(instancesDir, entry.name);
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instanceInfos.set(instanceConfig.get("instance.id"), {
@@ -943,6 +977,10 @@ class InstanceConnection extends libLink.Link {
}
async forwardEventToMaster(message, event) {
if (!this.slave.connector.hasSession) {
return;
}
event.send(this.slave, message.data);
}
@@ -973,7 +1011,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
constructor(slaveConfig, tlsCa, pluginInfos) {
super(
slaveConfig.get("slave.master_url"),
slaveConfig.get("slave.reconnect_delay"),
slaveConfig.get("slave.max_reconnect_delay"),
tlsCa
);
this.slaveConfig = slaveConfig;
@@ -981,7 +1019,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
}
register() {
logger.info("SOCKET | registering slave");
logger.info("Connector | registering slave");
let plugins = {};
for (let pluginInfo of this.pluginInfos) {
plugins[pluginInfo.name] = pluginInfo.version;
@@ -993,6 +1031,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
version,
id: this.slaveConfig.get("slave.id"),
name: this.slaveConfig.get("slave.name"),
public_address: this.slaveConfig.get("slave.public_address"),
plugins,
});
}
@@ -1038,6 +1077,15 @@ function checkFilename(name) {
}
}
function checkRequestSaveName(name) {
try {
checkFilename(name);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
}
/**
* Handles running the slave
*
@@ -1086,6 +1134,10 @@ class Slave extends libLink.Link {
}
this.updateInstances().catch((err) => {
if (err instanceof libErrors.SessionLost) {
return undefined;
}
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
});
@@ -1288,7 +1340,7 @@ class Slave extends libLink.Link {
_warning: "Changes to this file will be overwritten by the master server's copy.",
...instanceInfo.config.serialize(),
};
await fs.outputFile(
await libFileOps.safeOutputFile(
path.join(instanceInfo.path, "instance.json"),
JSON.stringify(warnedOutput, null, 4)
);
@@ -1308,6 +1360,14 @@ class Slave extends libLink.Link {
}
}
getRequestInstanceInfo(instanceId) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instanceInfo;
}
/**
* Initialize and connect an unloaded instance
*
@@ -1315,11 +1375,7 @@ class Slave extends libLink.Link {
* @returns {module:slave/slave~InstanceConnection} connection to instance.
*/
async _connectInstance(instanceId) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
let instanceInfo = this.getRequestInstanceInfo(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`);
}
@@ -1381,11 +1437,7 @@ class Slave extends libLink.Link {
return await request.send(instanceConnection, message.data);
}
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
let instanceInfo = this.getRequestInstanceInfo(instanceId);
return {
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
};
@@ -1397,6 +1449,85 @@ class Slave extends libLink.Link {
await request.send(instanceConnection, message.data);
}
async renameSaveRequestHandler(message) {
let { instance_id, old_name, new_name } = message.data;
checkRequestSaveName(old_name);
checkRequestSaveName(new_name);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.move(
path.join(instanceInfo.path, "saves", old_name),
path.join(instanceInfo.path, "saves", new_name),
{ overwrite: false },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async copySaveRequestHandler(message) {
let { instance_id, source, destination } = message.data;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.copy(
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${old_name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async transferSaveRequestHandler(message) {
let { source_save, target_save, copy, instance_id, target_instance_id } = message.data;
checkRequestSaveName(source_save);
checkRequestSaveName(target_save);
let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id);
let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
target_save = await libFileOps.findUnusedName(
path.join(targetInstanceInfo.path, "saves"), target_save, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstanceInfo.path, "saves", source_save),
path.join(targetInstanceInfo.path, "saves", target_save),
{ overwrite: true },
);
}
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${source_save} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves"));
return { save: target_save };
}
async sendSaveListUpdate(instance_id, savesDir) {
let instanceConnection = this.instanceConnections.get(instance_id);
let saveList;
@@ -1411,15 +1542,8 @@ class Slave extends libLink.Link {
async deleteSaveRequestHandler(message) {
let { instance_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
try {
await fs.unlink(path.join(instanceInfo.path, "saves", save));
@@ -1434,15 +1558,8 @@ class Slave extends libLink.Link {
async pullSaveRequestHandler(message) {
let { instance_id, stream_id, filename } = message.data;
try {
checkFilename(filename);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(filename);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
@@ -1459,11 +1576,11 @@ class Slave extends libLink.Link {
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let stream;
let writeStream;
while (true) {
try {
stream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(stream, "open");
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
@@ -1473,8 +1590,8 @@ class Slave extends libLink.Link {
}
}
}
response.pipe(stream);
await events.once(stream, "finish");
response.pipe(writeStream);
await finished(writeStream);
filename = await libFileOps.findUnusedName(savesDir, filename, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename));
@@ -1485,15 +1602,8 @@ class Slave extends libLink.Link {
async pushSaveRequestHandler(message) {
let { instance_id, stream_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
checkRequestSaveName(save);
let instanceInfo = this.getRequestInstanceInfo(instance_id);
let content;
try {
@@ -1583,10 +1693,10 @@ class Slave extends libLink.Link {
}
async prepareDisconnectRequestHandler(message, request) {
this._disconnecting = true;
for (let instanceConnection of this.instanceConnections.values()) {
await libLink.messages.prepareMasterDisconnect.send(instanceConnection);
}
this._disconnecting = true;
this.connector.setClosing();
return await super.prepareDisconnectRequestHandler(message, request);
}
@@ -1599,7 +1709,6 @@ class Slave extends libLink.Link {
return;
}
this._shuttingDown = true;
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
@@ -1613,23 +1722,32 @@ class Slave extends libLink.Link {
for (let instanceId of this.instanceConnections.keys()) {
await this.stopInstance(instanceId);
}
await this.connector.close(1001, "Slave Shutdown");
await this.connector.close(1000, "Slave Shutdown");
// Clear silly interval in pidfile library.
pidusage.clear();
} catch (err) {
setBlocking(true);
logger.error(`
+--------------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+--------------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
}
/**
* True if the connection to the master is connected, not in the dropped
* state,and not in the process of disconnecting.
* @type {boolean}
*/
get connected() {
return !this._disconnecting && this.connector.connected;
}
}
/**
@@ -1708,6 +1826,12 @@ async function startSlave() {
choices: ["none"].concat(Object.keys(levels)),
type: "string",
})
.option("log-directory", {
nargs: 1,
describe: "Directory to place logs in",
default: "logs",
type: "string",
})
.option("config", {
nargs: 1,
describe: "slave config file to use",
@@ -1728,11 +1852,25 @@ async function startSlave() {
.argv
;
logger.add(new winston.transports.File({
format: winston.format.combine(
winston.format.json(),
),
filename: "slave.log",
{
// Migration from alpha-10 single file logs, note that we can't use
// the logger here as it's not initalized yet.
/* eslint-disable no-console */
let slaveLogDirectory = path.join(args.logDirectory, "slave");
if (!await fs.pathExists(slaveLogDirectory) && await fs.pathExists("slave.log")) {
console.log("Migrating slave log...");
await fs.ensureDir(slaveLogDirectory);
await libLoggingUtils.migrateLogs("slave.log", slaveLogDirectory, "slave-%DATE%.log");
console.log("Migration complete, you should delete slave.log now");
}
/* eslint-enable no-console */
}
logger.add(new winston.transports.DailyRotateFile({
format: winston.format.json(),
filename: "slave-%DATE%.log",
utc: true,
dirname: path.join(args.logDirectory, "slave"),
}));
if (args.logLevel !== "none") {
logger.add(new ConsoleTransport({
@@ -1863,6 +2001,7 @@ async function startSlave() {
});
await slaveConnector.connect();
logger.info("Started slave");
}
module.exports = {
@@ -1905,10 +2044,10 @@ ${err.original.stack}`
} else {
logger.fatal(`
+--------------------------------------------------------------+
| Unexpected error occured while starting slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+--------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting slave, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.7",
"version": "2.0.0-alpha.12",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.13.8",
"@babel/preset-react": "^7.12.13",
"@clusterio/lib": "^2.0.0-alpha.7",
"@clusterio/lib": "^2.0.0-alpha.12",
"antd": "^4.16.0",
"assert": "^2.0.0",
"babel-loader": "^8.2.2",
+3 -5
View File
@@ -3,9 +3,8 @@ import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
import libConfig from "@clusterio/lib/config";
import libPlugin from "@clusterio/lib/plugin";
import { ConsoleTransport, WebConsoleFormat, logger } from "@clusterio/lib/logging";
import { libConfig, libLogging, libPlugin } from "@clusterio/lib";
const { ConsoleTransport, WebConsoleFormat, logger } = libLogging;
import App from "./components/App";
import { Control, ControlConnector } from "./util/websocket";
@@ -86,8 +85,7 @@ export default async function bootstrap() {
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10);
controlConnector.setTimeout(15);
let controlConnector = new ControlConnector(wsUrl, 120);
let control = new Control(controlConnector, plugins);
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
+2 -2
View File
@@ -1,8 +1,7 @@
import events from "events";
import React, { useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { logger } from "@clusterio/lib/logging";
import { libLogging } from "@clusterio/lib";
import ErrorBoundary from "./ErrorBoundary";
import SiteLayout from "./SiteLayout";
@@ -12,6 +11,7 @@ import LoginForm from "./LoginForm";
import { Card, Spin, Typography } from "antd";
const { Paragraph } = Typography;
const { logger } = libLogging;
function ErrorCard(props) {
@@ -1,7 +1,7 @@
import React, { useContext, useState } from "react";
import { Button, Form, Modal, Select, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import { libLink } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -1,8 +1,32 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd";
const { Title } = Typography;
function getInitialValues(config, props) {
let initialValues = {};
for (let [name, GroupClass] of props.ConfigClass.groups) {
let group = config.group(name);
for (let def of GroupClass.definitions.values()) {
if (!group.canAccess(def.name)) {
continue;
}
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
if (def.type === "object") {
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
initialValues[propPath] = JSON.stringify(value[prop]);
}
} else {
initialValues[fieldName] = value;
}
}
}
return initialValues;
}
export default function BaseConfigTree(props) {
let [config, setConfig] = useState(null);
@@ -16,8 +40,13 @@ export default function BaseConfigTree(props) {
let newConfig = new props.ConfigClass("control");
await newConfig.load(serializedConfig);
setConfig(newConfig);
return newConfig;
}
useEffect(() => { updateConfig(); }, [props.id]);
useEffect(() => {
updateConfig().then(updatedConfig => {
form.setFieldsValue(getInitialValues(updatedConfig, props));
});
}, [props.id]);
function renderInput(def) {
if (def.type === "boolean") {
@@ -190,7 +219,7 @@ export default function BaseConfigTree(props) {
request = props.setProp(fieldName, prop, value);
} else {
request = props.setField(field, value);
request = props.setField(field, value === null ? "" : String(value));
}
try {
@@ -1,8 +1,7 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import { Button, Form, Input, Modal } from "antd";
import libLink from "@clusterio/lib/link";
import { readMapExchangeString } from "@clusterio/lib/factorio/exchange_string";
import { libFactorio, libLink } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -23,7 +22,7 @@ export default function CreateSaveModal(props) {
if (values.exchangeString && values.exchangeString.trim()) {
let result;
try {
result = readMapExchangeString(values.exchangeString);
result = libFactorio.readMapExchangeString(values.exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
@@ -72,7 +71,7 @@ export default function CreateSaveModal(props) {
let exchangeString = form.getFieldValue("exchangeString");
let result;
try {
result = readMapExchangeString(exchangeString);
result = libFactorio.readMapExchangeString(exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
+1 -3
View File
@@ -1,8 +1,6 @@
import React, { useContext } from "react";
import React from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import PageLayout from "./PageLayout";
const { Paragraph } = Typography;
@@ -1,8 +1,6 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import { libConfig, libLink } from "@clusterio/lib";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
@@ -22,7 +20,7 @@ export default function InstanceConfigTree(props) {
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: props.id,
field,
value: String(value),
value,
});
}
@@ -0,0 +1,104 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { message, Button, Space, Table } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import { useSlaveList } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
import StartStopInstanceButton from "./StartStopInstanceButton";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function InstanceList(props) {
let history = useHistory();
let [slaveList] = useSlaveList();
function slaveName(slaveId) {
let slave = slaveList.find(s => s.id === slaveId);
if (slave) {
return slave.name;
}
return String(slaveId);
}
function instancePublicAddress(instance) {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave && slave.public_address) {
if (instance.game_port) {
return `${slave.public_address}:${instance.game_port}`;
}
return slave.public_address;
}
return null;
}
let columns = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
responsive: ["sm"],
},
{
title: "Public address",
key: "public_address",
render: instance => {
let public_address = instancePublicAddress(instance);
return public_address ? <>
{public_address}
<Button
type="text"
icon={<CopyOutlined/>}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(public_address);
message.success("Copied public address!");
}}
/>
</> : "";
},
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
responsive: ["lg"],
},
{
title: "Status",
key: "status",
render: instance => <InstanceStatusTag status={instance["status"]} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
{
key: "action",
render: instance => <StartStopInstanceButton
buttonProps={{ size: "small" }}
instance={instance}
/>,
responsive: ["sm"],
align: "right",
width: 100,
},
];
if (props.hideAssignedSlave) {
columns.splice(1, 1);
}
return <Table
size={props.size || "large"}
columns={columns}
dataSource={props.instances}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>;
}
@@ -1,7 +1,7 @@
import React, { useContext, useState } from "react";
import { Input, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import { libLink } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -0,0 +1,21 @@
import { Tag } from "antd";
import React from "react";
export const statusColors = {
unknown: "#8c8c8c",
unassigned: "#eb2f96",
stopped: "#cf1322",
starting: "#ad8b00",
running: "#389e0d",
stopping: "#d48806",
creating_save: "#096dd9",
exporting_data: "#08979c",
deleted: "#262626",
};
export default function InstanceStatusTag(props) {
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
}
@@ -1,14 +1,17 @@
import React, { useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Dropdown, Menu, Modal, PageHeader, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
import libLink from "@clusterio/lib/link";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import InstanceConfigTree from "./InstanceConfigTree";
import InstanceConsole from "./InstanceConsole";
import LogConsole from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
@@ -17,16 +20,48 @@ import SavesList from "./SavesList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const { Title, Paragraph } = Typography;
const { Title } = Typography;
function InstanceDescription(props) {
let account = useAccount();
const { slave, instance } = props;
let assigned = instance["assigned_slave"] !== null;
return <Descriptions
bordered
size="small"
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instance["id"]}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
let history = useHistory();
let account = useAccount();
let control = useContext(ControlContext);
let [instance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
@@ -38,20 +73,28 @@ export default function InstanceViewPage(props) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing) {
if (instance.missing || instance["status"] === "deleted") {
return <PageLayout nav={nav}>
<h2>Instance not found</h2>
<p>Instance with id {instanceId} was not found on the master server.</p>
<Alert
message={instance["status"] === "deleted" ? "Instance has been deleted" : "Instance not found" }
showIcon
description={<>Instance with id {instanceId} was not found on the master server.</>}
type="warning"
action={
<Button
type="text"
onClick={() => { history.push("/instances"); }}
>
Go back to instances list
</Button>
}
/>
</PageLayout>;
}
let instanceButtons = <Space>
<StartStopInstanceButton instance={instance} />
<LoadScenarioModal instance={instance} />
<Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
let instanceButtonsMenu = <Menu
onClick={({ key }) => {
if (key === "export") {
setExportingData(true);
libLink.messages.exportData.send(
control, { instance_id: instanceId }
@@ -60,65 +103,86 @@ export default function InstanceViewPage(props) {
).finally(() => {
setExportingData(false);
});
}}
>
Export data
</Button>
<Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteInstance.send(
} else if (key === "kill") {
libLink.messages.killInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
}}
>
<Button
danger
).catch(notifyErrorHandler("Error killing instance"));
} else if (key === "delete") {
Modal.confirm({
autoFocusButton: "cancel",
content: "Permamently delete instance and server saves?",
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => {
libLink.messages.deleteInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
},
});
}
}}
>
{account.hasPermission("core.instance.export_data") && <Menu.Item
disabled={exportingData || instance.status !== "stopped"}
key="export"
>Export data</Menu.Item>}
{account.hasPermission("core.instance.kill") && <Menu.Item
disabled={["unknown", "unassigned", "stopped"].includes(instance["status"])}
key="kill"
>Kill process</Menu.Item>}
{account.hasPermission("core.instance.delete") && <>
<Menu.Divider />
<Menu.Item
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
>
<DeleteOutlined />
</Button>
</Popconfirm>
danger
key="delete"
icon={<DeleteOutlined />}
>Delete</Menu.Item>
</>}
</Menu>;
let instanceButtons = <Space>
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasAnyPermission(
"core.instance.export_data",
"core.instance.kill",
"core.instance.delete",
) && <Dropdown placement="bottomRight" trigger={["click"]} overlay={instanceButtonsMenu}>
<Button>More <DownOutlined /></Button>
</Dropdown>}
</Space>;
let assigned = instance["assigned_slave"] !== null;
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
<PageHeader
className="site-page-header"
title={instance["name"]}
extra={instanceButtons}
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
<AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
/>
<InstanceDescription slave={slave} instance={instance} />
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
&& <SavesList instance={instance} />
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
}
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
}
<InstanceConfigTree id={instanceId} />
{account.hasPermission("core.instance.get_config") && <InstanceConfigTree id={instanceId} />}
<PluginExtra component="InstanceViewPage" instance={instance} />
</PageLayout>;
}
@@ -1,16 +1,16 @@
import React, { useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { Button, Form, Input, Modal, PageHeader } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import { libConfig, libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { useInstanceList } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
function CreateInstanceButton(props) {
let control = useContext(ControlContext);
@@ -29,7 +29,7 @@ function CreateInstanceButton(props) {
await instanceConfig.init();
instanceConfig.set("instance.name", values.instanceName);
let serialized_config = instanceConfig.serialize("master");
let result = await libLink.messages.createInstance.send(control, { serialized_config });
await libLink.messages.createInstance.send(control, { serialized_config });
setVisible(false);
history.push(`/instances/${instanceConfig.get("instance.id")}/view`);
}
@@ -51,7 +51,7 @@ function CreateInstanceButton(props) {
>
<Form form={form}>
<Form.Item name="instanceName" label="Name">
<Input/>
<Input />
</Form.Item>
</Form>
</Modal>
@@ -59,47 +59,42 @@ function CreateInstanceButton(props) {
}
export default function InstancesPage() {
let history = useHistory();
let [slaveList] = useSlaveList();
let control = useContext(ControlContext);
let account = useAccount();
let [instanceList] = useInstanceList();
return <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
className="site-page-header"
title="Instances"
extra=<CreateInstanceButton />
extra={<>
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instanceList.forEach(instance => {
if (instance.status === "stopped") {
libLink.messages.startInstance.send(
control, { instance_id: instance.id, save: null }
).catch(notifyErrorHandler("Error starting instance"));
}
})
}>
Start all
</Button>}
{account.hasPermission("core.instance.stop")
&& <Button onClick={e => instanceList.forEach(instance => {
if (["starting", "running"].includes(instance.status)) {
libLink.messages.stopInstance.send(
control, { instance_id: instance.id }
).catch(notifyErrorHandler("Error stopping instance"));
}
})
}>
Stop all
</Button>}
</>}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave) {
return slave.name;
}
return instance.assigned_slave;
},
},
{
title: "Status",
dataIndex: "status",
},
]}
dataSource={instanceList}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>
<InstanceList instances={instanceList} />
<PluginExtra component="InstancesPage" />
</PageLayout>;
}
@@ -1,8 +1,7 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import { Button, Form, Input, Modal } from "antd";
import libLink from "@clusterio/lib/link";
import { readMapExchangeString } from "@clusterio/lib/factorio/exchange_string";
import { libFactorio, libLink } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
@@ -23,7 +22,7 @@ export default function LoadScenarioModal(props) {
if (values.exchangeString && values.exchangeString.trim()) {
let result;
try {
result = readMapExchangeString(values.exchangeString);
result = libFactorio.readMapExchangeString(values.exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
@@ -71,7 +70,7 @@ export default function LoadScenarioModal(props) {
let exchangeString = form.getFieldValue("exchangeString");
let result;
try {
result = readMapExchangeString(exchangeString);
result = libFactorio.readMapExchangeString(exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
@@ -1,8 +1,9 @@
import React, { useEffect, useContext, useRef, useState } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import { libLink } from "@clusterio/lib";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
@@ -48,28 +49,40 @@ function formatLog(info, key) {
return <span key={key}>[{level}] {info.message}<br/></span>;
}
export default function InstanceConsole(props) {
export default function LogConsole(props) {
let account = useAccount();
let control = useContext(ControlContext);
let anchor = useRef();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState([]);
useEffect(() => {
let logFilter = {
all: props.all || false,
master: props.master || false,
slave_ids: props.slaves || [],
instance_ids: props.instances || [],
};
// Scroll view to the anchor so it sticks to the bottom
let parent = anchor.current.parentElement;
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
max_level: null,
}).then(result => {
setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index)));
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
if (account.hasPermission("core.log.query")) {
setPastLines([<span key={0}>{"Loading past entries..."}<br/></span>]);
libLink.messages.queryLog.send(control, {
...logFilter,
max_level: null,
limit: 400,
order: "desc",
}).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info, index)).reverse());
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
} else {
setPastLines([]);
}
function logHandler(info) {
setLines(currentLines => currentLines.concat(
@@ -77,11 +90,11 @@ export default function InstanceConsole(props) {
));
}
control.onInstanceLog(props.id, logHandler);
control.onLog(logFilter, logHandler);
return () => {
control.offInstanceLog(props.id, logHandler);
control.offLog(logFilter, logHandler);
};
}, [props.id]);
}, [props.all, props.master, (props.slaves || []).join(), (props.instances || []).join()]);
return <>
<Paragraph code className="instance-console">
+3 -3
View File
@@ -1,11 +1,11 @@
import React, { Fragment, useContext } from "react";
import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typography } from "antd";
import { Button, Card, Divider, Form, Image, Input, Space, Typography } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
import logo from "../images/logo.png";
import ControlContext from "./ControlContext";
const { Paragraph, Text } = Typography;
const { Paragraph } = Typography;
function TokenAuth(props) {
@@ -22,7 +22,7 @@ function TokenAuth(props) {
>
<Input
style={{ width: "14em" }}
prefix=<LockOutlined className="site-form-item-icon" />
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Authentication Token"
/>
@@ -1,8 +1,6 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import { libConfig, libLink } from "@clusterio/lib";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more