Compare commits

..
Author SHA1 Message Date
Hornwitser c2b6a6e1c0 v2.0.0-alpha.16 2024-01-26 13:04:07 +01:00
Hornwitser f5255d6f76 Fix message seq reuse on forwarding (#579)
When messages were forwarded they were sent using the low level
connector .send method which accepts a message and sends it over the
link.  This method sends the message as is with no modification which
means that at no point during the forwarding logic was the message
sequence number that originated from Link A renumbered to the correct
sequence number to send out on Link B which it was forwarded on.

This in turn caused the logic which drops acked messages from the send
buffer to break which in turn meant the resume could cause duplication
of message and/or messages to be lost.  With enough duplicated messages
the resume of one connection could cause other connections to drop and
also initiate resume.

Fix by renumbering the sequence on forwarded messages.  In the future
the sequences sent and received should also be checked to make sure this
kind of problem does not happen again, this is not done here because
partially updating a running cluster with sequence problems to one that
checks and enforces sequences numbers will be problematic.
2024-01-26 13:02:26 +01:00
Hornwitser 7d6eac6558 v2.0.0-alpha.15 2024-01-24 04:40:21 +01:00
Hornwitser 6732397061 Fix Web UI not clearing config props (#578) 2024-01-24 04:35:24 +01:00
Cooldude2606 2020024b68 Merge pull request #577 from Hornwitser/small-things-15
Small things 15
2024-01-24 03:18:07 +00:00
Hornwitser 499e973db9 Fix InstanceConfigSetPropRequest schema
Fix the value incorrectly being set as required in the schema and thus
preventing props on config field objects of the instance from being
removed.
2024-01-24 03:43:21 +01:00
Hornwitser e3cfc090d3 Fix string format of Address
When host and instance id in Address was reordered in 1e66f11 the
toString method Address was updated to account for this.  Fix toString
showing host addresses as being instance addresses and vice versa.
2024-01-24 03:43:21 +01:00
Hornwitser 38dcff2225 Use h: as short form of host in log lines
Before the rename from hosts were called slave which is why s: made
sense. Use h: instead.
2024-01-24 03:43:21 +01:00
Cooldude2606 a1109b9354 Merge pull request #576 from Hornwitser/fix-plugin-broadcast
Fix plugin broadcasts not being filtered
2024-01-24 02:42:30 +00:00
Hornwitser 2a57ddb02e Add note about send and bcast test functions 2024-01-24 03:08:24 +01:00
Hornwitser 847945cfdd Fix plugin broadcasts not being filtered
When a plugin broadcasts an event to instances there may be hosts and
instances in the cluster which does not have the plugin loaded.  This
would cause an error to be logged for every instance which does not have
the plugin loaded.

Filter out broadcast events sent to hosts and instances such that
instances and hosts that does not have the event do not received them.
2024-01-24 01:56:01 +01:00
Cooldude2606 ab88955111 Merge pull request #574 from Hornwitser/fix-auto-start
Fix instance auto start
Fix host hang on stopping during create save
2024-01-23 18:41:44 +00:00
Hornwitser fc5701f243 Fix instance auto start
Bypass the Link layer and invoke the handleInstanceStartRequest method
directly on the Instance object during the host's instance auto start
phase.
2024-01-23 16:23:09 +01:00
Hornwitser 256b455315 Fix host hang on stopping during create save
If during the startup of an instance the Factorio server is started up
to create a new save and the host shutdown in initiated then the
instance will be commanded to stop while the FactorioServer state is
"create".  This was was not handled by Instance.stop leaving the
child_process running after the host is done shutting down.

Kill the FactorioServer on stop if the current state of it is stopping
or create to make sure the server actually stops.
2024-01-23 16:18:21 +01:00
Cooldude2606 2733f2e3c5 Merge pull request #571 from Hornwitser/link-bugfixes
Link bugfixes
2024-01-22 21:57:50 +00:00
Hornwitser 96bd5fb1c2 Handle virtual links in Link._clearPendingRequests
When a request is forwarded from a virtual link to a WebSocket link as
is the case when an instance sends a request to the controller the
origin for the request is a virtual link.  Handle this case in
_clearPendingRequests instead of throwing an assertion error.
2024-01-22 20:48:01 +01:00
Hornwitser 123148e277 Fix pending request not deleted on response
Fix memory leak due to the recorded _pendingRequests entry for a request
not being deleted when the response for the request has been received.
2024-01-22 20:48:01 +01:00
Hornwitser d7edae99c5 Add metrics for pending request
Track the number of requests currently awaiting a response from a
request handler both from interally issued requests and requests
forwarded from other parties.
2024-01-22 20:48:01 +01:00
Hornwitser 51fe6c9519 Merge pull request #569 from Hornwitser/bugfixes
Bugfixes for Alpha 14
2024-01-22 00:35:28 +01:00
Hornwitser e0161114e7 Fix --no-download-headless conflict in installer
Fix installer incorrectly exiting with a conflicting options errors when
the --no-download-headless option was used in conjunction with the
--factorio-dir option.
2024-01-21 23:49:19 +01:00
Hornwitser 2872faab98 Skip loading plugins without entrypoint on hosts
If a plugin only has entrypoints at the controller level the config will
skip adding a "load_plugin" config option for the given plugin to avoid
making options that do nothing.  If such a plugin is added to a host it
will still reach loadPlugin which in turn tries to check the load_plugin
config and throw an error on startup due the field being missing.

Skip trying to load plugins without an entrypoint at the instance or
host level in hosts before checking the load_plugin config field.
2024-01-21 23:49:19 +01:00
Hornwitser 1d1e80fbf0 Export Control and related types from ctl 2024-01-21 23:49:19 +01:00
Hornwitser 3d7c11a8b6 Add missing enable source maps for host service 2024-01-21 23:49:19 +01:00
Hornwitser 72264f0c53 Add suport for color mod setting (#568)
Implement the color-setting type in ModPack and display a color picker
for it in the Web UI. This type of setting was added in Factorio 1.1.77.
2024-01-21 23:48:26 +01:00
Hornwitser d6d6c052aa Add known issue about being kicked out of the Web UI 2024-01-21 02:17:12 +01:00
Hornwitser 901ce4e08e Add broken instance data export to known issues 2024-01-21 01:01:08 +01:00
Hornwitser 5fdc585212 Add known issues section
Some problems were not caught during testing and is not straight forward
to fix.  Add them to the known issues section for now.
2024-01-20 22:48:20 +01:00
Hornwitser 0aa330561b Update migration guide
Add a note about double checking which version of Node.js is installed
to avoid accidentally updating on an outdated version, and fix migrate
arguments.
2024-01-20 22:44:28 +01:00
Hornwitser 1be1b029a0 2.0.0-alpha.14 2024-01-20 21:58:42 +01:00
Hornwitser cafafbd540 Fix handleFetchPlayerCodeRequest returning incorrect properties 2024-01-20 21:58:42 +01:00
Hornwitser 5ae19042f1 Fix capacity typo in metrics 2024-01-20 20:37:13 +01:00
HornwitserandCooldude2606 90969bd90b Add changelog for 2.0.0-alpha.14 (#562)
Add changelog and migration guide for alpha 14.

Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2024-01-20 20:33:45 +01:00
Hornwitser c53781d16c Add time unit suffixes to variables (#561)
Add suffixes like Ms and Ns to variables whos time unit is not in
seconds.  This is aligning with a new code style of putting units
on variables that are not in SI base units.
2024-01-20 13:26:55 +01:00
Hornwitser 9842705eb9 Improve error reporting of plugins in the Web UI (#557)
Keep the error message and display it in the plugin page when loading
the plugin info or the plugin itself fails to load in the Web UI.
2024-01-20 05:20:24 +01:00
HornwitserandCooldude2606 f702c956a5 Document migration of plugins to alpha 14 (#558)
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2024-01-20 05:13:55 +01:00
Hornwitser ca58e2f0a6 Merge pull request #560 from Hornwitser/small-things-14
Small things 14
2024-01-20 04:36:09 +01:00
Hornwitser c3c2528c30 Add missing updates to instance.updatedAt
Fix instances not properly updating their status to unassigned and
unknown due to the updatedAt field not being updated with the current
timestamp.
2024-01-20 02:44:55 +01:00
Hornwitser 1e66f11917 Reorder members of Address
The logical order of clusterio's components are in most places
controller, host, instance, control.  It is confusing that Address
breaks this convention and I do not remember I wrote it that way.
Reorder and renumber the host and instance members of Address to
be consistent with most other places.
2024-01-20 02:19:47 +01:00
Hornwitser bb1401a683 Update jsonwebtoken used by player_auth
This was missed during 1a9cbd2 which updated jsonwebtoken in other
packages.
2024-01-20 02:03:23 +01:00
Hornwitser 96f48ca102 Fix installer breaking on setting up plugins
The entrypoint of plugins were changed in 50f12c7 and b0f6c95 but the
installer's loading of plugin info to get the correct plugin name was
not updated for these changes.  Update installer plugin loading to use
the correct entrypoint.
2024-01-20 01:50:42 +01:00
Hornwitser 671ff23085 Write new run scripts during migration
If the old run-master or run-slave scripts are present write the new
run-controller and/or run-host scripts.
2024-01-20 01:30:24 +01:00
Hornwitser 5e2221796a Rename sharedMods to mods on migration
The place for storing mods was changed from sharedMods to mods when mod
management was implemented.  Rename the old sharedMods folder to mods
when the migration script is invoked.
2024-01-20 01:22:29 +01:00
Hornwitser adc2d9cb4d Fix HostInfo.name description 2024-01-19 19:17:16 +01:00
Hornwitser d5626d31e6 Fix rendering of ModDetails 2024-01-19 19:17:16 +01:00
Hornwitser b0f6c95fca Move export for plugin declaration (#555)
Change the expected exported name of plugin declarations to be "plugin"
at the main entrypoint instead of the default export.  This makes the
plugin's code organisation more flexible especially when using CommonJS
JavaScript modules as they can export additional symbols from the
plugin declaration file.
2024-01-18 20:31:52 +01:00
Hornwitser 8884116ee9 Implement stop and restart of controller/hosts (#554)
Implement remotely initiated stopping and restarting of the controller
and hosts.  Restarting is implemented by exiting with a non-zero exit
code and relying on a process manager to start the procress again.  To
prevent accidentally trying to restart on a system that doesn't have a
process manager present the --can-restart command line option needs to
be passed to enable the restart operation.
2024-01-18 16:10:42 +01:00
Lars Corbijn 885c18c0d4 Use a relative path for commands in create (#553)
The create.js script in development mode used to use hard coded paths to
the other cluster commands (ctl, controller etc.). This means that in
dev mode a cluster could only be created from the root directory of the
project.

By using relative paths one can also create a cluster in a subdirectory.
For example by using the following:

mkdir test-cluster
cd test-cluster
node ../packages/create --dev
2024-01-17 21:44:08 +01:00
Hornwitser 527c98d378 Add first join at player statistic (#551)
Record the time a player was first seen joining an instance in
PlayerStats.  For existing stats this value is set to the last
joined time when it is first loaded.
2024-01-17 20:29:30 +01:00
Hornwitser 863d90c7fb Implement custom input for mod pack config (#547)
Add custom input component support to the Web UI config in order to
provide a user friendly dropdown selector for the Mod Pack that is
configured as default on the controller and overridden instances.
2024-01-17 20:29:04 +01:00
Hornwitser 80f0c63113 Merge pull request #552 from Hornwitser/additional-system-info
Additional system info
2024-01-17 20:28:06 +01:00
HornwitserandCooldude2606 4628e0ad59 Fix incorrect test description for SystemInfo
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2024-01-17 06:46:18 +01:00
Hornwitser a786f186a2 Merge pull request #546 from Hornwitser/support-reverse-proxies
Use X-Forwarded-For header on trusted proxies
2024-01-17 05:27:45 +01:00
Hornwitser b5ed0800cf Add address host connected from to the host info
Provide an easy way to see where a host is connecting from in the web UI
and clusterioctl list.
2024-01-16 21:27:02 +01:00
Hornwitser de47989527 Add system info to SystemInfo
Provide the kernel, machine, CPU Model, hostname, and Node.js version
fields to SystemInfo and display them in the Web UI.  These were added
as metrics exposed to prometheus in b46efeb.
2024-01-16 21:27:02 +01:00
Hornwitser 4e36fa1b4e Rename SystemMetrics to SystemInfo
There are more pieces of information about a system than just its CPU,
Memory and disk metrics.  To avoid having to build a separate collection
and subscription for metrics and system info, rename the existing system
metrics to system info in anticipation of providing additonal static
system information such as CPU model in it.
2024-01-16 21:27:02 +01:00
Hornwitser 5f25aad273 Use X-Forwarded-For header on trusted proxies
If the controller is behind a proxy then the socket.remoteAddress will
be that of the proxy instead of the end client that connected.  In this
case the X-Forwarded-For header will contain the address of the end
client, but this should only be relied upon if the party connecting is a
trusted proxy, otherwise a malicious user could spoof the IP address
that is logged by providing a forged X-Forwarded-For header.

Implement a trusted proxy list consisting of a configured list of IP
addresses and/or CIDR blocks and use the last address[1] in the
X-Forwarded-For header if the remoteAddress address of the connecting
party is in the trusted proxy list.

[1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
2024-01-16 06:21:48 +01:00
Hornwitser c2a3d4361a Merge pull request #545 from Hornwitser/remote-host-config
Remote host config
2024-01-16 04:18:20 +01:00
Hornwitser 688ddc529b Separate connection related data from host data
Split out host specific data from RegisterHostData that is not related
to the establishment of a connection to the controller into it's own
HostInfoUpdate event.  This allows this data to be updated when the
config values they are taken from are changed.
2024-01-15 22:19:38 +01:00
Hornwitser 790f880e98 Split up BaseConfigTree component
Pull out the computation of the tree data into a separate function to
reduce the complexity of the BaseConfigTree component.
2024-01-15 20:32:56 +01:00
Hornwitser 707c0bb453 Add remote configuration of hosts
Implement remote editing of the host config in a similar fasion to how
the remote editing of the controller config works.
2024-01-15 20:28:44 +01:00
Hornwitser 7dce25423c Add non_blocking_saving to instance defaults (#544) 2024-01-15 05:04:34 +01:00
Hornwitser 705fb90740 Remove agent string
The agent string was inspired by the HTTP User-Agent header to serve as
a way to identify what's connecting to the cluster.  While there may
have been vision of other software connecting to a controller than the
Clusterio provided ones this seems highly unlikely to ever happen.

Remove redundant agent string for now.
2024-01-15 03:49:41 +01:00
Hornwitser 8704a24f26 Sort hosts in AssignInstanceModal (#542) 2024-01-15 03:36:02 +01:00
Hornwitser 6f6fe54d5c Merge pull request #541 from Hornwitser/system-metrics
Real-time metrics
2024-01-15 03:34:44 +01:00
Hornwitser d2861c4457 Add tests for derived SystemMetrics 2024-01-15 03:27:37 +01:00
Hornwitser ae2c5cf98e Implement power of two size suffies for memory
Memory sizes are usually written using the prefixes with a base of 1024
instead of 1000.  Implment formatting of the power of two based prefixes
for displaying memory sizes.
2024-01-14 09:57:44 +01:00
Hornwitser d64bf6015e Show System metrics in hosts page
Implement simple collection of basic real time system metrics of hosts
and the controller for the purpose of displaying them in the Web UI. The
metrics are collected by the controller at a configurable interval and
pushed out to web clients currently subscribed to the metrics.
2024-01-14 09:57:44 +01:00
Hornwitser 69bdc8e884 Update antd
Use the latest version of antd to get support for the hidden property on
table columns.
2024-01-14 00:42:43 +01:00
Hornwitser b46efeb4e3 Add system info, CPU, memory and disk metrics
Collect basic metrics about the system the controller and hosts run on
including CPU, memory and disk capacity and usage.
2024-01-14 00:42:43 +01:00
Hornwitser 81d6369a34 Fix incorrect merging of samples from hosts
The Prometheus library has an optimisation for metrics that expose
multiple time series such as Summary that provide two values in the
time series consisting of the metric name suffixed with _sum and _count.
These are provided as a series of sufixes with corresponding samples such
that a serialised summary metric named some_value would have a samples
mapping looking something like this:

[
    ["_sum", ["label=\"value\"", 3]],
    ["_count", ["lavel=\"value\"", 2]],
]

If multiple hosts expose the same metric then these need to be merged,
which is the job of mergeSamples, which merges the samples from each
sufix into the destination.

One corner case that may occur is if the source and destination metric
that's being merged have different lists of suffixed samples.  In this
case the suffixes that are present in the source but not in the
destination needs to be added verbatim to the destination.  The code for
handling this case incorrectly added these samples to the source metric
instead of the destination metric.

This corner case only occrs if a metric's type differs between two
hosts connected to the same controller.  For example if a Gauge metric
is turned into a Summary metric.
2024-01-14 00:42:43 +01:00
Hornwitser 32d70a83d1 Fix debug dump-ws crashing the controller
When ws was updated to version 8 it came with a breaking change where
message data was no longer utf8 decoded from binary to a string.  The
main codepath handling messages was updated to handle this but the ws
dump path was not resulting in the controller crashing when receiving
a message that is then forwarded to the dumper without decoding.

Fix by decoding the message in the dump-ws path.
2024-01-14 00:42:43 +01:00
Hornwitser 1f61e49599 Merge pull request #539 from Hornwitser/small-things-13
Small things 13
2024-01-11 02:26:49 +01:00
Hornwitser b976de926b Fix unassigning instances broken in Web UI 2024-01-11 01:21:35 +01:00
Hornwitser 880216b775 Add typing to column render methods
The render method for columns in Antd Table take the three arguments
value, record, index. If dataIndex is set then value is the property
given by dataIndex in record, otherwise value and record is the same.
Unfortunately only record has the proper type while value is the type
any.  Change usage of `render: value =>` to `render: (_, value) =>` to
get proper typing for the render methods defined on columns.
2024-01-11 01:21:35 +01:00
Hornwitser e530bbc83a Replace deprecated usage of Breadcrumb.Item 2024-01-11 01:21:35 +01:00
Hornwitser a67a4a41da Use separate build for web version of lib
Use a separate ES Module based build for the web version of the lib
package.  This fixes the rebuild on watched files changing in lib
failing with a TS1479 error.
2024-01-11 01:21:35 +01:00
Hornwitser 2bdb03b0a9 Merge pull request #538 from Hornwitser/host-port-ranges
Host port ranges
2024-01-11 00:57:01 +01:00
Hornwitser 8733575255 Assign game port from host configured port ranges
Add host.factorio_port_range config to hosts that specify a pool of
ports that gets automatically assigned to instances which do not have a
game_port set on them.  This allows setting up a port range that's
forwarded to the host on the router and then configure that port range
on the host and have instances automatically get assigned an available
port when they start up.

Resolves: #448
2024-01-11 00:19:19 +01:00
Hornwitser 9742eb1645 Add parser for simple range expression
Implement parsing of ranges of the form "1, 3-5" into a set of all the
numbers it represents.
2024-01-11 00:19:19 +01:00
Hornwitser c87493af0c Merge pull request #537 from Hornwitser/refactor-config
Refactor config
2024-01-11 00:05:44 +01:00
Hornwitser d8301f3c61 Rename RawInstanceInfo to HostInstanceUpdate
Due to configs needing a location to be deserialised it's not possible
to cleanly replace RawInstanceInfo with the InstanceInfo class.  Rename
it to HostInstanceUpdate to better represent its purpose.
2024-01-10 23:52:43 +01:00
Hornwitser c38a63692d Fix docs for initialValue
Remove reference to async function support in the doc for initialValue
that was removed in 27af142.
2024-01-10 23:37:50 +01:00
Hornwitser 1f19f1bfcb Add note about config value cast through unknown 2024-01-10 23:29:32 +01:00
Hornwitser c0f65e0610 Revert mistaken removal of await
Revert mistaken removal of await from controller plugin initialization
in 27af142.
2024-01-10 23:16:29 +01:00
Hornwitser 1985abbe6c Fix web interface not loading
The config refactor accidentally introduced a circular dependency loop
which would break the web interface from loading.
2024-01-09 02:25:36 +01:00
Hornwitser e74065a493 Remove config groups
Refactor away the config group concept and simplyfy the way config
fields are defined.  Fields are defined directly as properties of the
static field fieldDefinitions on the class itself with a separate
interface to declare the types of fields.

This makes it possible to easily make accessing the config fields type
checked in a way that lets plugins extend the interfaces with their own
config options by adding additional fields to the interface via
declation merging.
2024-01-09 02:19:07 +01:00
Hornwitser 96210373f3 Move IpcPlayerData out of inventory_sync/instance.ts
Remove circular dependency between inventory_sync/message.ts and
inventory_sync/instance.ts by moving the IpcPlayerData type from
message.ts to instance.ts.  This solves a problem with the web
build checking types in instance.ts that it dosen't use.
2024-01-08 23:54:12 +01:00
Hornwitser 7bbb44d4e6 Move TypedEventEmitter into its own file 2024-01-08 23:47:24 +01:00
Hornwitser 943c0d9cb4 Merge pull request #534 from Hornwitser/refactor-subscriptions
Refactor subscriptions
2024-01-07 02:54:20 +01:00
Hornwitser 001db4befe Refactor config to JSON data class pattern
Refactor the Config and ConfigGroup classes to match the JSON data class
pattern with toJSON, toRemote and fromJSON methods replacing the load,
init, and serialize methods.  Since fromJSON requires extra information
the messages structures passes around the plain JSON representation of
the configs instead of serialising instances of the config classes
automatically.
2024-01-06 19:53:32 +01:00
Hornwitser 27af142a87 Remove silly support for async initial_value
This complicates the handling of configs for no good reason.
2024-01-06 19:49:35 +01:00
Hornwitser ede150d80f Add roundtrip test for InstanceInfo 2024-01-06 19:32:42 +01:00
Hornwitser 699ef2c3cb Separate updatedAt from mtime in SaveDetails
If a new save is added to the saves list of an instance the current save
selected to be loaded by default changes its state without updating the
mtime of the file on disk, which in turn means basing the updatedAt
property on mtime will not work with caching.  Fix by having a separate
updatedAt property that is set by the controller whenever the save
details changes from the controller's perspective.

Noticed-By: @Cooldude2606
2024-01-06 18:52:41 +01:00
Hornwitser 1ae686b3ea Don't initalize activeModPack to an empty mod pack
Avoid possible use of an unintended empty mod pack by not setting the
activeModPack on the Instance until it's loaded in syncMods.  This was a
leftover from when a newly created empty mod pack was supposed to act as
the default when no mod pack was set.
2023-12-21 00:43:12 +01:00
Hornwitser 6d840fc1e7 Remove redundant .pop() in test 2023-12-20 22:32:12 +01:00
Hornwitser c62ea697fe Fix typos 2023-12-20 22:29:44 +01:00
Hornwitser 8c17adf15b Refactor EventSubscriber to track its resource
Process updates of the resource EventSubscriber are subscribed to and
have it keep a mapping of id to value syncronised with event provider as
long as there are subscription callbacks registered with it.  The
mapping is kept around after the last callback is unsubscribed so that
it can be efficiently resynced with the resource based on the updatedAt
timestaps of individual elements.

This allows the EventSubscriber to provide the right building blocks to
provide its values to React components via the the useSyncExternalStore
hook.  This in turn greatly simplifies the web_ui models for the
subscribed resources.
2023-12-12 23:40:29 +01:00
Hornwitser 867bb0c646 Add ModInfo.id getter
Provide a simple unique identifier for mods consisting of the name
followed by an underscore and then the version.
2023-12-12 00:08:25 +01:00
Hornwitser ddba58ca99 Require control in EventSubscriber.constructor
Reduce complexity in the implementation of EventSubscriber by requiring
a control link be passed to its constructor.  This is possible after the
change in 8c724e3 made the web interface control instance available to
plugins at init time.
2023-12-11 23:14:37 +01:00
Hornwitser 6cab1fa842 Remove SubscriptionResponse
Since subscriptions are handlede per link there's no need for the
eventReplay to be returned as a part of the SubscriptionRequest, it can
be sent and processed normally as an event.  Simplify the logic by
sending eventReplay as an event and remove SubscriptionResponse.
2023-12-11 21:47:15 +01:00
Hornwitser 4439691695 Decouple SubscriptionController from Controller
Remove the reliance a reference to the Controller to get the link that a
subscription is tied to by explicitly handling the SubscriptionRequest
and passing the link to the SubscriptionController.
2023-12-11 21:47:15 +01:00
Hornwitser 1d729adbb7 Move responsibility of handling unsubscription on close
Notify the SubscriptionController that a connection closed from the
Connection instead of having the subscription controller set up an event
to do so.  This simplifies the logic on the SubscriptionController's
side.
2023-12-11 21:47:15 +01:00
Hornwitser 0df672ecca Implement subscription handlers for resources
Send the hosts, instances, saves, mods, mod packs and users that has
updated since the lastRequestTime provided in the request.
2023-12-11 20:21:43 +01:00
Hornwitser 0c4387769e Use -1 as the default lastResponseTime
Entites that didn't have an updatedAt time at the time of creation will
default to having one set to 0.  Since entities with an updatedAt time
greater than lastResponseTime is expected to be returned this means that
these entities would not be included when a client does its initial sync
subscribing to a resource.  Change the default lastResponseTime
requested so that these entities are included.
2023-12-11 20:21:43 +01:00
Hornwitser 5748f834ff Only call subscription update function on subscribe
Skip calling the subscription function that should provide an event
replay if the client is unsubscribing from the event.  This avoids
having to check if the client is subscribing in every subscription
function.
2023-12-11 20:21:43 +01:00
Hornwitser a2914bda33 Cache save details on the controller
Keep a copy of the instance saves details on the controller so that
saves can be queried even when the host is disconnected and the
controller can properly track and provide updates on the saves to
its clients.
2023-12-11 20:21:43 +01:00
Hornwitser d186f85773 Refactor InstanceInfo
Refactor to store instance status, gamePort, and updatedAt to disk for
instances.  This enables InstanceDetails to properly record when it was
updated.
2023-12-09 17:23:24 +01:00
Hornwitser c248363895 Implement HostInfo as a class
Replace POJO definition of HostInfo with a class that properly manages
its own serialisation to JSON.
2023-12-09 17:23:24 +01:00
Hornwitser 20330bc2c3 Push multiple updates in each update event
Refactor HostUpdatesEvent, InstanceDetailsUpdatesEvent, ModUpdatesEvent,
ModPackUpdatesEvent and UserUpdatesEvent to have multiple updates of the
entities they support in each event. This is intended to make the events
capable of syncing the entire list that they represent.
2023-12-08 22:01:46 +01:00
Hornwitser f12a9926eb Add isDeleted and updatedAt to subscribable types
Prepare for subscriptinos being able to be cached and resumed by adding
updatedAt and isDeleted fields to the subscribable types.
2023-12-08 18:22:34 +01:00
Hornwitser f00995a7f9 Merge pull request #533 from Hornwitser/sync-mods-with-controller
Sync mods with controller
2023-12-04 02:19:29 +01:00
Hornwitser 58914042cc Remove channels to simplify subscription
Make subscriptions either on or off to greatly simplify the logic of
subscribing to events.
2023-12-03 20:32:48 +01:00
Hornwitser a2eb98ac93 Implement hosts downloading needed mods
Download and cache needed mods to run instance mod packs on instance
startup.  To avoid opening too many download streams at once only one
mod is downloaded at a time.
2023-12-03 20:15:07 +01:00
Hornwitser 853866492b Refactor controller mod storage into ModStore
Structure the storage of mods on the controller into into a reusable
component in preparation for the host using the same kind of mod storage
to retrieve and cache the mods it needs to run mod packs on instances.
2023-12-03 19:59:14 +01:00
Hornwitser 448cfbbb51 Log errors on more instance failures conditions
Log errors messages to the log when setting up an instance fails in the
preparation phase, creating save phase, starting up phase, and so on.
Previously these were only sent back as a an error response to the
request that iniatied the wakeup of the instance.
2023-12-03 19:59:14 +01:00
Hornwitser 1095d2466d Fix path traversal on mod upload
Fix possibility of a maliciously crafted mod being written outside of
the mods directory and thus possibily overwriting unrelated files.
2023-12-03 19:59:14 +01:00
Hornwitser 94cb607930 Improve typing of various controller entities 2023-12-03 19:59:14 +01:00
Hornwitser 8b0d7a67b5 Automatically obtain required mods in test
Build the clusterio_lib mod from source and download and copy the
subspace_storage mod used for testing if it's not present in the mods
directory.  This removes a manual step needed to run tests on a new
clone of the repository.
2023-12-03 00:05:44 +01:00
Hornwitser 94d67cef71 Add AsyncSerialCallback helper
Add helper to serialise the calls made to an async function such that no
overlapping invocations happen to the async function by queue up calls
made while it's running to continue after it's running.
2023-12-03 00:01:23 +01:00
Hornwitser 07160c2fe0 Merge pull request #532 from Hornwitser/small-things-12
Small things 12
2023-12-02 14:57:58 +01:00
Hornwitser ec734c92bc Fix bad typings detected by linting
Fix badly typed expressions that were detected by turning on
`@typescript-eslint/no-unnecessary-condition` in eslint.
2023-12-02 01:47:02 +01:00
Hornwitser 90a88f7e01 Fix /api/plugins leaking filesystem paths
Replace PluginWebApi's requirePath member with npmPackage which is only
populated if the package is not marked as private and the requirePath
matches the name. This prevents the public /api/plugins endpoint from
leaking filesystem paths into the controller.
2023-12-02 01:43:28 +01:00
Hornwitser 42ae4d6897 Fix research finished not syncing properly
Fix the research finished event not being sent to the controller, which
in turn meant it would not sync properly.
2023-12-02 01:43:28 +01:00
Hornwitser 11ffee646e Add the other packages to typedoc output
Add controller, host, ctl, and web_ui packages to the typedoc output.
2023-12-02 01:43:28 +01:00
Hornwitser e453a80155 Refactor User and Role into data classes (#531)
Use the JSON data class pattern for User, Role, and PlayerStats and move
them into lib/src/data.  This means that User is purely data now and
can't reference role objects directly.  To make it possible easily check
permissions attached to roles and store data only visible on the
controller on user objects a ControllerUser type is introduced which is
linked with the UserManager and can therefore reference role objects
directly.

Resolves #525.
2023-12-02 01:41:43 +01:00
Hornwitser f802e6ba67 Merge pull request #527 from Hornwitser/autosave-data
Implement autosaving of data held in memory
2023-12-01 02:30:54 +01:00
Hornwitser ab52cc35bf Provide controller resources at construction time
Load hosts, instances, mod packs, mods and users before constructing the
Controller instances.  This allows these resources to be correctly typed
as always available and prevents any possibility of save being initiated
on these resources before they are loaded.
2023-11-30 18:31:42 +01:00
Hornwitser a0b02ee68b Separate updates of Instance and InstanceDetails
Some updates for instances trigger instance details updates, while other
updates to instances are not visible on instance details. Rename
instanceUpdated to instanceDetailsUpdated to better represent what it
does and only set instancesDirty when an update is made to the
instances.
2023-11-30 15:01:14 +01:00
Hornwitser fae6ed3297 Implement serial calls to save with a helper
Separate out the serialisation of the invocations to saveData with an
AsyncSerialMergingCallback helper than has unit tests covering its
functionality.
2023-11-30 01:57:18 +01:00
Hornwitser a0807b22a4 Fix inventory wipe on SE remote view (#530)
Exit the Space Exploration remote view if its active to prevent the
empty remote view "character" being serialized as the inventory instead
of the real character for the player.

Original-patch-by: @Reavershark
Fixes: #513
2023-11-30 01:32:13 +01:00
Hornwitser 2c0c84b191 Add the ctl counterpart to the revoke host action. (#528)
Resolves #512.
2023-11-30 01:24:23 +01:00
Hornwitser d5bcd34cdb Use .tmp before the file extension (#529)
When creating temporary files use the pattern `.tmp.ext` instead of
`.ext.tmp` to be consistent with Factorio which creates `.tmp.zip` files
during saving.

If patching fails this will leave a `.tmp.zip` file in the instance saves
directory which is ignored by the default save selector.

Resolves #486.
2023-11-30 01:21:06 +01:00
Hornwitser ffe5040644 Implement autosaving of data held in memory
Add controller.autosave_interval config that specifies the frequency
which controller config, hosts, instances, users, mod packs and plugin
specific data which is held in memory should be automatically flushed to
disk if they have changed since the last time they were saved.  This
prevents data loss in the event of a server crash.

Plugins can use the onSaveData hook to save its own data at the same
time as the controller does and this obsoletes making an autosave
interval in the plugin.

Resolves #523.
2023-11-29 20:18:50 +01:00
Hornwitser faadf873fb Merge pull request #524 from Hornwitser/typescript-plugin-web
Port plugin web code to TypeScript
2023-11-29 20:16:24 +01:00
Hornwitser 1434ede2dc Improve error reporting when web_ui fails to load
Catch uncaught errors and display the error message on the page itself
if bootstraping fails. This should prevent most cases of the of the web
interface being stuck on "Loading web interface..." after an error was
thrown and the loading failed.
2023-11-29 19:01:46 +01:00
Hornwitser f1edbd78cb Move dist/web_build inside dest/web
By default webpack-dev-middleware will refuse to delete files outside of
its working directory. This becomes dist/web in the configuration
currently set up, which means that a rebuild triggered on the watched
files will fail when declaration files are output to to dist/web_build.

Fix by moving dist/web_build to dist/web/build.
2023-11-29 18:02:27 +01:00
Hornwitser 0a4368d1c0 Rename index.css to style.css
Make the tab completion on web/in<Tab> give the TypeScript code instead
of annoyingly stopping at the extension.
2023-11-28 15:52:54 +01:00
Hornwitser 6a404d9fb3 Refactor and port plugin web code to TypeScript
Clean up the configuration for compiling TypeScript for the Web
interface to us its own tsconfig.web.json config files and reference the
TypeScript source files directly in the configuration and imports
instead of files compiled to dist.

To properly support recompliation with TypeScript's projects feature
each package's tsconfig.web.json file is set to output the build to
dist/web_build which is then ignored in .npmignore.  Webpack is still
used to create the proper build in dist/web, so this is mainly to make
TypeScipt's compiler happy but it also allows the root tsconfig.json
file to include the web interface code, meaning running the build script
now also checks the typing of the web interface.

Final part of #491.
2023-11-28 15:52:54 +01:00
Hornwitser 8c724e3bc5 Provide control at init time for web plugins
The control interface is required for adding handlers for message types
a plugin defines, but the plugins were loaded before the Control
instance was created. This meant there was no sensible way for web
plugins to register handlers for their own message types.

Move the creating of the Control instance to before plugins are loaded
and set this.control for web plugins before init is called.
2023-11-28 15:35:40 +01:00
Hornwitser 70d2b319b3 Fix types related to plugins and the web interface 2023-11-28 15:35:40 +01:00
Hornwitser 8b5c66d900 Output compiled validator to source tree as well
Output message_validate.js to both dist/src/data and src/data. This
allows tools to directly consume the TypeScript sources in src/data
without throwing an error due to message_validate being missing.
2023-11-28 15:35:40 +01:00
Hornwitser ceb7e48c99 Fix global declaration of staticRoot and webRoot
These variables were intended to be globals declare in the global
namespace. That this ends up on the window object is an implementation
detail of JavaScript. Remove the window. prefix put in front of
staticRoot and webRoot and include the global declaration in the
TypeScript module.
2023-11-28 15:35:40 +01:00
Hornwitser 9df6717364 Move PluginWebApi to lib
Resolve circular dependency of controller depending on web_ui and web_ui
depending on controller by moving the type from controller that was used
in web_ui to lib.
2023-11-28 14:48:06 +01:00
Hornwitser 47c5a727b5 Merge pull request #522 from Hornwitser/base-plugin-move
Refactor plugin code
2023-11-28 14:47:23 +01:00
Hornwitser 654df76065 Strongly type invokeHook
Add strong typing of the invokeHook function to prevent incorrect
paramaters being passed to plugin hooks. This detected a few places
where the parameters passed were incorrect.
2023-11-28 14:28:22 +01:00
Hornwitser e3e5867d60 Fix PlayerEvent being completely broken
Fix the name being passed as the type when deserialising the event, the
stats being missing, and incorrect parameters passed to plugins.
2023-11-28 14:28:22 +01:00
Hornwitser b9c3458cc2 Fix onMetrics using the wrong iteration type 2023-11-28 14:07:38 +01:00
Hornwitser ee9c200ebf Implement host plugins
Add ability to define plugin code that runs in the context of a host,
starts up with the host does, and stops when the host shuts down.
2023-11-25 19:18:33 +01:00
Hornwitser 808eda0513 Move base plugins to their respective packages
Move BaseControllerPlugin to controller/src/BaseControllerPlugin.ts
Move BaseInstancePlugin to host/src/BaseInstancePlugin.ts
Move BaseControlPlugin to ctl/src/BaseCtlPlugin.ts
Move BaseWebPlugin to web_ui/src/BaseWebPlugin.ts

This solves the circular package dependency problem of the base plugins
needing types that are available only in the dependencies to lib.
2023-11-25 18:41:43 +01:00
Hornwitser 63dc370ea0 Merge pull request #520 from Hornwitser/vs-code-config
Add VSCode workspace config
2023-11-25 18:41:23 +01:00
Hornwitser 26827ec8a7 Configure VSCode Workspace
Add configuration for VSCode to use tabs for indent, unix style endings
and leave a newline at the last line of the file.  This will make
editing from VSCode less error-prone as it will select the right
settings by default.
2023-11-25 18:36:30 +01:00
Hornwitser 87e52b804e Convert JSON file indents to tabs
Be consistent with the usage of tabs as indents in all JSON files in the
project.  This makes it easier to configure editors to emit the right
indents when manually editing these files.
2023-11-25 18:19:15 +01:00
Hornwitser 702c323610 Merge pull request #519 from Hornwitser/small-things-11
Small things 11
2023-11-25 17:53:08 +01:00
Hornwitser d47fd20926 Fix --dev broken after 7517917 2023-11-24 21:11:56 +01:00
Hornwitser 51f7430a4b Add warning to generated server-settings.json
Leave a warning in case someone opens and tries to modify the
server-settings.json file in the instance folder, as any changes
made will be lost when it's overwritten.
2023-11-24 21:07:17 +01:00
Hornwitser fc6e9d0002 Fix type checking on Request and Event types
When defining a generic type where a type argument is never used
as a part of the type itself, TypeScript will consider two instances of
the generic type with different values for the type to be the same.
This caused the handle method to accept any handler for any
Request/Event, ignoring the event/request type completely.

Fix by specifying the constructor to return the intersection of the type
itself and the Request/Event type.
2023-11-24 20:56:02 +01:00
Hornwitser f23e442c4c Ignore BOM in locale files
Ignore BOM at the start of Factorio locale files as this is a thing
Factorio also does.

Fixes #507
2023-11-24 20:53:16 +01:00
Hornwitser 6eda1f78d5 Turn on --enable-source-maps
Turn on source map deduction in stack traces from thrown errors. This
feature is off by default due to potential performance degration in
certain corner cases.  For the dev environment add instructions for
passing the option to node.
2023-11-24 20:53:16 +01:00
Hornwitser 142cf8a626 Fix log splitting not happening on UTC boundnary 2023-11-24 20:53:16 +01:00
Hornwitser 56d3997675 Refactor to keep handle to Instance in connection
Keep a handle to the Instance in the connection the host has to the
instance so that methods can be called directly on the Instance. This
simplifies logic that exists outside of the link system like the host
shutting down all of its instances when it exits.

Fixes validation error thrown on host shutdown with running instances.
2023-11-24 20:53:16 +01:00
Hornwitser 82ed0adfc3 Merge pull request #517 from Hornwitser/setup-typescript-eslint
Setup TypeScript ESLint
2023-11-20 16:04:47 +01:00
Hornwitser 9f31394318 Name a type for event data
Use a named type for the EventData used in subscriptions.  This was done
as a workaround for a bug in the linter.
2023-11-20 14:43:03 +01:00
Hornwitser 2a1821993c Remove .js extension from import
This confuses the node/no-missing-import linter rule.
2023-11-20 14:03:52 +01:00
Hornwitser 3cc9ec3c68 Ignore complexity in ConfigGroup.define
This method will most likely be rewritten in a future config refactor.
Ignore linter complexity for now.
2023-11-20 13:50:20 +01:00
Hornwitser 52d3bba6f6 Move instance buttons out of InstanceViewPage
Break out the button row at the top of the instance page into its own
component to reduce the complexity of InstanceViewPage.
2023-11-20 13:49:23 +01:00
Hornwitser 0de70df472 Move dev server logic out of _startInternal
Break out the logic for setting up the webpack dev server into its own
method to reduce the complexity of _startInternal.
2023-11-20 13:46:51 +01:00
Hornwitser 7517917832 Move controller's TypeScript output to dist
Be consistent with the other packages and set the controller's
TypeScript compilation outDir to dist instead of dist/controller.
2023-11-20 13:46:25 +01:00
Hornwitser af9ed19af0 Setup typescript-eslint
Configure eslint to work with TypeScript via the typescript-eslint
plugin.
2023-11-19 04:50:07 +01:00
Hornwitser b78ca31b09 Add typing for serialized collector type
Setup TypeScript tyings and JSON schema for serializeResult and
deserializeResult and use these in the metrics related requests.
2023-11-19 04:50:07 +01:00
Hornwitser b66138587b Move exposition content type to its own var
Remove anti-pattern of setting properties on Function object that is
difficult to document.
2023-11-19 04:50:07 +01:00
Hornwitser 80e1904175 Fix bad typings left over from the migration 2023-11-19 04:50:07 +01:00
Hornwitser 7314dc99c8 Fix code style errors
Remove extra whitespace, use more consistent spacing, add missing
semicolons, using dangling commas, break up long lines, consistent
multiline operator placement, add missing JSDoc strings and so on.
2023-11-19 04:48:38 +01:00
Hornwitser 97e05c0610 Update GitHub actions 2023-11-18 22:14:29 +01:00
Hornwitser 2c9b07b8d6 Add subscription handlers in constructor
Move the calls to add subscrition handlers into the constructor of
Controller to reduce the amount of code in the start logic.
2023-11-18 22:12:14 +01:00
Hornwitser 60f09b11f9 Merge pull request #510 from Cooldude2606/refactor/subscriptions
Refactor/subscriptions - Create a generalized event subscription class
2023-10-13 14:45:44 +02:00
Cooldude2606andHornwitser 54cb483a31 Convert overloads to generic function
Co-authored-by: Hornwitser <github@hornwitser.no>
2023-10-13 11:21:55 +01:00
Hornwitser d58602f1a1 Fix merge conflicts 2023-10-12 17:21:56 +02:00
Hornwitser d84445baa1 Fix line endings and indents 2023-10-12 17:07:07 +02:00
Jonas Meeuws d73f26c83f Fix subspace_storage IpcItems type and UpdateStorageEvent.type (#516) 2023-10-12 16:20:27 +02:00
Cooldude2606 23ba5d612a Fixed failing tests for unsubscribe 2023-10-06 18:56:27 +01:00
Cooldude2606 b15b1a6007 Fixed bugs found during pre-review 2023-10-06 18:30:15 +01:00
Luciole e2dea35e02 allow to revoke host tokens from web ui (#511)
* allow to revoke / restore host access from web ui

* refector to what is expected

* remove core.admin check on HostViewPage.tsx

* update core.host.revoke_token description
2023-09-29 19:50:14 +02:00
Cooldude2606 11d820b085 Increase patch coverage 2023-09-07 22:31:09 +01:00
Cooldude2606 4986a06cdf Fixed web_ui websocket type error 2023-09-07 21:58:10 +01:00
Cooldude2606 621f850804 Some cleanup 2023-09-07 21:36:27 +01:00
Cooldude2606 2afddafe4d All tests passing 2023-09-07 20:54:21 +01:00
Cooldude2606 2c9050824e Test suite mostly ready 2023-09-07 03:36:38 +01:00
Cooldude2606 cccb27c733 Add test skeleton 2023-09-06 21:16:31 +01:00
Cooldude2606 71166af75b Fixed TestControl subscription requests 2023-09-06 01:09:02 +01:00
Cooldude2606 da25196830 Refactor to use subsribers 2023-09-05 01:46:45 +01:00
Cooldude2606 17943c48c3 A few renames and comments 2023-09-04 22:26:18 +01:00
Cooldude2606 62e69e3dbf Add SubscriptionController and EventSubscriber 2023-09-04 21:46:28 +01:00
Luciole 838170a9ff Port packages/web_ui to TypeScript (#508)
* .jsx to .tsx

* controller: tsc --build before webpack-cli

* Type Annotation

* disabled move into the <Input/> and <Checkbox/>

* cleanup debug left over

* documentation

* default ModPack

* modPackUpdateHandlers no undefined

* PluginInfo changes proposition

* PluginInfo changes proposition miss

* ModRecord & ModInfo feedback loop at const mods = [ ?

* ["connect", "drop", "resume", "close"] as const

* .tsx to .ts when possible
2023-09-02 14:51:35 +02:00
Cooldude2606 a4893751e9 Add module export detection (#505)
Allow modules to include a module_exports.lua file in their root which
will cause the file save/modules/module_name.lua to be generated
pointing to the exports file.
2023-08-27 17:47:37 +02:00
Hornwitser ee105e0e28 Merge pull request #504 from Hornwitser/tsconfig-cleanup
TSConfig cleanup
2023-08-27 17:33:49 +02:00
Hornwitser 55da662344 Rebuild TypeScript files on test
Prevent testing the wrong files by making sure the TypeScript build is
up to date before running the tests.
2023-08-26 17:11:47 +02:00
Hornwitser 425daac6b6 Set up references in TSConfig
Link the projects together properly so that builds don't use out of date
files and editor "go to definition" function give the right file.
2023-08-26 17:11:47 +02:00
Hornwitser c3d78a8cb8 Organise TSConfig files using inheritance
Removed duplicated TSConfig options by using a shared base config which
all plugins inherit shared settings from.
2023-08-26 17:11:46 +02:00
Luciole 1afbbe04e1 Fix issue with research_sync plugin (#502)
* fix type issues on loading technologies and broadcasting progress

* Distinguish Technology and TechnologySync type
2023-08-26 16:35:39 +02:00
Hornwitser 36ced0d3ba Merge pull request #503 from Luciole0x/inventory_sync-fix
fix inventory_sync plugin
2023-08-26 14:52:25 +02:00
Luciole 318a4301c0 Define responses types, DownloadResponse.player_data > playerData 2023-08-26 00:16:31 +02:00
Luciole 29eccb27ed Allow pluggins to get custom config field 2023-08-26 00:08:56 +02:00
Hornwitser 06f28eced9 Merge pull request #501 from Hornwitser/module-locale
Module locale
2023-08-25 22:07:11 +02:00
Hornwitser cc8ec36e5a Map locale from modules into the save's locale
Map locale files located at locale/{lang}/{file} in a module to
locale/{lang}/{module-name}-{file} in the save. This additionally
support a shorthand notation by mapping locale/{lange}.cfg to
locale/{lang}/{module-name}.cfg.

Resolves #500.
2023-08-25 16:46:13 +02:00
Hornwitser a1148c7ec9 Refactor module patching logic
Break out the module info and patch info into data classes and pack all
the files for a module into one more easily modified structure.
2023-08-25 16:33:02 +02:00
Hornwitser b1a6628bfa Remove left over load entries from 7b8127f
Remove save_crafts.lua and restore_crafts.lua entries in the module.json
for inventory_sync which was left over from the removal in 7b8127f.
2023-08-25 15:26:44 +02:00
Hornwitser 6d1ff4c178 Fix web interface broken by TypeScript porting 2023-08-25 15:25:43 +02:00
Luciole 0b3550b472 Port remaining plugins to Typescript (#499)
* .js to .ts

* Allow access to _items entries; getItemCount return number

* add permission field to PluginPage interface

* port plugins to typescript

* update Request classes

* fix IpcFlowType
2023-08-22 16:17:16 +02:00
Luciole 50f12c719b Port global_chat plugin to typescript (#497)
* .js to .ts

* translate

* update paths to dist/plugin

* add "tsc --build" to prepare

* use field main in package.json de find info

* tsc --build need to append before webpack

* update test and use for plugins package.main
2023-08-18 00:06:49 +02:00
Hornwitser 477b600b1b Merge pull request #498 from Hornwitser/typescript-host
Port host package to TypeScript
2023-08-17 19:48:22 +02:00
Hornwitser 946c6e93c4 Port host package to TypeScript 2023-08-17 16:47:09 +02:00
Hornwitser 880fc77b88 Fix logger.child returning incorrect type 2023-08-17 16:47:09 +02:00
Hornwitser 94fbb8a92f Rename MessageSrcDst to MessageRoutable 2023-08-17 16:47:09 +02:00
Hornwitser d088219d8d Merge pull request #496 from Hornwitser/fix-tests-on-windows
Fix tests on windows
2023-08-17 16:46:38 +02:00
Hornwitser 47375ec075 Skip unknown instance status check on Windows
The unknown status happens when an instance is on a host that has
disconnected, but since alt-host is not spawned in the tests on
Windows this status is not observed.
2023-08-15 18:44:53 +02:00
Hornwitser 115cb3fd5d Fix path slash issue in plugin loader test
On Windows require paths use a mix of forward and backward slashes due
to require path possibly being a package path which can't have backward
slashes, or a Windows file path which can have have either.  Fix plugin
loader test failing on Windows due to not using the same combination of
forward and backward slashes for require file paths.
2023-08-15 18:44:53 +02:00
Hornwitser 8bf5296fe6 Gracefully handle error linking mods on Windows 2023-08-15 18:44:53 +02:00
Hornwitser 9c9adf7c8e Use return type deduction for timers
Fix updates to Node.js timers types breaking the TypeScript build by
deducing the correct type from the return type of the
setTimeout/setInterval functions.
2023-08-15 18:01:27 +02:00
Hornwitser e51a644788 Merge pull request #495 from Luciole0x/master
Transition controller package to TypeScript.
2023-08-15 17:11:26 +02:00
Luciole e59027f557 don't cast filename to string 2023-08-14 22:27:35 +02:00
Luciole 4024969be8 no default for routes 2023-08-14 18:54:49 +02:00
Luciole f2736bae40 tsconfig update; fix function signature; test: add .default 2023-08-14 14:34:56 +02:00
Luciole a26dcf5075 defaultRoleId: explicitly check for null 2023-08-14 13:31:55 +02:00
Luciole 8108fcac81 update package.json to fix test running and import version 2023-08-14 11:56:41 +02:00
Luciole 8256f06a6a update QueryLogFilter: make maxLevel optional 2023-08-14 11:29:24 +02:00
Luciole 25e65910dd Controller: back to hosts, instances, modPacks, mods init to null 2023-08-14 11:26:48 +02:00
Luciole ef32f6f466 .closing > .connector.closing 2023-08-14 11:03:44 +02:00
Luciole 23089aa1af createUser: if no default_role_id, assign no role 2023-08-14 11:00:11 +02:00
Luciole 3036507baa clarify null / undefined / 0
remove useless type import and signature
export directly
cleanup
2023-08-14 10:54:30 +02:00
Luciole c96a13ef30 fix removed Buffer from base64 auth_secret 2023-08-14 10:03:31 +02:00
Luciole 3fac5b00d3 non-null assertion on response.socket 2023-08-14 09:48:38 +02:00
Luciole 3e4acaaf4e use instance.game_port 2023-08-14 09:47:50 +02:00
Luciole 799cfe7ff2 test: import controller/* from dist folder 2023-08-13 19:21:51 +02:00
Luciole 6104843858 transition of controller package to typescript 2023-08-13 18:12:12 +02:00
Luciole e011625a2d expose and/or modify signatures from lib package 2023-08-13 16:53:03 +02:00
Hornwitser b72fa25d3f Merge pull request #494 from Hornwitser/typescript-ctl
Port ctl package to Typescript
2023-08-12 20:52:14 +02:00
Hornwitser ec0fded0cc Port ctl package to TypeScript 2023-08-12 20:36:33 +02:00
Hornwitser 5ec7c9f99c Fix bad typings in lib
These were missed or broken in the in the inital porting of lib to
TypeScript.
2023-08-12 19:59:44 +02:00
Hornwitser 6d523096ce Merge pull request #493 from Hornwitser/typescript-lib
Port lib package to TypeScript
2023-08-12 14:44:59 +02:00
Hornwitser f875843c5c Turn on source maps for .ts files
Fix coverage checking not being able to deduce the original TypeScript
files due to lacking a way to link the compiled .js output files with
the .ts input files.
2023-08-12 14:34:36 +02:00
Hornwitser 954bc9b314 Use composite build
Make TypeScript build faster for a multi package setup by allowing the
compiller to quickly determine if a package is already built.
2023-08-12 02:31:17 +02:00
Hornwitser a66e930997 Use strict type checking 2023-08-12 02:31:17 +02:00
Hornwitser 2ca8e69025 Remove message_validate.js ignore
This file was relocated to the dist folder.
2023-08-11 01:16:12 +02:00
Hornwitser 679c00527f Port remaining lib/data modules to TypeScript 2023-08-11 01:13:38 +02:00
Hornwitser 2a52a82fb7 Port message types to TypeScript 2023-08-11 00:24:00 +02:00
Hornwitser 15ea9a91dc Port basic lib sub-modules to TypeScript 2023-08-06 18:38:20 +02:00
Hornwitser e0c197b9bc Port basic lib modules to TypeScript 2023-08-06 01:03:13 +02:00
Hornwitser 2988cd962c Port stream.ts to TypeScript 2023-08-03 15:51:58 +02:00
Hornwitser dedb42447c Port helpers.js to TypeScript 2023-08-03 15:03:52 +02:00
Hornwitser 74e9ed8257 Replace jsdoc with typedoc
Use TypeDoc instead of JSDoc because it has better support for
documenting TypeScript APIs.
2023-08-03 15:02:31 +02:00
Hornwitser 3a321b39e1 Remove incorrect validation of forwared messages
Messages are forwareded when received from another link, this means it's
too late to throw an error that bubbles up to the code trying to send
the incorrect message thus defeating the purpose of the validation.
2023-08-03 15:01:34 +02:00
Hornwitser 90798205e0 Set up TypeScript for lib
Add a TypeScript build suitable for incremental migration to TypeScript
to the lib package.
2023-08-03 15:01:34 +02:00
Hornwitser a1ee147c46 Merge pull request #490 from Hornwitser/speedup-ctl
Speedup ctl
2023-07-30 02:27:19 +02:00
Hornwitser 5eb91be6fb Pre-compile Message validator
Use a script to pre-compile the validator for the Message class to
reduce the loading time for ctl by about 40ms.
2023-07-30 01:54:59 +02:00
Hornwitser 83695e3e4c Merge pull request #488 from Hornwitser/refactor-lib
Refactor lib
2023-07-30 00:10:25 +02:00
Hornwitser ae542e9501 Lazy compile schema validators for ctl
Speed up the startup time for ctl by delaying schemas compilation until
the validator is used. Saves approximately 120ms on startup time.
2023-07-28 21:36:43 +02:00
Hornwitser a5c7eb3a27 Move user token signing to UserManager
Only the controller has access to the secret needed to sign user tokens
so it makes sense for the user signing code to live in the controller.
This saves about 20ms on the time ctl takes to start up due to lib no
longer needing to import jsonwebtoken.
2023-07-28 16:03:29 +02:00
Hornwitser 6af25e085c Move Factorio server implementation to host
Move Factorio related code in lib that's only used by the host into the
host package.
2023-07-28 16:03:29 +02:00
Hornwitser bc574ee1cb Reorganise packages/lib
Move the majority of the script files into a src directory to be
consistent with the organisation of the other packages.
2023-07-28 16:03:29 +02:00
Hornwitser bfe119eaa3 Flatten lib namespace
Move all functions and classes currently available through lib/name and
libName to be under a single flat namespace in lib.  This make
reorganising the lib namespace much simpler as the location of symbols
can move without breaking the consumers of lib.

The multi-entrypoint design was already problematic for the web plugin
system which couldn't effectively use it due to how webpack's shared
module system is awkward to set up with multiple entrypoints per package.
2023-07-28 16:03:29 +02:00
Hornwitser bb58670597 Merge pull request #487 from Hornwitser/node-js-16
Bump supported Node.js to v18 and update dependencies
2023-07-27 23:17:09 +02:00
Hornwitser c2fa873d85 Update version of pnpm used on CI 2023-07-27 21:04:27 +02:00
Hornwitser d9e94648d3 Fix wrong Node.js version in contributing docs. 2023-07-27 17:55:34 +02:00
Hornwitser fd709fd98c Update web dependencies
- react: Update to new root rendering method
- react-router: Replace useHistory with useNavigate
- react-router: Replace <Switch> with <Routes>
- reimplement sticky notice from scratch in ModPackViewPage
- remove isomorphic-ws as it's latest version doesn't work and only
  saves one line of code.
- remove import of antd.dark.css
- fix misc css breakages.
2023-07-27 17:50:14 +02:00
Hornwitser 8d98eaf3a5 Reimplement PageHeader
Implement our own version ofwhe PageHeader component, due to it being
removed from ant design v5.
2023-07-27 17:37:27 +02:00
Hornwitser 789c02b5c7 Fix missing return in applyModPackChanges 2023-07-27 13:43:32 +02:00
Hornwitser a1c9bc5f9b Migrate deprecated ant design APIs
Rename visible to open and use menu instead of overlay in Dropdown as
both of these have been removed in the next major version of Antd.
2023-07-27 13:42:35 +02:00
Hornwitser 2de029b96e Fix strict mode warnings from Ajv
Add type object to schemas specifying properties and minItems to schemas
specifying tuples. Disable strictTuples because it's desirable to be
able to add additional items to the tuples in the future.
2023-07-27 00:35:26 +02:00
Hornwitser 1a9cbd26b3 Update non-web dependencies
Update non-web related dependencies to their latest version were
possible. It appears yargs has changed its parsing of command line
options to no longer remove a layer of quotes.
2023-07-27 00:35:26 +02:00
Hornwitser a96a8a6d25 Bump minimum supported Node.js version to v18
The LTS releases for v12 and v14 are already end of life and the v16 is
end of life in under two months from now on.  Bump supported version up
to v18 as it does not make sense to continue to support EOL releases.
Even if we wanted to keep supporting them, our dependencies will not.

Note that Node.js v18 is currently not avaliable via the NodeSource PPA
on Ubuntu 19.10 and below or Debian 9 and below.
2023-07-27 00:35:26 +02:00
Hornwitser 93b19110df Merge pull request #485 from Hornwitser/data-class-refactor
Data class refactor
2023-07-27 00:34:57 +02:00
Hornwitser 295cc7543a Reverse argument order for sendTo
It makes more sense to put the address first then have the large
structure to send as the second argument to sendTo.  The opposite order
was originally chosen to be similar to that of sendto in BSD sockets.
2023-07-27 00:24:24 +02:00
Hornwitser 183d2921a8 Up timeout of mod-pack roundtrip test 2023-07-26 14:36:01 +02:00
Hornwitser 156d517976 Prefer putting nested structures on new lines
Format arguments to have nested structures start on a new line instead
of inline to make it easier to see where the structures starts and what
structure it is.
2023-07-26 14:27:49 +02:00
Hornwitser cb21a63ac2 Track forwarded requests to notify if link dies
Keep track of requests that have been forwarded over a link in a similar
manner to requests that were initiated on a link in order to properly
return an error response to the sender of the request if the link goes
down while the request is still pending at the forwarded node.
2023-07-25 21:01:48 +02:00
Hornwitser 0f9224a4ef Add lint-fix script to auto fix linter errors 2023-07-25 17:26:12 +02:00
Hornwitser adb9ebafe1 Fix broken tests under fast-test 2023-07-25 17:25:39 +02:00
Hornwitser 87172dfb8d Implement basic ingress filtering on controller
Validate messages received on the controller from hosts and control
nodes before handling or forwarding them.  This should prevent
performing actions that are not allowed by the role system and block
simple attacks such as spoofing the source address of a message.
2023-07-25 17:21:44 +02:00
Hornwitser 7a9aec74b5 Optimize ModPackViewPage performance
Avoid re-rendering everything by using useCallback and memo to skip
rendering of form elements that haven't changed.
2023-06-11 17:30:29 +02:00
Hornwitser 790b03bc03 Fix accidentally duplicated imports
Import lib instead of lib/data in the messages files to avoid these
getting packed with the web build.
2023-06-11 17:30:24 +02:00
Hornwitser 04b46540fb Fix minimized web_ui build stripping class names
The data class pattern used for request and event payloads require the
class names to be preserved as they are used for identifying the type of
the requests and events.
2023-06-11 17:30:11 +02:00
Hornwitser 7f8a700716 Refactor message registration and validation
Split up the action of registring an event or request data class to be
decoded by the Link class and the action of registring a handler for
those requests and events.  This moves the validation work from Link
instantiation time to startup initialisation which makes logic errors
fail on startup instead of failing instantiation of the link when a
connection attempt is made.

Additionally implement a partial validation logic for messages received
using the newly refactored message registration.  This unfortunately
ended up merged with the registration refactor.
2023-06-03 16:47:47 +02:00
Hornwitser bcd94709d2 Refactor link system to JSON serialisable class pattern
Reverse the direction of the API for sending messages from

    messageDefinition.send(link, { content });

to

    link.send(new MessageClass(content));

and convert all message definitions to use the JSON serialisable class
pattern and explicit registration of message handlers, along with a
end-to-end based routing of messages instead of link to link.

The experience I gained from the existing way messages were defined and
sent over links was that it was difficult to understand for people
unfamiliar with the system.  The automatic registration of message
handlers ment that code was being called as if by magic from nowhere
with no easy way to discover it.  The manual forwarding and rules for
what can be sent where and how obscured its working.

The use of pure JSON as both input and output from message handlers made
it tedious to work with.  Data had to be manually converted between the
program representation and the over the wire format, which made handler
code unplesant to write.  Particularly because the over the wire format
used snake_case while the program code uses camelCase for variables.
The idea behind the decision to do it this way was to make it more clear
which data came from the wire and which from the program, and while this
worked it didn't make message handlers any nicer to write.

The direction of the API and magic registration of message handlers also
meant that a future migration to TypeScript would be troublesome.  While
it may be possible to implement, the degree of TypeScript magic required
would go starkly against the principle of keep it simple stupid.

By refactoring to using the JSON serialisable class pattern most of
these issues go away.  There is no longer a need to distinguish the on
wire format of messages with the program representation because the only
place that deals with the wire format of messages are the toJSON and
fromJSON methods on the classes themselves.  Message handlers will work
with proper instances of classes instead of raw data, something that
makes them easier to write, and trivial to type with TypeScript.

End-to-end routing also simplifies sending messages that need to do
mulitple hops to reach the destination.  And removes the need for having
all messages be specified in plugin's info module, as they can be routed
without having to be parsed completely.

The explicit registration of message handlers is something that I
thought would be tedious boilerplate code that could be automated away.
But experience now shows it to be the opposite.  Allthough dull
repetition, the purpose it serves is to link together the code in a way
that humans can easily find.  With explicit registration searching the
name of the message handler in the editor will show the registration that
ties it together with the type of message it handles and the kind of
registration.  With implicit registration searching the name of the
message handler turns up only the handler with no clue as to what calls
it.
2023-04-23 13:19:46 +02:00
Hornwitser 9a967a5779 Refactor JSON serialiable class pattern
Change the JSON seriasiable data classes to use an explicit static
fromJSON method and an optional toJSON method for turning them to and
from JSON.

This improves over the previous pattern of passing the JSON to the
constructor as it frees up the constructor to be able to take whichever
argument types suits the code instead of being beholden to the JSON
serialised form.
2023-02-25 00:02:12 +01:00
Hornwitser a448d87e3a Fix instanceCreate example 2023-02-24 23:29:06 +01:00
Hornwitser 538159dd71 Remove time dependency in HttpCloser tests (#484)
Attempt to fix the HttpCloser tests failing due to timing issues by
replacing dodgy time based logic with explicit synchronisation.
2023-02-24 15:41:17 +01:00
Hornwitser a8af39e675 Merge pull request #483 from Hornwitser/control-connection-refactor
Control connection refactor
2023-02-24 15:40:59 +01:00
Hornwitser 05e9230e9e Add .id shorthand to InstanceInfo
Turn instance.config.get("instance.id") into instance.id.
2023-02-24 02:00:12 +01:00
Hornwitser f662437b3f Refactor actions out of ControlConnection
Move implementation of instance related actions out of ControlConnection
and into Controller to allow plugins to easily make use of these methods.
2023-02-24 02:00:12 +01:00
Hornwitser 496ebd18a5 Put instance runtime state into InstanceInfo class
Use the data class pattern for storing the instance data on the
controller.  Improves documentation for plugins trying to use it.
2023-02-24 02:00:12 +01:00
Hornwitser 6a2aaf3d26 Merge pull request #481 from Hornwitser/rename-master-slave
Rename master/slave terminology to controller/host
2023-02-23 19:53:42 +01:00
Hornwitser 56106d41eb Add migration mode to installer
Add migration code to installer fixing the names used in configs,
logs, database, and packages to reflect the new controller/host names
for people updating from alpha-13.
2023-02-23 00:12:48 +01:00
Hornwitser a32fb81273 Bump LogIndex version
The rename means that the log index is no longer correct. Bump the
version so that its content is ignored and a new index is generated.
2023-02-23 00:12:48 +01:00
Hornwitser 10528fbb90 Allow pre-rename host tokens
Ease migration to the new controller/host naming by allowing tokens
issued to the slave naming to be authenticated by the controller.
2023-02-23 00:12:48 +01:00
Hornwitser b4259f59cf Match process title with script name
The clusteriocontroller and clusteriohost scripts don't use camelCase so
the process title set by these scripts shouldn't use it either.
2023-02-22 15:50:02 +01:00
Hornwitser 116eceaac4 Remove migration of alpha-10 single file logs
This code is no longer functional after the master/slave rename and has
served its purpose to migrate the exsting logs to the new format.
2023-02-22 15:47:19 +01:00
Hornwitser 6fc3abafd5 Rename Slave to Host
The role of a slave as a host for Factorio servers is not well reflected
in its name and has caused confusion among users.  Rename slave to host
to better reflect its role as a host of Factorio servers.
2023-02-22 15:30:51 +01:00
Hornwitser 70ad295a89 Rename Master to Controller
The role of the master server as the party controlling every part of the
cluster is not well reflected in its name and has caused confusion among
users on several occasions.  Rename master to controller to better
reflects its role.
2023-02-22 15:30:51 +01:00
Hornwitser 6ae4c8ef46 Merge pull request #480 from Hornwitser/small-things-10
Small things 10
2023-02-22 15:28:34 +01:00
Hornwitser 8662243ed1 Show which config entries requires a restart
Add a label in the web UI to config entries that require a restart to
take effect as this is not obvious to end users.
2023-02-21 22:39:23 +01:00
Hornwitser 0af1a25c93 Fix bad identifier in copy save code 2023-02-21 22:39:23 +01:00
Hornwitser 57c7e04f04 Use Summary metric for query log duration 2023-02-21 22:39:23 +01:00
Hornwitser 2960db2cca Add revoke user token action
Implement revoking of user tokens to allow lost credentials to be
invalidated.
2023-02-21 22:39:23 +01:00
Hornwitser 5426d9b2bd Log warning on mismatched slave or plugin versions
Log warning to the master log if a slave connects with a missmatched
version, missmatched plugins or missmatched plugin versions.
2023-02-21 19:35:35 +01:00
Hornwitser 17e4b088f1 Fix crash when game starts tick_paused
Check patch number in update_instance step to ensure the clusterio_api
is properly initialized if the game started tick paused and no ticks
to trigger the patch check have happened yet.
2023-02-20 17:45:10 +01:00
Hornwitser 475182efd0 Ignore techonolgy enabled status for sync
Technologies that were disabled were ignored completely by research sync
due to a missunderstanding of what disabled means.  Disabled does not
mean the tech is somehow inaccessible and completely out of play, it
only means it can't be selected in the technology screen.

Ignore the enabled status of technologies in research_sync to fix a
crash that happened when a mod enables a disabled by default technology
and the player attempts to research it.
2023-02-20 04:45:18 +01:00
Hornwitser c60abdc349 Call fsync on patching saves
Similar to 1b2a20c call fsync on the newly written patched save before
renaming it to prevent data loss in the event of a system crash.
2023-02-17 23:52:31 +01:00
Hornwitser 43e6dd60b3 Removed duplicated rejection handling
Remove code accidentally left out by the change in 50703f8.
2023-02-17 23:52:31 +01:00
Hornwitser b7b7aa737a Merge pull request #479 from Hornwitser/small-things-9
Small things 9
2023-02-16 20:15:26 +01:00
Hornwitser 1b2a20c395 Call fsync on safeOutputFile
If a system crash occurs after a simple write file + rename operation
the target file that was renamed over may end up being empty if the data
for the file written had not yet been committed to disk.  This is
possible because no part of the open file, write data to it, close it
and then rename it ensure the data was actually written to disk prior to
the rename happening. See this discussion on the btrfs wiki[1].

Fix this possible data loss scenario by calling fsync on the temporary
file that is written prior to calling close on it.

[1] https://btrfs.wiki.kernel.org/index.php/FAQ#What_are_the_crash_guarantees_of_overwrite-by-rename.3F
2023-02-16 05:19:04 +01:00
Hornwitser 50703f8ae6 Treat unhandled errors as fatal
All possible Promise rejections and exception bubbling should be handled
by either the code creating the exception or the code invoking the
operation that could fail.  Treat therefore uncaught exceptions and
unhandled promise rejetions as fatal errors that log a traceback and
immedately terminate the Node.js process.
2023-02-16 01:52:42 +01:00
Hornwitser 27633d51b6 Fix log filtering in Web UI
Remove broken alternative logFilter implementation in
web_ui/src/websocket in favour of having one proper
function in lib/logging
2023-02-16 01:22:30 +01:00
Hornwitser 4f6452c90c Fix Web UI not removing log subscriptions 2023-02-16 01:07:42 +01:00
Hornwitser 5bb10ef970 Attribute instance logged errors
Fix errors during startup and from plugins not being correctly
attributed to the instance they originate from.
2023-02-16 01:06:03 +01:00
Hornwitser 59eca88e91 Filter out instance logs from slave console
Exclude instance related logs from the slave console as these are not
significant for the running of the slave and just add noise.
2023-02-16 00:57:54 +01:00
Hornwitser bfe523ac45 Fix disabled mods still being loaded
The format of mod-list.json written on syncMods would incorrectly write
a base mod without an enableds key which is required, causing Factorio
to ignore the file entirerly.  Furthermore the enabled status was not
written causing all mods to be loaded even if they were disabled in the
mod pack.
2023-02-15 20:31:02 +01:00
Hornwitser fda2d7f574 Fix error applying factorio.settings log location
Log to the instance instead of the generic logger when an error occurs
applying a factorio.settings config entry.
2023-02-15 20:31:02 +01:00
Hornwitser 1ba091fd0e Fix WebConsoleFormat not using names
The logging format provides both the name and id of slaves and
instances.  Use the name when formatting for the web console just like
the TerminalFormat does.
2023-02-15 20:31:02 +01:00
Hornwitser 5e01f427af Fix slave crashing when Factorio server is not executable
Handle errors events emitted from the child process created by
lib/factorio/server and translate this error if it's EACCES.
2023-02-15 20:31:02 +01:00
Hornwitser 0341d41165 Fix debug dump-ws command broken by 246ce73 2023-02-15 20:31:02 +01:00
Hornwitser 082d07d718 Fix no way to unassign instances in Web UI 2023-02-15 20:31:02 +01:00
Hornwitser 2d8b35291d Fix incorrect JSDoc async return annotations
The return type of an async function should be documented as a Promise
of what the function returns as this is what JSDoc expects.
2023-02-15 20:31:02 +01:00
Hornwitser a7dedcd3b7 Trim whitespace from token login in Web UI
Prevent accidentally copying spaces before and/or after the token when
pasting it into the login form from failing to log in.
2023-02-15 20:31:02 +01:00
Hornwitser d39b525b07 Add clusterio_master_log_bytes metric
Export a metric for total size of logs stored on the master server.
2023-02-15 20:31:02 +01:00
Hornwitser 351fcba4f8 Print version on startup
Help determining what is running by printing the version that is running
on master and slave startup.
2023-02-15 20:31:02 +01:00
Hornwitser 3d186a24ff Fix incorrect handling of 0 ids
Fix tests of slave and instance ids that incorrectly return the wrong
result when the id is 0.
2023-02-15 20:31:02 +01:00
Hornwitser 5ad89f072f Replace endpointHitCounter
Track summary statistic of total HTTP response durations using a
middleware that applies for all requests instead of having every request
needing to update the hit counter manually.  Deprecates
endpointHitCounter.
2023-02-15 20:31:02 +01:00
Hornwitser 98dc0174d6 Implement basic Summary metric
Provide the basic version of Summary that does not report quantiles in
the prometheus library.  Useful for geting averages of observed values.
2023-02-15 20:31:02 +01:00
Hornwitser d29715e9ad Allow players to list users by default
The rationale for not allowing players to list users in the cluster by
default was that this would be an information disclosure of banned users
in the cluster, but given that a player is a user that can connect to a
server, and anyone that can connect to a server get the full banlist
from the server during the connection phase this does not make much
sense.  Grant permission to list users by default for players.
2023-02-15 20:31:02 +01:00
Hornwitser 58e5d96e1d Annotate error when loading module.json fails
If a plugin has an invalid module/module.json file this should also give
a readable error.
2023-02-15 20:31:02 +01:00
Hornwitser 033de0e20a Validate plugin entrypoint
Check that Master/Instance/Control plugins export the correct class and
that this class is a subclass of the correct base class.  This gives a
better user experience when plugins are incorrectly set up and loads a
duplicated clusterio/lib, and when accidentally using the wrong name
when developing plugins.
2023-02-15 20:31:02 +01:00
Hornwitser 6d7a2d46d6 Fix sorting of last seen column 2023-02-15 20:31:02 +01:00
Hornwitser bf92dde28d Merge pull request #478 from Hornwitser/mod-management
Add mod pack oriented mod management
2023-02-15 18:32:37 +01:00
Hornwitser 0eabdf011b Fix mod loading tests 2023-02-02 01:07:33 +01:00
Hornwitser 91af66e2db Fix export when default mod pack is used 2023-02-02 01:07:08 +01:00
Hornwitser 8db1b48691 Fix export for mods with uncommon root directory
The directory which all files are placed under in a mod is commonly
named <mod-name>_<mod-version>, but this is not a requirement as it can
be named anything.  Fix code incorrectly assuming the zipped mod's root
folder follows this pattern during data export.
2023-02-02 00:28:58 +01:00
Hornwitser 3f6a7e4336 Format mod settings with prototypes and locale in web UI
Use exported settings prototypes and locale to format the settings UI to
with mod names, settings names, and select dropdowns.  This is a crude
approximation of how the game presents these settings to the user.
2023-02-02 00:28:58 +01:00
Hornwitser ddc548f590 Set default values for mod settings on export
Add missing mod settings set to their default values to a mod pack when
performing a data export based on the settings prototypes that were
extracted from the game. This mirrors the behaviour of Factorio when new
setting prototypes are added by a mod.
2023-02-02 00:28:58 +01:00
Hornwitser 39ed098a64 Fix error messages changing with Node.js version
Updates to Node.js error reporting have caused tests to fail due to them
being hardcoded to expect a certain format that has since changed.  Fix
by making Node.js create the error message for us.
2023-02-02 00:28:58 +01:00
Hornwitser 13e12f7335 Tie data exports to mod packs
Store the export manifest in ModPack to allow mulitple mod packs to have
different data exported. This is necessary as the export contains the
settings prototypes needed to create a proper UI for mod settings.

For now the useLocale and useItemMetadata interfaces uses the manifest
for a default mod pack set in the master config.
2023-02-02 00:28:58 +01:00
Hornwitser 7c6aaeb05d Export settings and all prototypes
Add settings and all prototypes to the export of data done.  Settings
are needed for a settings GUI to function, while prototype data would be
useful to display any sort of information about things from the game.
2023-02-01 15:29:23 +01:00
Hornwitser fb75e47eda Mark missing or missmatched checksum for mods in mod packs
Mark mods in the UI and the mod-pack show ctl command that are missing
with a question mark and mark mods with a missmatched checksum with an
exclamation mark.
2022-11-10 00:28:50 +01:00
Hornwitser e6add2f7e6 Fix handling of numeric settings in ModPackViewPage
Due to a mistake in the logic numeric settings would end up getting
converted to string settings when edited.
2022-11-10 00:28:50 +01:00
Hornwitser 32cf9321cc Make sure checksum matches in ModsTable
Validate sha1 sum matches for a mod used in ModsTable if it is present.
This prevents showing details for a zip file where the version matches
but the cheksum does not.
2022-11-10 00:28:50 +01:00
Hornwitser dfadc62720 Prety print database/mod-packs.json 2022-11-10 00:28:50 +01:00
Hornwitser fb4ffaffed Implement mod pack loading for shared mods
Wipe and reconstruct the instances' mods/ folder using the configured
ModPack for the case were the mods folder is shared with the master
server.  This assumes the master server will check the mod checksums
before sending the start request.
2022-11-10 00:28:50 +01:00
Hornwitser 0ebb2f5858 Add import and export mod-pack commands 2022-11-10 00:28:50 +01:00
Hornwitser 87598b24ee Use pre-generated certificate for HttpCloser test (#477)
Remove ugly hack to generate and use an insecure certificate on the fly
in the HttpCloser's https tests and replace it with the already existing
properly generated test certificate. Removes the dependency on node-forge.
2022-11-09 20:18:24 +01:00
Hornwitser 4e9f3ee173 Merge pull request #475 from Danielv123/slaves-generate-random-token
Generate a new random slave token on opening modal
2022-11-09 20:17:54 +01:00
Daniel 4eb4bb9623 Fix slave token modal form validation 2022-10-17 08:23:43 +00:00
Daniel 7bb33554f4 Generate a new random slave token on opening modal 2022-10-16 18:49:48 +00:00
Hornwitser 2ae8270a1d Don't hypenate mod-pack 2022-10-15 18:01:54 +02:00
Hornwitser 20e620993b Use Factorio's version component names
Minor nitpick: The coponent names of the Factorio version is called
main, major, minor.  Change integerFactorioVersion to match.
2022-10-15 18:01:54 +02:00
Hornwitser 660d0f08a1 Fully specify mod pack Factorio version
Remove support for major only versions of Factorio being specified in
mod packs.
2022-10-15 18:01:54 +02:00
Hornwitser 53a5d7ea6f Add ModPack.toModSettingsDat
Add utility function to convert the settings representation of a ModPack
into the mod-settings.dat format which Factorio reads.
2022-10-15 18:01:54 +02:00
Hornwitser fafdac21fb Follow mod pack string proposal
Make all properties on the mod pack object except sha1 required and add
the base mod if it does not exist in order to align the JSON
representation with the proposed Mod Pack String format I published on
the Factorio Forums (https://forums.factorio.com/103578)
2022-10-15 18:01:54 +02:00
Hornwitser f863aee3c2 Add enabled field for mods in mod packs
Make it possible to disable built in mods like base by providing an
enabled field that can be set to false in mod packs.
2022-10-15 18:01:54 +02:00
Hornwitser 2384c48749 Document and expose data members under lib/data
Make members of lib/data exposed under lib/data instead of
lib/data/<member>.
2022-09-26 16:54:19 +02:00
Hornwitser 7072b94355 Derive mod filename from name and version
Enforce the filename of uploaded mods match {name}_{version}.zip with
name and version taken from the zip file's info.json entry.  This allows
it to be used as a unique key for the mod and solves part of the issues
with mods having different number of leading zeros in their version.
2022-09-26 00:34:25 +02:00
Hornwitser 7d34c60dd1 Break out version to integer functions
Move the multiple implementation of version to integers calculation to
dedicated functions in one place.
2022-09-25 23:33:22 +02:00
Hornwitser af79e2abbd Remove includeHash option to toModPackString
Since the Mod Portal provides SHA1 hashes it makes sense to always
include the hashes with mod pack strings if they are available.
2022-09-24 21:49:41 +02:00
Hornwitser 9b4ca86650 Rename mod hash field to sha1
The Factorio Mod Portal uses and provides SHA1 hashes of mods, so it
makes sense to align the ModPack and ModInfo structures to sha1 by
renaming the genericly named hash property to sha1.
2022-09-24 21:43:07 +02:00
Hornwitser bddde30be4 Add web UI for mod packs
Implement create, import, show, export, update, and delete UI for mod
packs on the master server.
2022-09-24 21:17:18 +02:00
Hornwitser 3eb63db313 Add search mods request
Add paged search mods request suitable for showing a real time list of
results for a user provided search expression.
2022-09-24 21:10:38 +02:00
Hornwitser 6039715a53 Wrap section header extra content under the header
Make the extra content for section headers flow under the heading when
there is not enough space to show both the heading and the extra content
in a single line.
2022-09-24 00:49:56 +02:00
Hornwitser 85cec761e1 Use overflow clip in site layout
Use overflow clip instead of hidden in the page area of the site layout
to avoid creating a formatting context that would make sticky not work
as expected inside the page area.
2022-09-24 00:46:38 +02:00
Hornwitser 476f460f75 Add react wrapper for navigator.clipboard
Add a thin wrapper of the navigator.clipboard interface suitable for
use with React.
2022-09-24 00:44:13 +02:00
Hornwitser 40a0b929d0 Break mod details out into a component 2022-09-24 00:39:16 +02:00
Hornwitser 82c6448149 Use no-break space in formatBytes
Prevent tables cells containing byte sizes from having a line break
inserted between the number and the byte unit when they get squished by
using a no-break space instead of a regular space.
2022-09-24 00:30:18 +02:00
Hornwitser 9fb3d6d8db Implement mod pack storage
Add ModPack data type with corresponding storage on the master, messages
to create, update and delete them, as well as commands to invoke them.
The ModPack data type contains a list of mods with versions and optional
sha1 hashes, and mod settings to be used.
2022-09-16 02:44:06 +02:00
Hornwitser dfcbc559b8 Add parseSearchString to helpers
Implement parsing of a search string into terms which can be used to
match results with them.
2022-09-16 02:44:06 +02:00
Hornwitser 3b57c1e0ae Add shared mods management
Add the ability to manage a set of mods to be shared by the master
server to the rest of the cluster.  This implements storage, uploads,
downloads, deletetion, and listing details of mods but does not actually
do anything with the stored mods.
2022-09-13 19:15:46 +02:00
Hornwitser 2ab915cc00 Move formatBytes to libHelpers 2022-08-24 15:42:26 +02:00
Hornwitser 921ebad8fb Allow importing libHash to succeed in browser
Alsthough this does not make libHash work in the browser it allows other
modules to require libHash while still being exported to the browser.
2022-08-24 15:42:26 +02:00
Hornwitser a40fefc8d7 Pull findRoot out into libZipOps
The findRoot function useful for managing mod zip files on the master.
2022-08-24 15:42:26 +02:00
Hornwitser 9a1c7b2ecd Bad Windows names also apply with any extension
A file name con.zip is also treated in the same way as the special CON
redirection and may not be used as a normal filesystem path in Windows.
This includes any extension, even with multiple dots.  Fix checkFilename
and cleaneFilename not handling these cases.
2022-08-24 15:42:26 +02:00
Hornwitser 14934a0d3a Move checkFilename and cleanFilename to libFileOps
These are also useful for managing mod zip files on the master.
2022-08-24 15:42:26 +02:00
Arvids Godjuks e82914c4e1 Merge pull request #473 from rstrom1763/master
Corrected assorted spelling and grammar in README
2022-07-30 23:38:28 +03:00
Ryan 190db2f98b Corrected assorted spelling and grammar in README
Corrected assorted spelling and grammar in the README.md file.
2022-07-30 15:02:26 -05:00
Hornwitser 5570d2da38 Update instructions for publishing
Manual workflow for now.
2022-07-20 15:27:37 +02:00
Hornwitser 949ba708ea Hotfix installer wiping package.json
Fix the installer wiping the package.json file due to a call to
outputFile that needed to target the original file being incorrectly
changed to safeOutputFile.
2022-07-20 14:40:41 +02:00
444 changed files with 35997 additions and 22451 deletions

No files matched your search

+1 -2
View File
@@ -13,8 +13,7 @@ factorio
factorio.tar.gz
# We build internal mods during build, mods from local test env should not be applied
sharedMods
sharedModsDisabled
mods
# These files should never be required during the build
.git
+66 -21
View File
@@ -1,8 +1,10 @@
"use strict";
module.exports = {
"plugins": [
"@typescript-eslint",
"node",
],
"root": true,
"env": {
"node": true,
"commonjs": true,
@@ -10,20 +12,40 @@ module.exports = {
},
"overrides": [
{
"files": ["{packages,plugins}/**/*.ts?(x)"],
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"prefer-const": "off",
},
},
{
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
"env": {
"mocha": "true",
},
"parserOptions": { sourceType: "commonjs" },
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"valid-jsdoc": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
"files": ["packages/web_ui/src/**/*.jsx", "packages/lib/browser.js", "plugins/*/web/**/*.jsx"],
"files": [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
"env": {
"browser": true,
},
@@ -108,7 +130,7 @@ module.exports = {
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"default-param-last": "off",
"dot-location": ["error", "property"],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"eol-last": "error",
@@ -116,7 +138,11 @@ module.exports = {
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration"],
"func-style": ["error", "declaration", {
// Allow arrow functions as it better expresses referencing this
// of the containing scope, which is unweilding in plain functions.
"allowArrowFunctions": true,
}],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
@@ -136,7 +162,7 @@ module.exports = {
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always"],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": ["error", { "code": 120 }],
@@ -148,7 +174,10 @@ module.exports = {
"max-statements-per-line": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": "error",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
@@ -159,7 +188,7 @@ module.exports = {
"no-bitwise": "off",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "error",
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-console": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
@@ -223,7 +252,7 @@ module.exports = {
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["yargs"] }],
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"no-spaced-func": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
@@ -235,12 +264,12 @@ module.exports = {
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "error",
"no-use-before-define": "off",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-constructor": "off",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
@@ -260,6 +289,7 @@ module.exports = {
":": "before",
"||": "before",
"&&": "before",
"??": "before",
},
}],
"padded-blocks": "off",
@@ -296,14 +326,22 @@ module.exports = {
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["error", "always"],
"spaced-comment": ["error", "always", {
"line": {
"markers": ["/"],
},
}],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", { "requireReturn": false }],
"valid-jsdoc": ["error", {
"requireReturn": false,
"requireReturnType": false,
"requireParamType": false,
}],
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
@@ -313,14 +351,17 @@ module.exports = {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "error",
"node/handle-callback-err": "error",
"node/no-callback-literal": "error",
"node/global-require": "off",
// Node.js callback passing style of (err, value) is not used in this project
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-extraneous-import": "error",
"node/no-extraneous-require": "error",
"node/no-missing-import": "error",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": ["error", {
"tryExtensions": [".js", ".ts"],
}],
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
@@ -335,9 +376,9 @@ module.exports = {
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unsupported-features/es-builtins": "error",
"node/no-unsupported-features/es-syntax": "error",
"node/no-unsupported-features/node-builtins": "error",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
@@ -348,6 +389,10 @@ module.exports = {
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/$1.js"],
},
}],
},
};
+13 -15
View File
@@ -12,30 +12,28 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 16.x]
node-version: [18.x, 20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
with:
version: 6
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- run: wget -q -O temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- name: Run tests
if: ${{ matrix.node-version != '12.x' }}
if: ${{ matrix.node-version != '18.x' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '12.x' }}
if: ${{ matrix.node-version == '18.x' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '12.x' }}
if: ${{ matrix.node-version == '18.x' }}
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info
@@ -43,12 +41,12 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2.2.2
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
with:
version: 6
- uses: actions/setup-node@v2
version: 8
- uses: actions/setup-node@v3
with:
node-version: 16.x
node-version: 18.x
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm run lint
+3 -3
View File
@@ -32,12 +32,12 @@ node_modules
Factorio*
/factorio
/instances
/sharedMods
/mods
/database
/plugin-list.json
/config-master.json
/config-controller.json
/config-control.json
/config-slave.json
/config-host.json
pnpm-lock.yaml
mod_cache.json
/static/
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!settings.json
+11
View File
@@ -0,0 +1,11 @@
{
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"[markdown]": {
"editor.indentSize": 4,
"editor.insertSpaces": true,
},
}
+130 -21
View File
@@ -1,5 +1,115 @@
# Changelog
## Version 2.0.0-alpha.16
## Changes
- Fixed message sequences not being renumbered when forwarded between links causing reconnects to send duplicated messages.
## Version 2.0.0-alpha.15
### Features
- Added support for `color-settings` Mod settings [#568](https://github.com/clusterio/clusterio/issues/568).
- Added `clusterio_controller_pending_requests` and `clusterio_host_pending_requests` metrics.
### Changes
- Fixed generated systemd file for clusteriohost not enabling source maps.
- Fixed ctl not exporting types used for adding commands to ctl plugins.
- Fixed controller only plugins throwing an error about missing a config field on startup out when added to hosts.
- Fixed installer incorrectly claiming the options --factorio-dir and --no-download-headless conflicts with each other.
- Fixed a memory leak on every request sent over the WebSocket.
- Fixed assertion error on closing or invalidating a link with messages forwarded from a virtual link.
- Fixed host hanging if stopped while an instance is in the process of creating a save during startup.
- Fixed `instance.auto_start` option.
- Fixed host log spammed with errors when plugins broadcast messages to instances with the plugin disabled.
- Fixed properties of object config fields not being possible to remove.
## Version 2.0.0-alpha.14
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
### Major Features
- Added mod management. Mods can be uploaded to the controller and included in mod packs that can be assigned to instances and run.
- Added host port ranges which the instance ports will be automatically assigned a port from if factorio.game_port is not set.
- Added system info such as CPU model, hostname and kernel along with basic metrics for CPU usage, memory usage and disk usage for the controller and hosts.
- Added ability to remotely view and modify the configuration of hosts.
### Features
- Added logging of Clusterio version on startup
- Added `clusterio_controller_log_bytes` metric counting the size of the log files on the controller.
- Added warning logged when plugins on a connecting host does not match the ones installed on the controller.
- Added ability to revoke user tokens.
- Added restart required indicator to config fields in the Web UI.
- Added ability to revoke host tokens.
- Added support for plugins to run in the context of a host.
- Added more feedback when the web UI and or its plugins fails to load.
- Added autosaving of data held in memory on the controller such as configs, instances, users, mods to prevent large data loss on crashing.
- Added `system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of the controller.
- Added `clusterio_host_system_info` metric with kernel, instruction set, CPU Model, hostname and Node.js version of hosts.
- Added `system_cpu_seconds_total`, `system_cpu_idle_seconds_total`, `system_memory_capacity_bytes`, `system_memory_available_bytes`, `system_disk_capacity_bytes`, `system_disk_available_bytes` metrics for CPU, memory and disk usage on the controller.
- Added `clusterio_host_system_cpu_seconds_total`, `clusterio_host_system_cpu_idle_seconds_total`, `clusterio_host_system_memory_capacity_bytes`, `clusterio_host_system_memory_available_bytes`, `clusterio_host_system_disk_capacity_bytes`, `clusterio_host_system_disk_available_bytes` metrics for CPU, memory and disk usage on hosts.
- Added support for reading the client's IP address from the `X-Forwarded-For` header when the source address is in the `controller.trusted_proxies` config option.
- Added field showing the IP address a host is connecting from.
- Added per player statistic tracking when they were first seen joining a particular server.
- Added ability to remotely restart and/or stop the controller and hosts.
### Changes
- Fixed filenames with extensions that are invalid on Windows such as "con.zip" not being treated as invalid.
- Fixed generate host token modal not using a random id when the optional id field is unset.
- Exported Factorio data is now tied to mod packs instead of being per cluster.
- Fixed export failing if a mod uses a different root folder mod_version in its zip file.
- Fixed sorting of user last seen column in the Web UI.
- The list users permission is now granted by default to players.
- Fixed incorrect handling of hosts and instance with an id of 0 causing them to not update properly.
- Fixed unable to unassign instances in the Web UI.
- Fixed host crashing if the Factorio server is not marked executable.
- Fixed errors logged from instance not being attributed to the instance in certain situations.
- Hosts no longer print messages from instances into their console.
- Clusterio now treat unhandled Node.js errors and promise rejections as fatal and exit immediately.
- Fixed a potential for data loss after a hard system crash due to fsync not being called on files written.
- Fixed Factorio server crashing in the research_sync plugin when a mod enables a previously disabled technology and a player attempts to research it.
- Fixed Factorio server crashing when the game starts with tick_paused = true
- Removed migration of alpha 10's single file log format.
- Log errors linking mods on Windows instead of preventing startup.
- Added locale support for modules patched into to the game save.
- Added support for defining a module_exports.lua file for modules patched into the game save.
- Fixed controller logs being split on server's local time day boundary instead of UTC day boundary.
- Fixed Unicode BOM in mod locale files breaking instance data export.
- Added a warning to the server-setting.json that is automatically written to instance folders stating changes will be overwritten.
- Changed save patching to use a `.tmp.zip` extension for the temporary file created instead of `zip.tmp`.
- Fixed Inventory Sync wiping the player's inventory when logging out while in the Space Exploration remote view mode.
- Fixed the public /api/plugins endponit leaking filesystem paths on the controller.
- Refactored list subscriptions in the Web UI to cache and only fetch updated entries when navigating to previously seen list.
- Added caching of save lists on the controller, enabling the saves of instances on offline hosts to still be seen.
- Hosts in the assign instance dropdown is now sorted by name.
- Added `non_blocking_saving` as one of the default settings added to `factorio.settings` on new instances.
### Breaking Changes
- Renamed usage of the terms master and slave to controller and host in all places they occur.
- Mods located in `sharedMods` are no longer copied to instances' mods folder.
- The instances' mods folder is now managed through mod packs, mods not part of the mod pack will be automatically deleted.
- Removed ability to import sub packages like `@clusterio/lib/link` from.
- Replaced `clusterio_controller_http_hits_total` metric with `clusterio_controller_http_endpoint_duration_seconds`.
- Replaced `clusterio_controller_last_query_log_duration_seconds` metric with `clusterio_controller_query_log_duration_seconds` summary.
- Reworked the network protocol to be simpler to work and reason with.
- Bumped minimum supported Node.js version to v18.
- Ported the codebase to TypeScript
- Changed the config format stored on disk to be much simpler.
### Contributors
Many thanks to the following for contributing code in this release:
[@Hornwitser](https://github.com/Hornwitser) [@Luciole0x](https://github.com/Luciole0x) [@Cooldude2606](https://github.com/Cooldude2606) [@Danielv123](https://github.com/Danielv123) [@Reavershark](https://github.com/Reavershark) [@Laar](https://github.com/Laar) [@rstrom1763](https://github.com/rstrom1763)
## Version 2.0.0
### Major Features
@@ -8,11 +118,11 @@
Removed the Hotpatch scenario and the depency on it for getting code into the game.
Added a save patcher than runs before starting up Factorio that patches in lua modules based on the event_loader lib into the savegame.
Regular freeplay games can now be used with Clusterio and will be compatible without having to convert them to Hotpatch.
- Daemonized slaves.
Slaves now have the ability to run multiple Factorio instances, starting and stopping them individually.
To manage this the local command line interface has been replaced with a remote interface on the master server that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between slaves and the master from scratch.
The new system is based on a WebSocket connection between the slaves and the master server and provides efficient validated bi-directonal communication.
- Daemonized hosts.
Hosts now have the ability to run multiple Factorio instances, starting and stopping them individually.
To manage this the local command line interface has been replaced with a remote interface on the controller that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between hosts and the controller from scratch.
The new system is based on a WebSocket connection between the hosts and the controller and provides efficient validated bi-directonal communication.
- Rewritten the plugin system from scratch.
Plugins now inherit from a base class and use the same WebSocket connection Clusterio uses to communicate.
- New configuration system with support for initializing configs when needed, modifying config entries using built-in commands, and updating instance configuration remotely.
@@ -24,9 +134,9 @@
### Features
- Added export of pollution statitics.
- Reconnection logic that esures no data is dropped talking to the master server provided the session can be resumed.
- Reconnection logic that esures no data is dropped talking to the controller provided the session can be resumed.
- Added support for having multiple Factorio installs and selecting which version to use on a per instance basis.
- Added config to specify which path the master interface is accessed under, allowing it to be proxied behind web-server.
- Added config to specify which path the controller interface is accessed under, allowing it to be proxied behind web-server.
- Added WebSocket usage statistics.
- Added commands sent statistic.
- Added Factorio server CPU and memory usage statistic.
@@ -37,18 +147,18 @@
Previously this was handled by the playerManager plugin.
- Added stripping of long paths in the Factorio server log.
- Added option to configure maximum number of commands to send in parallel.
- Added option to auto start instances on slave startup.
- Added metric mapping of slave and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
- Added option to auto start instances on host startup.
- Added metric mapping of host and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
- Added option to configure timeout for exported Prometheus metrics.
- Added option to set address to bind HTTP(S) port to.
- Added an overview of the installed plugins to the web interface.
- Added clusterioctl command to list plugins on the master server.
- Added clusterioctl command to list plugins on the controller.
- 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
- Added error handling during master startup.
- Added error handling during controller startup.
- Factorio game and rcon port now defaults to a random port above 49151.
- Removed unimplemented mods update command.
- Fixed rcon password being generated with Math.random().
@@ -69,14 +179,14 @@
- Removed factorio_version from config.
The version installed is auto detected and used instead.
- Removed the playerManager specific command CLI tools/delete_player.js.
- Creating an instance, assigning it to a slave, creating a save for an instance and starting an instance is now four separate commands.
- Removed oddball limits to slaves.json size.
- Moved slave specific and instance specific configuration into their own configuration files.
- Creating an instance, assigning it to a host, creating a save for an instance and starting an instance is now four separate commands.
- Removed oddball limits to hosts.json size.
- Moved host specific and instance specific configuration into their own configuration files.
- Removed unused binary option from plugin config.
- Removed info and shout command from globalChat plugin - Removed mirrorAllChat and enableCrossServerShout configuration options for globalChat plugin.
- Removed UPSdisplay plugin.
UPS statistics is exported by the statistics exporter plugin.
- Master server now defaults to hosting on https on port 8443.
- Controller now defaults to hosting on https on port 8443.
- Renamed client to slave.
- Removed rotation of factorio-current.log files.
@@ -100,7 +210,7 @@
- Removed the remoteCommands plugin and the old runCommand interface.
Breaks playerManager and external tools sending commands.
- Removed broken serverManager plugin.
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the slaves in the slave database.
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the hosts in the host database.
- Removed getInstanceName and lib/clusterTools.
Breaks playerManager, and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints.
@@ -109,7 +219,7 @@
Breaks researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake.
Breaks playerManager, trainTeleports, serverSelect, and discordChat
- Changed the format of database/slaves.json.
- Changed the format of database/hosts.json.
- Removed the output file subscription system.
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins.
@@ -134,17 +244,16 @@
- Removed msBetweenCommands config option.
The RCON is instead limited to 5 concurrent commands.
- Removed allowRemoteCommandExecution config option.
Remote commands are always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server.
Remote commands are always allowed with the move to controller managing hosts/instances.
- Removed `--databaseDirectory`, `--controllerPort`, and `--sslPort` command line arguments from the controller.
- Implemented a new config system that replaces the old.
Breaks all plugins.
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
- Renamed clusterioMod plugin to subspace_storage.
- Master no longer creates secret-api-token.txt.
- Controller no longer creates secret-api-token.txt.
- Removed automatic creation of self-signed TLS certificate.
- Node.js versions below 12 are no longer supported.
## Version 1.2.4
- Removed broken remote combinator signaling.
+8 -8
View File
@@ -1,4 +1,4 @@
FROM node:12 as subspace_storage_builder
FROM node:20 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/subspace_storage.git
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:12 as clusterio_builder
FROM node:20 as clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
@@ -20,17 +20,17 @@ RUN pnpm install
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
# If you want a different set of plugins, consider using this as the base image for your own.
#RUN pnpm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
#RUN npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/mods/
# Build Lua library mod
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
&& mv dist/* sharedMods/ \
RUN node packages/lib/build_mod --build --source-dir packages/host/lua/clusterio_lib \
&& mv dist/* mods/ \
&& mkdir temp \
&& mkdir temp/test \
&& cp sharedMods/ temp/test/ -r \
&& ls sharedMods
&& cp mods/ temp/test/ -r \
&& ls mods
# Remove node_modules
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
+70 -55
View File
@@ -12,7 +12,7 @@ Discord for development/support/play: https://discord.gg/5XuDkje
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.
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
### Ways to support me/the project:
@@ -33,12 +33,13 @@ If you are starting a new cluster it's highly recommended to use the 2.0 alpha.
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Configure Controller](#configure-controller)
* [Managing Factorio mods](#managing-factorio-mods)
* [Setting up shared storage](#setting-up-shared-storage)
* [Running Clusterio](#running-clusterio)
* [Setting up remote slaves](#setting-up-remote-slaves)
* [Setting up remote hosts](#setting-up-remote-hosts)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Known issues](#known-issues)
* [Common problems](#Common-problems)
@@ -49,7 +50,7 @@ It was previously best known for implementing cross server transfer and cloud st
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.
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready-made plugins you can install into a Clusterio cluster.
## Features
@@ -71,7 +72,7 @@ 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.
- [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 controller.
- [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.
@@ -80,7 +81,7 @@ There's also plugins developed and maintained by the community:
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge) (@hornwitser/discord_bridge): Bridges chat between instances and Discord.
By Hornwitser.
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
Originally by Godmave, ported to 2.0 by Hornwitser.
Want to make your own plugin?
@@ -89,8 +90,8 @@ Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for w
## Installation
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 v18 and up, it's distributed via npm and comes with a guided install script.
If you already have a recent Node.js installed, you can set it up in a new directory with:
npm init "@clusterio"
@@ -99,11 +100,11 @@ Otherwise see below for OS specific instructions.
### 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.
1. Install Node.js v18 or higher.
Note the version of Node.js provided by the Ubuntu/debian repos are too old and you will have to use the nodesource PPA
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt install nodejs
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
sudo apt-get install -y nodejs
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
@@ -126,9 +127,9 @@ Clusterio has *very* limited support for using docker.
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 controller -e MODE=controller -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:/clusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
sudo docker run --name host -e MODE=host -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.
@@ -145,7 +146,7 @@ Your instances (save files etc) will be stored there.
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 Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
3. If you chose to use local factorio directory for the Factorio installation, 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
@@ -160,12 +161,12 @@ Your instances (save files etc) will be stored there.
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 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.
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 the `factorio` folder in your clusterio installation folder.
### Installing Plugins
For well known plugins you can select them during installation and no further steps are necessary to make them work.
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
@@ -173,96 +174,110 @@ First install the package the plugin is provided by via npm, for example
Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by master, slave and ctl.
If you have slaves or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins, instead use the clusterio command you have available.
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller 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).
## Configure Master Server
## Configure Controller
By default the master server will listen for HTTP on port 8080.
By default the controller will listen for HTTP on port 8080.
You can change the port used with the command
npx clusteriomaster config set master.http_port 1234
npx clusteriocontroller config set controller.http_port 1234
When changing the port you will also need to change the address slaves connect with.
When changing the port you will also need to change the address hosts connect with.
For the standalone installation mode you can use
npx clusterioslave config set slave.master_url http://localhost:1234/
npx clusteriohost config set host.controller_url http://localhost:1234/
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriomaster config set master.external_address http://203.0.113.4:1234/
npx clusteriocontroller config set controller.external_address http://203.0.113.4:1234/
Change the url to reflect the IP, protocol, and port the master server is accessible under, dns names are also supported.
If you're planning on making the master server accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
Change the url to reflect the IP, protocol, and port the controller is accessible under, dns names are also supported.
If you're planning on making the controller accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
You can list the config of the master server with the `npx clusteriomaster config list` command.
See the [readme for @clusterio/master](/packages/master/README.md) for more information.
You can list the config of the controller with the `npx clusteriocontroller config list` command.
See the [readme for @clusterio/controller](/packages/controller/README.md) for more information.
## Managing Factorio mods
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.
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 host installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that host 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.
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.
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.
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.
The chests teleporting items to and from the shared storage on the controller 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 host 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 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.
After completing the installation start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller 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.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host 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:
The install script creates systemd service scripts for clusteriocontroller and clusteriohost (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
sudo systemctl start clusteriocontroller # for the controller
sudo systemctl start clusteriohost # for each physical server intended to host Factorio servers
#### 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
sudo systemctl enable clusteriocontroller # for the controller
sudo systemctl enable clusteriohost # for each physical server intended to host Factorio servers
## Setting up remote slaves
## Setting up remote hosts
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.
Run the installer as described in the installation section and choose "Host only" as the operating mode to install, it'll ask for a controller URL and an authentication token.
The URL is the same as what is needed to connect to the web interface, and the authentication token can be generated on the Hosts page in the web interface.
Once you start up the host it should show up in the Hosts list and be available for assigning and running instances on.
## Setting up clusterioctl
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
There's a command line interface available for Clusterio which is installed separately with the same installer as for the controller and host.
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 controller 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 clusteriomaster bootstrap generate-user-token <username>
npx clusteriocontroller bootstrap generate-user-token <username>
## Known Issues
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
This should be ignored as it does not indicate an error occured.
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
To workaround this find the id of the host in the host config and generate a new host token with the same id.
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
```
localStorage.controller_token = localStorage.master_token
```
## Common problems
@@ -276,7 +291,7 @@ According to [this link](https://askubuntu.com/questions/839520/open-port-443-fo
sudo setcap 'cap_net_bind_service=+ep' $(readlink -f $(which node))
### Portforwarding doesn't work on the master server when running under WSL
### Port forwarding doesn't work on the controller when running under WSL
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip.
This is also true when you correctly port-forwarded the correct ports.
+4 -4
View File
@@ -1,9 +1,9 @@
version: '3'
services:
master:
controller:
build: .
command: npx clusteriomaster run
slave:
command: npx clusteriocontroller run
host:
build: .
command: npx clusterioslave run
command: npx clusteriohost run
+2 -2
View File
@@ -11,8 +11,8 @@ Links represents one side of a two way communication pipe, connectors deal with
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
The link also knows what the endpoint types it's connected to are via the source and target attributes.
The endpoint types currently implemented are master, slave, instance, and control.
Although slave and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
The endpoint types currently implemented are controller, host, instance, and control.
Although host and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
Upon creation the link will register with the connector in order to receive and process messages from it.
The messages received are validated using the validator registered for the type of the message.
+22 -24
View File
@@ -2,36 +2,34 @@
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Clusterio uses a group based configuration system to manage settings for the master server, instances, slaves, and the clusterioctl utility.
Plugins get their own config group for the master server and instances which they can add their own fields to.
The fields are defined early in the startup, and the groups and their fields for disabled plugins will still be created.
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
Plugins can add their own config fields for the controller and instances configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config groups and their fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config groups to the `MasterConfigGroup` and `InstanceConfigGroup` properties of the `info.js` export using subclasses of `PluginConfigGroup`.
The plugin group has the enabled field pre-defined on it, and it's used to enable/disable plugins.
The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
## Defing Fields
Fields are defined using the `define` class method on group classes after the groupName have been set and before the class has been finalized.
The `define` method takes an object as argument with the following properties:
Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field.
The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot.
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.lavel`.
**access**:
The locations this config field can be read and modified from.
This is an array of strings which can contain the values `"master"`, `"slave"` and `"control"`.
This is an array of strings which can contain the values `"controller"`, `"host"` and `"control"`.
If a party is missing in this array then that party will not receive the value of this field when the config is shared, and will be unable to modify the field.
Takes the value of `defaultAccess` set on the config group class if not passed.
Note that instance config groups should have both `"master"` and `"slave"` for all of its fields or unexpected behaviour may occur.
If not set any party can read and modify this config entry.
Note that instance config fields should have both `"controller"` and `"host"` or unexpected behaviour may occur.
**type**:
The type of config value this field will support.
The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type.
**name**:
Name of the configuration field, this is the primary way the field is accessed, both in code and on the command line.
As such it should follow the lower\_case\_underscore style and not contain any spaces or dots.
**title** (optional):
Text used to identify the config field in user interfaces.
This should not contain the grouping used in `name`.
**description** (optional):
Text used to describe the config field in user interfaces.
@@ -44,7 +42,7 @@ The `define` method takes an object as argument with the following properties:
True if this field can be set to null.
Defaults to false.
**initial_value** (optional if optional is true):
**initialValue** (optional if optional is true):
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
@@ -53,26 +51,26 @@ If the value for a field stored in on disk somehow ends up being invalid the fie
## Accessing Fields
On the Config instance there's two ways to access fields: through the set and get methods using dot notation.
On the Config instance there's two ways to access fields: through the set and get methods.
E.g.:
let value = configInstance.get("group.field");
Or by getting the group first and then calling set or get on that.
Or by through the raw fields object (which is best avoided as it doesn't check types or whether the accessed field exists).
E.g.:
let value = configInstance.group("group").get("field")
let value = configInstance.fields["group.field"]
The set method takes an extra argument, which is the value to set the field to.
If the value is not valid this will throw an InvalidValue exception, if the group or field doesn't exist it will throw an InvalidField exception.
The set method also takes an extra argument, which is the value to set the field to.
If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception.
## Config Lifecycle
Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded.
Once a field has a value it is never automatically removed, even if the field or group it was part of no longer exists.
Once a field has a value it is never automatically removed, even if the field it was part of no longer exists.
This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed.
For instance configs, they are kept on the master server and serialized and sent over to the relevant slaves on startup.
This makes it possible to edit instance configs when the slave the instance is on is offline.
A copy is also stored in the instance directory on the slave, and this config will be deliverd to the master server on slave startup, which enables instances to be copied between clusters as long as their IDs are unique.
For instance configs, they are kept on the controller and serialized and sent over to the relevant hosts on startup.
This makes it possible to edit instance configs when the host the instance is on is offline.
A copy is also stored in the instance directory on the host, and this config will be deliverd to the controller on host startup, which enables instances to be copied between clusters as long as their IDs are unique.
+67 -67
View File
@@ -1,9 +1,9 @@
# Configuration
## Master Configuration
## Controller Configuration
### master.name
### controller.name
Name of the cluster.
Used to distinguish it from other clusters and is prepended to the name of servers in the server list.
@@ -11,37 +11,37 @@ Used to distinguish it from other clusters and is prepended to the name of serve
Defaluts to "Your Cluster".
### master.database_directory
### controller.database_directory
Directory used to store cluster data to.
Needs to be writeable by the master server.
Needs to be writeable by the controller.
This is also used by plugins to store its data.
Defaults to "database".
### master.http_port
### controller.http_port
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and master.https_port needs to be set to a port.
At least one of this and controller.https_port needs to be set to a port.
Defaults to 8080.
### master.https_port
### controller.https_port
Port to host HTTPS server on.
Uses master.tls_certificate and master.tls_private_key as the certificate and private key for the HTTPS server.
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and master.http_port needs to be set to a port.
At least one of this and controller.http_port needs to be set to a port.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
Defaults to null.
### master.bind_address
### controller.bind_address
IP address to bind the HTTP and HTTPS ports on.
Useful to limit which interface to accept connections from.
@@ -51,39 +51,39 @@ interfaces.
Defaults to null.
### master.external_address
### controller.external_address
External address the master server is accessible on.
Currently only used for `clusteriomaster bootstrap create-ctl-config` in order to give the right url to connect to.
External address the controller is accessible on.
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### master.tls_certificate
### controller.tls_certificate
Path to TLS certificate to use for the HTTPS server when master.https_port is configured.
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
Defaults to "database/certificates/cert.crt".
### master.tls_private_key
### controller.tls_private_key
Path to TLS private key to use for the HTTPS server when master.https_port is configured.
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
Defaults to "database/certificates/cert.key".
### master.auth_secret
### controller.auth_secret
Base64 encoded authentication secret used to verify tokens issued to slaves, users, and sessions.
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
### controller.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
@@ -92,33 +92,33 @@ A lower value means less time between connections going stale and them being clo
Defaults to 15.
### master.session_timeout
### controller.session_timeout
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.
In the case of the controller or client crashing the data loss is unavoidable.
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`.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
Defaults to 60.
### master.metrics_timeout
### controller.metrics_timeout
Timeout in seconds before a call to gather metrics from a slave times out.
Timeout in seconds before a call to gather metrics from a host times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
Defaults to 8.
### master.stream_timeout
### controller.stream_timeout
Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed.
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Defaults to 15.
### master.default_role_id
### controller.default_role_id
Role to automatically grant to new users.
If null no role is granted.
@@ -128,30 +128,30 @@ Defaults to 1 which correspond to the default Player role.
### <plugin_name>.load_plugin
Whether to load the plugin on the master server.
Plugins not loaded on the master server will not be loaded on instances.
Whether to load the plugin on the controller.
Plugins not loaded on the controller will not be loaded on instances.
Defaults to true.
## Slave Configuration
## Host Configuration
### slave.name
### host.name
Name of the slave.
Shows up in slave lists and is used to reference this slave in the clusterioctl command line interface.
Name of the host.
Shows up in host lists and is used to reference this host in the clusterioctl command line interface.
Defaults to "New Slave".
Defaults to "New Host".
### slave.id
### host.id
Immutable numeric id of the slave which uniquely identifies this slave in the cluster.
Immutable numeric id of the host which uniquely identifies this host in the cluster.
Defaults to a random 31 bit number.
### slave.factorio_directory
### host.factorio_directory
Directory to look for the Factorio server(s) in.
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
@@ -159,48 +159,48 @@ This can either point to a Factorio server directory, or to a directory containi
Defaults to "factorio".
### slave.instances_directory
### host.instances_directory
Directory to store instances in.
One sub-directory will be created in it for each instance assigned to this slave and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
One sub-directory will be created in it for each instance assigned to this host and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
Defaults to "instances".
### slave.master_url
### host.controller_url
URL to connect to the master server to.
URL to connect to the controller to.
Defaults to "http://localhost:8080/".
### slave.master_token
### host.controller_token
Access token used for authenticating with the master server.
You can generate an access token with `clusterioctl slave generate-token --id <slave-id>`, or use the `clusterioctl slave create-config` to create a new slave config with the correct url and token in it.
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
Defaults to "enter token here".
### slave.tls_ca
### host.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the master server.
If you have a self signed certificate on the master server you will need to copy the certificate to your slaves and set it as the certificate authority with this option.
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### slave.public_address
### host.public_address
External address instances hosted on this slave can be accessed on.
External address instances hosted on this host can be accessed on.
This is used by plugins like Server Select to give the correct address to switch between instances in-game.
Defaults to "localhost".
### slave.max_reconnect_delay
### host.max_reconnect_delay
Maximum delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
Maximum delay in seconds to wait after connection to the controller 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 60.
@@ -222,19 +222,19 @@ Imutable numeric id of the instance which uniquely identifies this instance in t
Defaluts to a random 31 bit integer.
### instance.assigned_slave
### instance.assigned_host
Slave this instance is assigned to.
Host this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a slave the directory for the instance is created on the slave.
After being assigned to a host the directory for the instance is created on the host.
Defaults to null meaning not assigned.
### instance.auto_start
If enabled start this instance when the slave it is assigned to is started up.
Does not affect start or stop of the instance while the slave is running.
If enabled start this instance when the host it is assigned to is started up.
Does not affect start or stop of the instance while the host is running.
Defaults to false.
@@ -242,7 +242,7 @@ Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the slave.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
Defaults to "latest".
@@ -383,39 +383,39 @@ Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded on the master server in order for them to be loaded on instances.
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
Defaults to true.
## Control Configuration
### control.master_url
### control.controller_url
URL to connect to the master server to.
URL to connect to the controller to.
Defaults to null meaning complain about it not being set and exit.
### control.master_token
### control.controller_token
Access token used for authenticating with the master server.
You can generate an access token with `clusteriomaster bootstrap generate-user-token <username>`, or use the `clusteriomaster bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
Access token used for authenticating with the controller.
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the master server.
If you have a self signed certificate on the master server you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Defaults to 60.
+39 -23
View File
@@ -19,6 +19,7 @@ Though we have some conventions and workflows that we kindly ask you to follow.
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
- [Naming Style](#naming-style)
- [Units](#units)
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Markdown](#markdown)
@@ -68,17 +69,19 @@ The `plugins/` directory contains the core plugins for the project and can be ad
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 clusteriocontroller -> node packages/controller
npx clusteriohost -> node packages/host
npx clusterioctl -> node packages/ctl
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
Note that to use `clusterioctl` you will have to create a control config first:
node packages/master bootstrap create-ctl-config [admin-user]
node packages/controller 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.
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
For web development on the master there are also the following flags:
For web development on the controller there are also the following flags:
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
@@ -86,7 +89,7 @@ For web development on the master there are also the following flags:
These options can be combined and multiple plugins can be actived for dev mode.
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the master without these flags again.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the controller without these flags again.
### Starting a Feature Branch
@@ -181,8 +184,8 @@ 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 GitHub Actions tests are run on whatever version of Node.js v12, v14, and v16 is the default.
Clusterio run on Node.js v18 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The GitHub Actions tests are run on whatever version of Node.js v18 and v20 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.
@@ -200,20 +203,31 @@ But there are at least a few things that have been agreed upon.
JavaScript variables in general uses camelCase.
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
Fields in configs and messages sent over links uses lowercase_underscore.
Fields in configs uses lowercase_underscore.
JavaScript files are named using lowercase_underscore for files that export multiple items.
Files containing and exporting a single class should be named the same as the class in PascalCase.
Imported modules from the lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
const libLink = require("@clusterio/lib/link");
const libLuaTools = require("@clusterio/lib/lua_tools");
const libLink = require("./link");
const libLuaTools = require("./lua_tools");
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
For lua code lowercase_underscore is used for everything.
### Units
When naming variables whos unit is not a base SI unit like seconds or metres the unit used should be suffixed in the variable name:
E.g.:
const startTimeMs = date.now();
const endNs = process.hrtime();
This makes it easier to spot different unit bases are being mixed in the code.
### Strings
@@ -271,9 +285,7 @@ Headings use the `# heading` format.
## Pushing releases to npm
***Warning: This workflow is outdated due to lerna having been removed from the project.****
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
The releases are done by updating packages versions and then publishing them with pnpm.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
@@ -283,23 +295,27 @@ You will need commit access to the repository as well publish access to the @clu
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`, otherwise they will be auto commited!
Check and make sure there are no untracked files with `git status`.
2. Run a lerna version to create and tag a new version.
This will push a commit to the master branch.
2. Update the version in package files.
Make sure to update the command for the right version jump.
```sh
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
sed -i 's/2\.0\.0-alpha\.12/2\.0\.0-alpha\.13/' {packages,plugins}/*/package.json
```
3. Run a full build in every package to update the dist/ files.
3. Inspect, commit and tag the changes.
Again, make sure to be tagging the right version.
```sh
npx lerna run build
git add -p
git commit -m v2.0.0-alpha.13
git tag v2.0.0-alpha.13
```
4. Publish the packages with lerna.
4. Publish the packages.
```sh
npx lerna publish from-package
# Warning: Double check your current working directory is the repository before running this
pnpm -r --filter='!./external_plugins/**' publish
```
+119
View File
@@ -0,0 +1,119 @@
# JSON Serialisable Classes
To serialise and deserialise data the Clusterio codebase uses a JSON serialisable data class pattern.
Instances of these classes can be serialised to JSON by passing them directly to `JSON.serialise` and deserialised by passing the serialised JSON representation to its static `fromJSON` method.
The data class also has a static `jsonSchema` property describing the serialised JSON representation for validation purposes.
See the following example code for a hypothetical `Data` class with a single property `member` defaulting to a value of `"foo"`.
```js
// Data.js
"use strictc";
/**
* A Data class example
* @alias module:Data
*/
class Data {
/** @type {string} */
member = "foo";
constructor(member) {
if (member !== undefined) { this.member = member; }
}
static jsonSchema = {
type: "object",
properties: {
"member": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.member);
}
// toJSON is called by JSON.stringify when serialising this class
toJSON() {
let json = {};
// Omit property if it's set to the default value
if (this.member !== "foo") { json["member"] = this.member; }
return json;
}
}
module.exports = Data;
```
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
Deserialising is done by calling the static `fromJSON` method with the serialised JSON data, like `data = Data.fromJSON(JSON.parse(serialised))`.
By structuring the classes like this they can be easily composed together.
For example a class holding a data property with an instance of the Data class from the previous example can be created as follows.
```js
// Composed.js
"use strict";
const Data = require("./Data");
/**
* Composed data class example
* @alias module:Data
*/
class Composed {
/** @type {module:Data|undefined} */
data;
constructor(data) {
if (data) { this.data = data; }
}
static jsonSchema = {
type: "object",
properties: {
"data": Data.jsonSchema,
},
};
static fromJSON(json) {
let data;
if (json["data"]) { data = Data.fromJSON(json["data"]); }
return new this(data);
}
// toJSON is optional and not needed for this particular class
}
module.exports = Composed;
```
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
Meaning `serialised = JSON.stringify(composed)` and `composed = Composed.fromJSON(JSON.parse(serialised))` does as expected.
## Interface description
Data classes are ES6 style class declarations with the following methods and fields:
### toJSON(): any (optional)
Method returning an object or primitive suitable for passing to JSON.stringify in order to create a JSON representation of the class instance.
The returned object may contain objects that have their own toJSON methods which JSON.stringify will then call recursively.
This method is optional if calling `JSON.stringify` on an instance without it would produce the desired serialised form, however this is rarely the case as often you'll have to provide it to convert the values fram Map/Set properites, omit default values or do similar actions.
Note that the primary use case for serialising data classes is to send them over the network, this method should therefore try to reduce the size of the resulting JSON by for example omitting properties who's values are the default value.
### static fromJSON(json: any): this
Method constructing an instance of this class from one that was previously serialised by passing an instance of it to `JSON.stringify`.
While not a requirement it is preferrable if the input is not mutated and that no references to any any structures in the input ends up in the returned object.
In other words calling fromJSON twice with the same input should produce two independent objects.
Note that directly passing the output of `toJSON` to `fromJSON` is not valid, as the output of `toJSON` may require further expansion calling `toJSON` on objects returned by it.
It has to go through `JSON.stringify` or an equivalent recursive expansion of `toJSON`.
### static jsonSchema
A [JSON schema](http://json-schema.org/) describing the format of the JSON resulting from passing an instance of this class to `JSON.stringify`.
This should be lenient towards adding additional properties that would be ignored by `fromJSON` as it makes it easier to extend these classes in the future without breaking compatibility.
+523
View File
@@ -0,0 +1,523 @@
# Migrating Plugins to Alpha 14
Between version 2.0.0-alpha.13 and 2.0.0-alpha.14 the codebase underwent major refactoring and was ported to TypeScript.
In order to support alpha 14 there are several major changes that need to be done to existing plugins, these are in no particular order
## Rename master and slave
All references to slave and master in the code was renamed to controller and host respectively which means that code referencing these in plugins must be renamed.
In short this means performing a global search and replace on the codebase replacing:
- `master` with `controller`
- `Master` with `Controller`
- `slave` with `host`
- `Slave` with `Host`
in all places it occurs, even as part of longer names such as changing `BaseMasterPlugin` to `BaseControllerPlugin`.
## Change lib imports to import directly from the top level package
Previously code could import sub packages of lib such as lib/lua_tools.
These imports need to be changed to all import from lib directly.
**Alpha 13**
```js
const libLuaTools = require("@clusterio/lib/lua_tools");
const libErrors = require("@clusterio/lib/errors");
// Using imports
new libErrors.RequestError(...)
libLuaTools.escapeString(...)
```
**Alpha 14 (JS)**
```js
const lib = require("@clusterio/lib");
// Using library components
new lib.RequestError(...)
lib.escapeString(...)
```
**Alpha 14 (TS)**
```ts
import * as lib from "@clusterio/lib";
// Using library components
new lib.RequestError(...)
lib.escapeString(...)
```
## Change import of base plugins
Previously `BaseMasterPlugin` and `BaseInstancePlugin` were imported from `@clusterio/lib`, these have been moved to `@clusterio/controller` and `@clusterio/host` respectively.
**Alpha 13**
```js
const { BaseMasterPlugin } = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/lib");
```
**Alpha 14 (JS)**
```js
const { BaseControllerPlugin } = require("@clusterio/controller");
const { BaseInstancePlugin } = require("@clusterio/host");
```
**Alpha 14 (TS)**
```ts
import { BaseControllerPlugin } from "@clusterio/controller";
import { BaseInstancePlugin } from "@clusterio/host";
```
## Change main entrypoint of plugin
Previously the info of a plugin would be loaded by requiring `./plugin_dir/info`, this has been changed to require `./plugin_dir` which in turn means that `info.js` is no longer the default entrypoint.
To solve this you can either add `"main": "info.js",` to package.json or rename `info.js` to `index.js` (the rest of this guide assumes the entrypoint was renamed to `index.js`).
The motivation behind this change was to allow TypeScript to output its build to a sub-folder of the plugin, if you're using TypeScript you'll likely have the entrypoint be located in `dist/index.js` instead.
The expected place where the declaration of a plugin is exported has also changed from `module.exports` to `module.exports.plugin`:
**Alpha 13**
```js
// info.js
module.exports = {
name: "plugin_name",
...
}
```
**Alpha 14 (JS)**
```js
// index.js
const plugin = {
name: "plugin_name",
...
}
module.exports = {
plugin,
};
```
**Alpha 14 (TS)**
```ts
// index.ts
import type * as lib from "@clusterio/lib";
export const plugin: lib.PluginDeclaration = {
name: "plugin_name",
...
};
```
### Modify exposed path in webpack.config.js
The `./info` import path exposed in the ModuleFederationPlugin in the `webpack.config.js` needs to be changed to `./`.
E.g.:
**Alpha 13**
```js
// In webpack.config.js
new webpack.container.ModuleFederationPlugin({
...
exposes: {
"./info": "./info.js",
...
},
}),
```
**Alpha 14**
```js
// In webpack.config.js
new webpack.container.ModuleFederationPlugin({
...
exposes: {
"./": "./index.js",
...
},
}),
```
## Remove usage of ConfigGroup
The Config system was refactored and the `ConfigGroup` concept was completely removed.
Config fields are now defined directly on the exported plugin info.
**Alpha 13**
```js
const lib = require("@clusterio/lib");
class MasterConfigGroup extends lib.PluginConfigGroup { }
MasterConfigGroup.groupName = "plugin_name";
MasterConfigGroup.defaultAccess = ["master", "control"];
MasterConfigGroup.define({
name: "option",
title: "Option",
description: "A config option.",
type: "string",
optional: true,
});
module.exports = {
name: "plugin_name",
MasterConfigGroup,
...
}
```
**Alpha 14 (JS)**
```js
const plugin = {
name: "plugin_name",
controllerConfigFields: {
"plugin_name.option": {
title: "Option",
description: "A config option.",
type: "string",
initial_value: "foo",
optional: true,
},
},
...
}
module.exports = {
plugin,
};
```
**Alpha 14 (TS)**
```ts
import type * as lib from "@clusterio/lib";
// Declare the type of the field to make the type of controller.config.get(field) correctly deduced.
declare module "@clusterio/lib" {
export interface ControllerConfigFields {
"plugin_name.option": null | string;
}
}
export const plugin: lib.PluginDeclaration = {
name: "plugin_name",
controllerConfigFields: {
"plugin_name.option": {
title: "Option",
description: "A config option.",
type: "string",
initialValue: "foo",
optional: true,
},
},
...
};
```
### Use onControllerConfigFieldChanged instead of listering to the fieldChanged event
**Alpha 13**
```js
this.controller.config.on("fieldChanged", (group, field, prev) => {
if (group.name === "plugin_name" && field === "option") {
const curr = group.get(field);
// Do stuff with curr and or prev
}
}
```
**Alpha 14 (JS)**
```js
async onControllerConfigFieldChanged(field, curr, prev) {
if (field === "plugin_name.option") {
// Do stuff with curr and or prev
}
}
```
**Alpha 14 (TS)**
```ts
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
if (field === "plugin_name.option") {
// Do stuff with curr and or prev
}
}
```
## Redefine link messages as classes
The link layer has been majorly refactored and Request and Events are now defined as [JSON Serialisable Classes](./json-serialisable-classes.md).
**Alpha 13**
```js
module.exports = {
name: "plugin_name",
messages: {
forwarded: new lib.Event({
type: "plugin_name:forwarded",
links: ["instance-slave", "slave-master"],
forwardTo: "master",
eventProperties: {
"string_value": { type: "string" },
"number_value": { type: "number" },
},
}),
simple: new lib.Request({
type: "plugin_name:simple",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestProperties: {
"instance_id": { type: "number" },
"string_value": { type: "string" },
},
responseProperties: {
"number_value": { type: "number" },
},
}),
},
};
```
**Alpha 14 (JS)**
```js
const lib = require("@clusterio/lib");
class ForwardedEvent {
static type = "event";
static src = "instance";
static dst = "controller";
static plugin = "plugin_name";
constructor(stringValue, numberValue) {
this.stringValue = stringValue;
this.numberValue = numberValue;
}
static jsonSchema = {
type: "object",
required: ["stringValue", "numberValue"],
properties: {
"stringValue": { type: "string" },
"numberValue": { type: "number" },
},
};
static fromJSON(json) {
return new this(json.stringValue, json.numberValue);
}
}
class SimpleRequest {
static type = "request";
static src = "controller";
static dst = "instance";
static plugin = "plugin_name";
constructor(stringValue) {
this.stringValue = stringValue;
}
static jsonSchema = {
type: "object",
required: ["stringValue"],
properties: {
"stringValue": { type: "string" },
},
};
static Response = lib.JsonNumber
}
const plugin = {
name: "plugin_name",
messages: [
ForwardedEvent,
SimpleRequest,
],
...
};
module.exports = {
plugin,
ForwardedEvent,
SimpleRequest,
}
```
**Alpha 14 (TS)**
```ts
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
export class ForwardedEvent {
declare ["constructor"]: typeof ForwardedEvent;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
static plugin = "plugin_name" as const;
constructor(
public stringValue: string,
public numberValue: number,
) { }
static jsonSchema = Type.Object({
"stringValue": Type.String(),
"numberValue": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.stringValue, json.numberValue);
}
}
export class SimpleRequest {
declare ["constructor"]: typeof SimpleRequest;
static type = "request" as const;
static src = "controller" as const;
static dst = "instance" as const;
static plugin = "plugin_name" as const;
constructor(
public stringValue: string,
) { }
static jsonSchema = Type.Object({
"stringValue": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.stringValue);
}
static Response = lib.JsonNumber
}
export const plugin: lib.PluginDeclaration = {
name: "plugin_name",
messages: [
ForwardedEvent,
SimpleRequest,
],
...
}
```
### Refactor message sending
Message handlers are now explicitly registered instead of relying on magic name lookups that don't make sense unless you know them.
**Alpha 13**
```js
const lib = require("@clusterio/lib");
class ControllerPlugin extends lib.BaseControllerPlugin {
// event name is magically created by concatenating EventHandler or
// RequestHandler to the name in the messages export in in info.js.
async forwardedEventHandler(message) {
// message.data contains the raw JSON data sent.
if (message.data.string_value) {
// ...
}
}
}
```
**Alpha 14 (JS)**
```js
const { BaseControllerPlugin } = require("@clusterio/lib");
const { ForwardedEvent } = require("./index.js");
class ControllerPlugin extends BaseControllerPlugin {
async init() {
// Explicit binding between message class and the handler for it
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
// Note: On an instance plugin you would use this.instance.handle
}
async handleForwardedEvent(event, src, dst) {
// Event is an instance of the ForwardedEvent class
if (event.stringValue) {
// ...
}
// src and dst are instances of lib.Address and describe where
// the message came from and who it was addressed to.
}
...
}
module.exports = {
ControllerPlugin,
}
```
**Alpha 14 (TS)**
```ts
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import { ForwardedEvent } from "./index.js";
export class ControllerPlugin extends BaseControllerPlugin {
async init() {
// When using TypeScript this call is type checked to make sure the handler accepts the right class.
this.controller.handle(ForwardedEvent, this.handleForwardedEvent.bind(this));
// Note: On an instance plugin you would use this.instance.handle
}
async handleForwardedEvent(event: ForwardedEvent, src: lib.Address, dst: lib.Address) {
// Event is an instance of the ForwardedEvent class
if (event.stringValue) {
// ...
}
// src and dst describe where the message came from and who it was addressed to.
}
...
}
```
### Refactor message sending
Message are now addressed end to end instead of using the confusing forwardTo and broadcastTo properties of the message definition.
When broadcasting events the special `"allInstances"`, `"allHosts"`, and `"allControls"` shorthand addresses can be used.
There's two methods for sending messages `.send` and `.sendTo` where `.send` is a shorthand for `.sendTo` with an address filled in as the other side of the link, this is mostly only useful in control plugins where the other side of the link is the controller.
**Alpha 13**
```js
// from instance plugin
this.info.messages.forwarded.send(this.instance, {
string_value: "foo",
number_value: 123,
});
// from master plugin
const instance_id = ...
const response = await this.forwardRequestToInstance(this.info.messages.simple, {
instance_id,
string_value: "foo",
});
const value = response.number_value;
```
**Alpha 14**
```js
const { ForwardedEvent } = require("./info.js");
// from instance plugin
this.instance.sendTo("controller", new ForwardedEvent("foo", 123));
// from controller plugin
const instanceId = ...
const value = await this.controller.sendTo({ instanceId }, new SimpleRequest("foo"));
// if using TypeScript then the type of value will be inferred as number here
```
+21 -26
View File
@@ -12,9 +12,9 @@ Events and requests are defined as instances of the `Request` and `Event` classe
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.
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
For ctl and web_ui there's a `Control` instance that acts as the link to the controller.
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.
@@ -24,7 +24,7 @@ The handlers are defined on the link class itself for core messages, and on the
## 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 client has no previous session with the server it sends a `register_host` 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.
@@ -33,7 +33,7 @@ Messages sent over the link should be stored in a send buffer and only cleared f
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`.
Otherwise the server will respond with `invalidate` and the client is expected to drop its send buffer start a new session with `register_host` or `register_control`.
![Connection flow](../assets/connection_flow.svg)
@@ -96,30 +96,27 @@ The following messages are the only valid messages that can be sent during the h
#### `hello`
Sent by the master server when the WebSocket connection has been opened.
Sent by the controller when the WebSocket connection has been opened.
- `version: string` - The version of the master, e.g. "2.0.0".
- `version: string` - The version of the controller, 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.
Object mapping plugin names to plugin version for plugins that are loaded on the controller.
#### `register_slave`
#### `register_host`
Slave handshake for establishing a new connection session.
Host 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.
- `version: string` - The protocol version of the host, e.g. "2.0.0".
- `id: integer` - ID of the host.
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the slave.
Object mapping plugin names to plugin version for plugins that are available on the host.
#### `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`
@@ -131,12 +128,12 @@ Handshake for resuming an existing connection session where the connection had d
#### `invalidate`
Sent by the master server in reply to a resume for a session that cannot be resumed.
Sent by the controller 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.
Sent by the controller 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.
@@ -144,7 +141,7 @@ Sent by the master server to signal resuming the session was successful.
#### `ready`
Sent by the master server to signal the connection has been established with a new session.
Sent by the controller 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.
@@ -159,7 +156,7 @@ The heartbeat should contain the `seq` of the last received message, and receivi
#### `heartbeat`
Sent by both the master server and clients at the interval given in the continue/ready messages.
Sent by both the controller and clients at the interval given in the continue/ready messages.
- `last_seq: integer` - Last message seq received over the link.
@@ -235,23 +232,21 @@ Server hello after opening the WebSocket.
}
```
Slave registering a new connection session.
Host registering a new connection session.
```json
{
"seq": null,
"type": "register_slave",
"type": "register_host",
"data": {
"token": "xyzabc123",
"agent": "Clusterio Slave",
"version": "2.0.0",
"name": "Foo",
"id": 123
}
}
```
Master server reply signaling the session is established and ready.
Controller reply signaling the session is established and ready.
```json
{
@@ -265,4 +260,4 @@ Master server reply signaling the session is established and ready.
}
```
Slave and master can now send events and requests over the connection.
Host and controller can now send events and requests over the connection.
+1 -1
View File
@@ -11,6 +11,6 @@ This can be disabled with the `factorio.enable_save_patching` config on a per in
## User Accounts
The master server creates and stores accounts for all Factorio users that log into instances under its control.
The controller creates and stores accounts for all Factorio users that log into instances under its control.
These accounts are used to store per player data and authorize management of the cluster.
By default users accounts are assigned a Player role that gives a limited read access to the cluster resources, and admins of the cluster can be assigned a Cluster Admin role to get access to manage it.
-25
View File
@@ -1,25 +0,0 @@
{
"plugins": [
"plugins/markdown"
],
"tags": {
"allowUnknownTags": false
},
"opts": {
"destination": "docs/api",
"recurse": true
},
"source": {
"include": [
"packages/master/src/",
"packages/master/master.js",
"packages/slave/src/",
"packages/slave/slave.js",
"packages/ctl/src/",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
],
"excludePattern": "node_modules"
}
}
+14 -14
View File
@@ -1,32 +1,32 @@
# Managing a Cluster
Clusterio clusters are managed through the master server by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
Clusterio clusters are managed through the controller by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should be executed.
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-config <username>` on the master server, which creates the necessary `config-control.json` for managing the cluster as the given user.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
## Slaves
## Hosts
### List slaves
### List hosts
ctl> slave list
ctl> host list
Lists all slaves.
Lists all hosts.
### Generate token
ctl> slave generate-token [--id <slave id>]
ctl> host generate-token [--id <host id>]
Generate a token for a slave. `--id` is the slave id.
Generate a token for a host. `--id` is the host id.
### Create config
ctl> slave create-config [--id <slave id>] [--name <slave name>] [--generate-token <bool>] [--output <path to output>]
ctl> host create-config [--id <host id>] [--name <host 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.
Create a host config. `--id` id the host id. `--name` is the host 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-host.json", "-" for stdout.
## Instances
@@ -85,11 +85,11 @@ To be written.
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
### Assign instance to slave
### Assign instance to host
ctl> instace assign <instance> [slave]
ctl> instace assign <instance> [host]
Assigns instance to slave. `instance` is instance to assign, `slave` is slave to assign to. If `slave` is not provided, instance will be unassigned.
Assigns instance to host. `instance` is instance to assign, `host` is host to assign to. If `host` is not provided, instance will be unassigned.
## Users
@@ -189,7 +189,7 @@ By default there's a roled named Cluster Admin which grants access to everything
ctl> user delete <name>
Deletes everything stored on the master server for this user.
Deletes everything stored on the controller for this user.
Note: If the player is banned from the cluster this will effectively unban them, as the ban status is stored with the user account.
+42
View File
@@ -0,0 +1,42 @@
# Migrating from 2.0.0-alpha.13 to 2.0.0-alpha.14
Alpha 14 comes with significant breaking changes for existing clusters, most notably the rename of master and slave to controller and host.
Please study the entire migration guide carefully before attempting to update an existing cluster.
## Before updating
If you're using mods you need to create mod pack string for the mod configurations you're currently running.
You can do this by running the follow RCON command on an instance that is currently running the mod configuration you want to use (you can also run this command in single player and find the string printed in Factorio's [Log file](https://wiki.factorio.com/Log_file)):
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
Mod Pack strings looks something like this:
eNqFketuwyAMhd/Fv0OUVpW25lWmqSLESdAIRNh0l4h3n9mlitZt5QdCx/7so8MKXs8ILSzaPEEFPZKJdmEbvIgiDNpwiDaczhjpU93Vu/r+Tmpz6Anah/V7RqcJRUavO4c9tBwTVnAF5upCGJeIsYx3tvsPbQTdb0lKHYlnPJHY0+ONvcdjvW8gP1ZAyGz9KL5XINaR0yLPXEFMnu2ManSh0+6j/GOFitqXG4mjNSUidVm5wlm7JMYG7Qhl3BX8FjyqZ9vztOk+NM2fvRPaceLbzbN+UejQFFOWXzfArvk6v2HWD9ZLv5JfxA1UwsubPBaMKhHGklLO76vAwfQ=
If you have multiple mod configuration create a mod pack string for each and note them down.
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
## Migrate installations
**Note:** Check that plugins you are using have updates avaialble for alpha 14 before attempting to update, if they do not either wait for the plugin to be updated or uninstall the plugin from all places it is installed on first.
Shutdown all slaves and the master server and then migrate each installations by running the following command:
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
If you're using systemd then copy the generated clusteriocontroller.service and/or clusteriohost.service files from the systemd folder to /etc/systemd/system/ and remove the old clusteriomaster.service and clusterioslave.service files.
Once the installations have been migrated start up the controller and hosts again.
## Post installation
Import the Mod Pack strings that was created beforehand on the controller and upload the mods needed by those mod packs to the controller.
You can assign a default mod pack in the controller config which instances use by default or set the mod pack on the per instance basis in the instance config.
Once you have assigned a mod pack to an instance run a data export on the instance in order to properly populate the mod settings in the mod and get item icons and item names resolved in the web interface.
+11 -11
View File
@@ -1,7 +1,7 @@
# Setting up TLS
By default Clusterio will listen for connections over HTTP which is fine for local networks but over the Internet it should idealy be over HTTPS as there are some credentials that could be exposed to eavesdroppers.
The recommended setup is to have an ordinary HTTPS server software like Apache or Nginx to act as a reverse proxy in front of the Clusterio master server.
The recommended setup is to have an ordinary HTTPS server software like Apache or Nginx to act as a reverse proxy in front of the Clusterio controller.
The reverse proxy would then be set up with a TLS certificate from a Certificate Authoritiy like [Let's Encrypt](https://letsencrypt.org).
Alternatively you can have Clusterio listen for connections over HTTPS directly by providing it with a TLS certificate and private key.
@@ -11,17 +11,17 @@ See the last section for how to create and use a self-signed certificate for an
## Using a reverse proxy
Configure the proxy to forward requests over HTTP to the port the master server is listening to so that for example a request to `https://example.com/instances/` is forwarded to `http://localhost:8080/instances/`.
It is possible to host the interface under a sub-path, in that case a request under the sub-path should be mapped to the master server with the sub-path stripped out.
Configure the proxy to forward requests over HTTP to the port the controller is listening to so that for example a request to `https://example.com/instances/` is forwarded to `http://localhost:8080/instances/`.
It is possible to host the interface under a sub-path, in that case a request under the sub-path should be mapped to the controller with the sub-path stripped out.
For example `https://example.com/sub/path/instances/` would be mapped to `http://localhost:8080/instances`.
Additianlly WebSocket connections to `/api/socket` also needs to be forwarded.
Typically this requires explicit configuration and support for forwarding WebSocket connections.
If the interface hosted under a sub-path the `/api/socket` address is relative to the sub-path and the sub-path also needs to be stripped out when forwarded.
You can also optionally set the master server to listen only on the loopback interface to prevent it being directly connected to from the outside.
You can also optionally set the controller to listen only on the loopback interface to prevent it being directly connected to from the outside.
npx clusteriomaster config set master.bind_address 127.0.0.1
npx clusteriocontroller config set controller.bind_address 127.0.0.1
### Apache Config
@@ -44,7 +44,7 @@ Pay attention to the use of trailing slashes, as they are important.
## Hosting HTTPS directly from Node.js
Listening on HTTPS is supported by the Clusterio master server via the `master.https_port` option provided paths to a certificate file and a private key file is configured for the `master.tls_certificate` and `master.tls_private_key` options.
Listening on HTTPS is supported by the Clusterio controller via the `controller.https_port` option provided paths to a certificate file and a private key file is configured for the `controller.tls_certificate` and `controller.tls_private_key` options.
Both of these are expected to be unencrypted PEM files.
@@ -58,14 +58,14 @@ substituting `<ip>` with the IP address the server will be accessed under, for e
Multiple addresses can be given by supplying a comma sepparated list of `IP:address` pairs and DNS names can also be specified using the `DNS:example.com` format.
You need to supply every name you want to connect to the server under the subjectAltName field, for example to connect to the HTTPS port locally via an address like `https://localhost:4443/` you need to add `DNS:localhost`.
This creates a private key file named `key.pem` and a public certificate file named `cert.pem` and these are used for the `master.tls_certificate` and `master.tls_private_key` config options respectively on the master server:
This creates a private key file named `key.pem` and a public certificate file named `cert.pem` and these are used for the `controller.tls_certificate` and `controller.tls_private_key` config options respectively on the controller:
npx clusteriomaster config set master.tls_certificate cert.pem
npx clusteriomaster config set master.tls_private_key key.pem
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as slaves and configure it as the `slave.tls_ca` option:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
npx clusterioslave config set slave.tls_ca cert.pem
npx clusteriohost config set host.tls_ca cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
+102 -101
View File
@@ -25,9 +25,9 @@ The plugin classes have pre-defined hooks that are called during various stages
The basic file structure of a plugin is the following.
plugin_name/
+- info.js
+- index.js
+- package.json
+- master.js
+- controller.js
+- instance.js
+- control.js
+- test/
@@ -41,22 +41,20 @@ The usual guides for creating such packages apply.
At minimum the `package.json` file must contain a version entry.
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriomaster, clusterioslave or clusterioctl.
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriocontroller, clusteriohost or clusterioctl.
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
For a plugin the most important file is the `info.js` file.
Without it the plugin will not recognized by Clusterio.
Here's an example of it:
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
Here's an example of `index.js`:
```js
const { libLink } = require("@clusterio/lib"); // For messages
module.exports = {
module.exports.plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance",
masterEntrypoint: "master",
controllerEntrypoint: "controller",
messages: {
/* See below */
},
@@ -80,23 +78,23 @@ The following properties are recognized:
**instanceEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the master in order for it to be possible to load it on an instance.
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
**InstanceConfigGroup**:
Subclass of `PluginConfigGroup` for defining the per instance configuration fields for this plugin.
**instanceConfigFields**:
Object mapping instance configuration fields to `FieldDefinitions`for this plugin.
See [Plugin Configuration](#plugin-configuration)
**masterEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the MasterPlugin class definiton for this plugin.
**controllerEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the master server.
A plugin can be made that only runs on the controller.
**MasterConfigGroup**:
Subclass of `PluginConfigGroup` for defining the master server configuration fields for this plugin.
**ControllerConfigGroup**:
Object mapping controller configuration fields to `FieldDefinitions`for this plugin.
See [Plugin Configuration](#plugin-configuration)
**controlEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the ControlPlugin class definition for this plugin.
**ctlEntrypoint**:
Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
This is an optional paramater.
A plugin can be made that only runs on the clusterioctl side.
@@ -108,21 +106,21 @@ The optional module folder contains a Clusterio module that will be patched into
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
While there is no standard for how to organize a plugin it's recommended to put the MasterPlugin class definition into master.js and the InstancePlugin class definition into instance.js.
While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
You can put them into whatever file you want (even the same one for both).
For both instanceEntrypoint and masterEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
## Defining the plugin class
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a MasterPlugin class the following code can be used:
For example, to define a ControllerPlugin class the following code can be used:
```js
const libPlugin = require("@clusterio/lib/plugin");
const { BaseControllerPlugin } = require("@clusterio/controller");
class MasterPlugin extends libPlugin.BaseMasterPlugin {
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
@@ -132,12 +130,12 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
}
module.exports = {
MasterPlugin,
ControllerPlugin,
}
```
For the instance plugin it's exactly the same except "Master" is replaced with "Instance", and for the clusterioctl plugin "Control" is used.
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
The available hooks that you can override are documented in the base classes.
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
@@ -157,7 +155,7 @@ The async init method is always called immediatly after the constructor, so ther
## Logging Messages
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the slave the instance is on.
For instances a copy of the log is also stored on the host the instance is on.
To use it, pass a string to one of the log levels functions, for example:
```js
@@ -202,39 +200,42 @@ For plugin hooks expections thrown are automatically catched and logged, but for
## Plugin Configuration
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
You can take advantage of it by subclassing `PluginConfigGroup`, setting `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export.
For example in info.js:
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
For example in index.js:
```js
const libConfig = require("@clusterio/lib/config");
```ts
import type * as lib from "@clusterio/lib";
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = "foo_frobber";
MasterConfigGroup.define({
name: "level",
description: "Level of frobnication done",
type: "number",
initial_value: 2,
});
MasterConfigGroup.finalize();
declare module "@clusterio/lib" {
// Extend the interface defining available fields so that instance.config.get(...)
// will have our own options.
export interface ControllerConfigFields {
"foo_frobber.level": number, // If optional is true then this should have | null in addition.
}
}
module.exports = {
export default {
...
MasterConfigGroup: MasterConfigGroup,
};
controllerConfigFields: {
"foo_frobber.level": {
description: "Level of frobnication done",
type: "number",
initialValue: 2,
},
}
} satisfies lib.PluginDeclaration;
```
Code inside the `MasterPlugin` class will then be able to access the level config field through the `Config` object at `this.master.config`, for example in the MasterPluginClass:
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
```js
```ts
async init() {
let level = this.master.config.get("foo_frobber.level");
let level = this.controller.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
}
```
The same applies for instance configs, replace "master" with "instance" where appropriate.
The same applies for instance configs, replace "controller" with "instance" where appropriate.
See [Configuration System](config-system.md) for more details on how this system works.
@@ -313,8 +314,8 @@ For RCON, commands longer than 50 characters may end up being executed after sho
## Defining Link Messages
You will most likely have to communicate with the master or other instances in your plugin for it to do anything useful.
For this there's a WebSocket communication channel established between the slaves and the master server that plugins can define their own messages to send over it.
You will most likely have to communicate with the controller or other instances in your plugin for it to do anything useful.
For this there's a WebSocket communication channel established between the hosts and the controller that plugins can define their own messages to send over it.
This channel is bi-directional and all messages sent over it are validated with a JSON schema (see [this guide][guide] for an introduction to writing JSON schema).
[guide]: https://json-schema.org/learn/getting-started-step-by-step.html
@@ -327,15 +328,15 @@ The requests are similar to HTTP requests, except both parties of a link may ini
### Defining Events
Events are defined as properties of the messages object exported by `info.js` that map to instances of the `Event` class from `lib/link`.
Events are defined as properties of the messages object exported on `plugin` that map to instances of the `Event` class from `lib/link`.
The name of the property correspond to the handler invoked on the plugin class.
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
The Event constructor takes an object of properties that define the event, for example the following could be defined in `plugin`:
```js
messages: {
startFrobnication: new libLink.Event({
startFrobnication: new lib.Event({
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
links: ["controller-host", "host-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
@@ -346,9 +347,9 @@ messages: {
},
```
This specifies an event that can be sent from the master to a slave, and from a slave to an instance.
This specifies an event that can be sent from the controller to a host, and from a host to an instance.
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
It will also be forwarded by slaves to a specific instance.
It will also be forwarded by hosts to a specific instance.
The following properties are recognized by the Event constructor:
@@ -361,17 +362,17 @@ The type of the message sent over the socket will have the suffix `_event` appen
#### links
An array of strings describing which links this event can be sent over.
Direction matters, `"master-slave"` means the event can be sent from the master to the slave, but can't be sent back the other way, unless `"slave-master"` is also present in the links array.
Direction matters, `"controller-host"` means the event can be sent from the controller to the host, but can't be sent back the other way, unless `"host-controller"` is also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and `control`.
Master talks with slave and control, and slave talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
The available endpoints are `controller`, `host`, `instance`, and `control`.
Controller talks with host and control, and host talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
#### forwardTo
Target to forward an event to.
Can either be `"master"`, to indicate a slave should forward it to the master server, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
Can either be `"controller"`, to indicate a host should forward it to the controller, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
This works by using a default handler for the event at the links that forward it.
#### broadcastTo
@@ -379,7 +380,7 @@ This works by using a default handler for the event at the links that forward it
Target to broadcast this message towards.
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
Currently, only "instance" is supported.
This means that sending the event to a slave from an instance will cause it to be broadcast to all instances of that slave except for the instance it came from.
This means that sending the event to a host from an instance will cause it to be broadcast to all instances of that host except for the instance it came from.
#### eventRequired
@@ -393,24 +394,24 @@ Object with properties mapping to a JSON schema of that property that specifies
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
See [this guide][guide] for an introduction to writing JSON schemas.
The forwardTo and broadcastTo can be combined such that specifying `"master"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
For this to work, you will need to specify `instance-slave`, `slave-master`, `master-slave`, and `slave-instance` as the links.
The forwardTo and broadcastTo can be combined such that specifying `"controller"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
For this to work, you will need to specify `instance-host`, `host-controller`, `controller-host`, and `host-instance` as the links.
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
Use a request if you need a confirmation that the message was received.
### Definining Requests
Requests are defined as properties of the messages object exported by `info.js` that map to instances of the `Request` class from `lib/link`.
Requests are defined as properties of the messages object exported by `plugin` that map to instances of the `Request` class from `lib/link`.
The name of the property corresponds to the handler invoked on the plugin class.
The Request constructor takes an object of properties that define the event.
For example, the following could be defined in `info.js`:
For example, the following could be defined in `plugin`:
```js
messages: {
reportFrobnication: new libLink.Request({
reportFrobnication: new lib.Request({
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
links: ["controller-host", "host-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
@@ -427,10 +428,10 @@ messages: {
},
```
This specifies a request that can be sent from the master to a slave, and from a slave to an instance.
This specifies a request that can be sent from the controller to a host, and from a host to an instance.
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
It also defines that the response sent must contain a `report` property mapping to an array of strings.
When received by a slave, it will also be forwarded to the instance specified by `instance_id`.
When received by a host, it will also be forwarded to the instance specified by `instance_id`.
The following properties are recognized by the Request constructor:
@@ -443,17 +444,17 @@ The type of the message sent over the socket will have the suffix `_request` app
#### links
An array of strings describing which links this request can be sent over.
Direction matters; `"master-slave"` means the request can be sent from the master to the slave and the slave can reply to the master, but the slave can't send a request to the master unless `"slave-master"` is also present in the links array.
Direction matters; `"controller-host"` means the request can be sent from the controller to the host and the host can reply to the controller, but the host can't send a request to the controller unless `"host-controller"` is also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and `control`.
Master talks with slave and control, and slave talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
The available endpoints are `controller`, `host`, `instance`, and `control`.
Controller talks with host and control, and host talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
See `forwardTo` for ways to forward a request to the next link in a chain.
#### forwardTo
Target to forward the request to.
Can either be `"master"` to indicate a slave should forward it to the master server when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
Can either be `"controller"` to indicate a host should forward it to the controller when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
This works by using a default handler for the request by the links that forward it.
#### requestRequired
@@ -480,8 +481,8 @@ Same as the requestProperties only for the response sent back by the target.
## Sending Link Messages
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
For `InstancePlugin` code the link to the slave is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
For `InstancePlugin` code the link to the host is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
The `.info` property of the plugin class exposes the data exported from the plugin's `plugin` export.
In other words:
```js
@@ -497,26 +498,26 @@ For the Request class the send method is async and returns the response data rec
### Handling connection events
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between master and slave.
This is signaled to `MasterPlugin` classes via the `onPrepareSlaveDisconnect` hook and to `InstancePlugin` classes via the `onPrepareMasterDisconnect` hook.
The most important is the prepare disconnect for the link between controller and host.
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `SlaveConnection` class and `Slave` class on the master and slave respectively.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host 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.connected) {
if (this.host.connected) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
}
```
If the event or request needs to be sent to the master it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onMasterConnectionEvent` for `InstancePlugin` classes and through `onSlaveConnectionEvent` for `MasterPlugin` classes.
This is raised when the connection between the master and slave in question is lost, most likely due to networking issues.
When in the dropped state the slave will keep trying to reconnect to the master server in order to re-establish it, and if successful no events or requests will be lost.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
When in the dropped state the host will keep trying to reconnect to the controller 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 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.
@@ -530,23 +531,23 @@ In its simplest form collecting data from plugins consists of defining the metri
For example:
```js
const { Counter } = require("@clusterio/lib/prometheus");
const { Counter } = require("@clusterio/lib");
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
);
// Somewhere in the master plugin code
// Somewhere in the controller plugin code
fooMetric.inc();
```
This works for master plugins, and the metric will be automatically available through the /metric HTTP endpoint.
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
```js
const { Counter } = require("@clusterio/lib/prometheus");
const { Counter } = require("@clusterio/lib");
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
@@ -557,11 +558,11 @@ const barMetric = new Gauge(
barMetric.labels(String(this.instance.id)).set(someValue);
```
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the master server on slaves.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a slave.
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
For statistics you need to update on collection there's an `onMetrics` hook on both master and instance plugins that is run before the metrics in the default registry are collected.
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
## Adding Custom Commands to clusterioctl
@@ -570,7 +571,7 @@ The control entrypoint for plugins allows you to extend clustectl with your own
The creation of custom commands typically starts with defining a command tree for the plugin:
```js
const { Command, CommandTree } = require("@clusterio/lib/command");
const { Command, CommandTree } = require("@clusterio/lib");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
@@ -600,20 +601,20 @@ For a command the `definition` is the arguments to pass to [yargs.command](http:
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
Note that messages sent from clusterioctl needs to have `"control-master"` as a part of the links array for it to be accepted by the master server, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the master.
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Control plugin:
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
```js
const libPlugin = require("@clusterio/lib/plugin");
const { BaseCtlPlugin } = require("@clusterio/ctl");
class ControlPlugin extends libPlugin.BaseControlPlugin {
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
}
}
module.exports = {
ControlPlugin,
CtlPlugin,
}
```
+43 -39
View File
@@ -1,41 +1,45 @@
{
"name": "root",
"private": true,
"scripts": {
"test": "mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint \"*.js\" packages plugins test",
"docs": "jsdoc --configure docs/jsdoc.json",
"bundle-dependencies": "bundle-dependencies"
},
"nyc": {
"exclude": [
"test/**",
"plugins/*/test/**"
]
},
"engines": {
"node": ">=12"
},
"devDependencies": {
"@clusterio/ctl": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/master": "workspace:*",
"@clusterio/slave": "workspace:*",
"eslint": "^8.18.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.16.2",
"form-data": "^4.0.0",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.6",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"mocha": "^9.2.2",
"node-forge": "^1.3.1",
"nyc": "^15.1.0",
"phin": "^3.5.1"
}
"name": "root",
"private": true,
"scripts": {
"build": "tsc --build",
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
"nyc": {
"exclude": [
"test/**",
"plugins/*/test/**"
]
},
"engines": {
"node": ">=18"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.45.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.18.2",
"form-data": "^4.0.0",
"fs-extra": "^11.1.1",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.0",
"mocha": "^10.2.0",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.24.8",
"typescript": "^5.1.6"
}
}
@@ -1 +1,2 @@
webpack.config.js
dist/web/build
@@ -1,27 +1,27 @@
# Clusterio Master Server
# Clusterio Controller
Communication hub for Clusterio clusters.
The master server forwards data between Clusterio slaves connected to it and allows the cluster to be remotely managed through WebSocket connections to it either by using the included web interface or the Clusterio ctl command line utility.
The controller forwards data between Clusterio hosts connected to it and allows the cluster to be remotely managed through WebSocket connections to it either by using the included web interface or the Clusterio ctl command line utility.
# Usage
npx clusteriomaster <command>
npx clusteriocontroller <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the master server.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the controller.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the master server.
Defaults to `config-master.json` and will be created if it does not exist.
* `--config <file>` JSON file to use for storing configuration for the controller.
Defaults to `config-controller.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the master server.
Configure plugins available to be loaded by the controller.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
@@ -33,9 +33,9 @@ A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise i
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -46,10 +46,10 @@ Removing and unistalling a plugin is usually not neccessary as the functions pro
For example, uninstalling the Subspace Storage plugin:
npx clusteriomaster plugin remove subspace_storage
npx clusteriocontroller plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -59,8 +59,8 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the master server configuration offline.
This should only be used when the master server is stopped, otherwise the config read might be out of date and config changes will be overwritten when the master server shuts down.
Manage the controller configuration offline.
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
#### `config set <config-entry> [value]`
@@ -96,7 +96,7 @@ You should be using your own Factorio multiplayer username here.
#### `bootstrap generate-user-token <name>`
Generate an access token for logging in and possibly managing the cluster from the given user account.
This can be used both in the web interface and as the `master_token` to connect with clusterioctl.
This can be used both in the web interface and as the `controller_token` to connect with clusterioctl.
#### `bootstrap create-ctl-config <name>`
@@ -106,7 +106,7 @@ Creates a clusterioctl config for the given user with url and token set up for c
### `run`
Runs the master server.
Runs the controller.
## See Also
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env node
/**
* Clusterio controller
*
* Facilitates communication between hosts and control of the cluster
* through WebSocet connections, and hosts a webserver for browser
* interfaces and Prometheus statistics export. It is remotely controlled
* by {@link module:ctl/ctl}.
*
* @module controller/controller
* @author Danielv123, Hornwitser
* @example
* npx clusteriocontroller run
*/
import path from "path";
import fs from "fs-extra";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import { version } from "./package.json";
// globals
let controller: Controller;
void new lib.Gauge(
"clusterio_controller_pending_requests",
"Count of pending link requests currently waiting in memory on the controller.",
{
callback: (gauge) => {
let count = 0;
if (controller) {
for (const host of controller.wsServer.hostConnections.values()) {
count += host.pendingRequestCount;
}
for (const control of controller.wsServer.controlConnections.values()) {
count += control.pendingRequestCount;
}
}
gauge.set(count);
},
}
);
void new lib.Gauge(
"clusterio_controller_host_mapping",
"Mapping of Host ID to name",
{
labels: ["host_id", "host_name"],
callback: function(gauge:lib.Gauge) {
gauge.clear();
if (!controller || !controller.hosts) {
return;
}
for (let [id, host] of controller.hosts) {
gauge.labels({
host_id: String(id),
host_name: host.name,
}).set(1);
};
},
}
);
void new lib.Gauge(
"clusterio_controller_instance_mapping",
"Mapping of Instance ID to name and host",
{
labels: ["instance_id", "instance_name", "host_id"],
callback: function(gauge:lib.Gauge) {
gauge.clear();
if (!controller || !controller.instances) {
return;
}
for (let [id, instance] of controller.instances) {
gauge.labels({
instance_id: String(id),
instance_name: String(instance.config.get("instance.name")),
host_id: String(instance.config.get("instance.assigned_host")),
}).set(1);
}
},
}
);
void new lib.Gauge(
"clusterio_controller_websocket_active_connections",
"How many WebSocket connections are currently open to the controller",
{ callback: function(gauge:lib.Gauge) {
gauge.set(controller.wsServer.activeConnectors.size);
}}
);
void new lib.Gauge(
"clusterio_controller_active_hosts",
"How many hosts are currently connected to the controller",
{ callback: function(gauge:lib.Gauge) {
gauge.set(controller.wsServer.hostConnections.size);
}}
);
void new lib.Gauge(
"clusterio_controller_connected_clients_count",
"How many clients are currently connected to this controller",
{
labels: ["type"], callback: async function(gauge:lib.Gauge) {
gauge.labels("host").set(controller.wsServer.hostConnections.size);
gauge.labels("control").set(controller.wsServer.controlConnections.size);
},
},
);
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
): Promise<void> {
let subCommand = args._[1];
let userManager = new UserManager(controllerConfig);
await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
if (subCommand === "create-admin") {
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
let admin = userManager.users.get(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
} else if (subCommand === "generate-user-token") {
let user = userManager.users.get(args.name);
if (!user) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(userManager.signUserToken(user.name));
} else if (subCommand === "generate-host-token") {
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
));
} else if (subCommand === "create-ctl-config") {
let admin = userManager.users.get(args.name);
if (!admin) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
let controlConfig = new lib.ControlConfig("control");
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
userManager.signUserToken(admin.name),
);
let content = JSON.stringify(controlConfig, null, "\t");
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await lib.safeOutputFile(args.output, content);
}
}
}
export interface ControllerArgs {
[x: string]: unknown;
logLevel: string;
logDirectory: string;
pluginList: string;
devPlugin?: (string | number)[] | undefined;
dev?: boolean;
config: string;
_: (string | number)[];
$0: string;
}
interface InitializeParameters {
args: ControllerArgs;
shouldRun: boolean;
clusterLogger: winston.Logger;
pluginInfos: lib.PluginNodeEnvInfo[] | null;
controllerConfigPath: string | null;
controllerConfig: lib.ControllerConfig | null;
}
async function initialize(): Promise<InitializeParameters> {
// argument parsing
const args = await yargs
.scriptName("controller")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stdout",
default: "info",
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: "controller config file to use",
default: "config-controller.json",
type: "string",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
default: "plugin-list.json",
type: "string",
})
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
yargs
.command("create-admin <name>", "Create a cluster admin")
.command("generate-user-token <name>", "Generate authentication token for the given user")
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
yargs.positional("id", { describe: "ID of the host", type: "number" });
})
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
nargs: 1, default: "config-control.json",
});
})
.demandCommand(1, "You need to specify a command to run");
})
.command("run", "Run controller", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart the controller on failure",
});
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.argv
;
// Combined log stream of the whole cluster.
const clusterLogger = winston.createLogger({
format: winston.format.json(),
level: "verbose",
levels,
});
clusterLogger.add(new DailyRotateFile({
format: winston.format.json(),
filename: "cluster-%DATE%.log",
utc: true,
dirname: path.join(args.logDirectory, "cluster"),
}));
// Log stream for the controller.
logger.add(new DailyRotateFile({
format: winston.format.json(),
filename: "controller-%DATE%.log",
utc: true,
dirname: path.join(args.logDirectory, "controller"),
}));
logger.add(new winston.transports.Stream({
stream: clusterLogger,
}));
if (args.logLevel !== "none") {
logger.add(new ConsoleTransport({
level: args.logLevel,
format: new lib.TerminalFormat(),
}));
}
lib.handleUnhandledErrors();
let command = args._[0];
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${version}`);
shouldRun = true;
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, { encoding: "utf8" })));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return {
args,
shouldRun,
clusterLogger,
pluginInfos: null,
controllerConfigPath: null,
controllerConfig: null,
};
}
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const controllerConfigPath = args.config;
logger.info(`Loading config from ${controllerConfigPath}`);
let controllerConfig = new lib.ControllerConfig("controller");
try {
let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" }));
controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller");
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
controllerConfig = new lib.ControllerConfig("controller");
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`);
}
}
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
controllerConfig.set("controller.auth_secret", bytes.toString("base64"));
await lib.safeOutputFile(
controllerConfigPath, JSON.stringify(controllerConfig, null, "\t")
);
}
if (command === "config") {
await lib.handleConfigCommand(
args, controllerConfig, controllerConfigPath
);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
}
return {
args,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
shouldRun,
};
}
async function startup() {
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
process.title = "clusteriocontroller";
let {
args,
shouldRun,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
} = await initialize();
if (!shouldRun || !pluginInfos || !controllerConfigPath || !controllerConfig) {
return;
}
controller = new Controller(
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
Boolean(args.canRestart),
...await Controller.bootstrap(controllerConfig)
);
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
});
await controller.start(args);
}
export function bootstrap() {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startup().catch(err => {
if (err instanceof lib.StartupError) {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
+-------------------------------+
${err.stack}`
);
} else if (err instanceof lib.PluginError) {
logger.fatal(`
${err.pluginName} plugin threw an unexpected error
during startup, please report it to the plugin author.
------------------------------------------------------
${err.original.stack}`
);
} else {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting controller, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
process.exitCode = 1;
});
}
if (module === require.main) {
bootstrap();
}
+14
View File
@@ -0,0 +1,14 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceInfo } from "./src/InstanceInfo";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as WsServer } from "./src/WsServer";
if (module === require.main) {
bootstrap();
}
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@clusterio/controller",
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriocontroller": "./dist/controller.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"@sinclair/typebox": "^0.30.4",
"busboy": "^1.6.0",
"compression": "^1.7.4",
"express": "^4.18.2",
"fs-extra": "^11.1.1",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"webpack-cli": "^5.1.4",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.13.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.16",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.14",
"typescript": "^5.1.6",
"webpack": "^5.88.2",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
},
"publishConfig": {
"access": "public"
}
}
+109
View File
@@ -0,0 +1,109 @@
import path from "path";
import fs from "fs-extra";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import * as routes from "./routes";
/**
* Base class for controller connections
*
* @extends module:lib.Link
* @alias module:controller/src/BaseConnection
*/
export default class BaseConnection extends lib.Link {
declare ["connector"]: WsServerConnector;
constructor(
connector: lib.WebSocketBaseConnector,
public _controller: Controller
) {
super(connector);
this.router = _controller.router;
for (let [Request, handler] of this._controller._registeredRequests) { this.handle(Request, handler); }
for (let [Request, handler] of this._controller._fallbackedRequests) { this.fallbackRequest(Request, handler); }
for (let [Event, handler] of this._controller._registeredEvents) { this.handle(Event, handler); }
for (let [Event, handler] of this._controller._snoopedEvents) { this.snoopEvent(Event, handler); }
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
this._controller.subscriptions.unsubscribe(this);
});
}
async disconnect(code: number, reason: string) {
try {
await this.connector.disconnect();
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
}
}
}
/**
* True if the link is connected, not in the dropped state and not in
* the process of disconnecting.
*/
get connected(): boolean {
return this.connector.connected;
}
async handleModPackGetRequest(request: lib.ModPackGetRequest): Promise<lib.ModPack> {
let { id } = request;
let modPack = this._controller.modPacks.get(id);
if (!modPack) {
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
}
return modPack;
}
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
let id = this._controller.config.get("controller.default_mod_pack_id");
if (id === null) {
throw new lib.RequestError("Default mod pack not set on controller");
}
let modPack = this._controller.modPacks.get(id);
if (!modPack) {
throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`);
}
return modPack;
}
getMod(mod: { name: string, version: string, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
throw new lib.RequestError(`Mod ${filename} does not exist on controller`);
}
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
throw new lib.RequestError(`Mod ${filename} checksum does not match controller's checksum`);
}
return modInfo;
}
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = fs.createReadStream(modPath);
stream.mime = "application/zip";
stream.size = String(mod.size);
return stream.id;
}
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
}
@@ -0,0 +1,285 @@
import type {
CollectorResult, Event, InstanceStatus, Logger,
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceInfo from "./InstanceInfo";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
/**
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
export default class BaseControllerPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
public info: PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
logger: Logger
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the controller saves data in memory to disk
*
* Invoked on the configured controller.autosave_interval by the
* controller and intended to be used to flush any in memory data that
* has changed to disk.
*
* This will also be called during graceful shutdown after {@link
* BaseControllerPlugin.onShutdown} have been invoked and all links have
* been disconnected.
*/
async onSaveData() { }
/**
* Called when the status of an instance changes
*
* Invoked when the controller has changed the status of an instance
* or received notice from a host 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 host.
* - `unknown`: Host assigned to instance is offline.
* - `stopped`: Instance is no longer running or was just assigned to a
* host.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance startup completed and is now running.
* - `stopping`: Instance is in the processing stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting item
* icons and locale data.
* - `deleted`: Instance was deleted.
*
* On controller startup all known instances gets the status `unknown` if
* they are assigned to a host, when the host then connects to the
* controller 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`. If the host disconnects the
* instances will again get the `unknown` status.
*
* When instances are created on the controller they will notify of a status
* change with prev set to null. While the status of new instances is
* in most cases `unassigned` it's possible for the created instance to
* start with any state in some host connection corner cases.
*
* Note that it's possible for status change notification to get lost in
* the case of network outages if reconnect fails to re-establish the
* session between the controller and the host.
*
* @param instance -
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on the controller.
*
* @param field - Name of the field that changed.
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called when the value of an instance config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on an instance.
*
* @param instance -
* The instance the config changed on.
* @param field - Name of the field that changed.
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called when the controller is shutting down
*/
async onShutdown() { }
/**
* Called when an event on a host connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the host has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the controller and a
* host. Plugins should respond to this event by throtteling messages
* it is sending to the given host connection to an absolute minimum.
*
* Messages sent over a dropped host connection will get queued up in
* memory on the controller and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when a connection that had previously been dropped is
* re-established
*
* ##### close
*
* Invoked when the connection to the host has been closed.
*
* @param connection -
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when an avent on a control connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the control has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the controller and a
* control. Plugins should respond to this event by throtteling
* messages it is sending to the given control connection to an absolute
* minimum.
*
* Messages sent over a dropped control connection will get queued up in
* memory on the controller and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when a connection that had previously been dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the control has been closed.
*
* @param connection -
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when a host is preparing to disconnect from the controller
*
* Invoked when a host has requested the disconnection from the controller.
* This typically happens when it is in the process of shutting down.
*
* Plugins must stop sending messages to the host in question after the
* prepare disconnect has been handled.
*
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareHostDisconnect(connection: HostConnection) { }
/**
* Called when one or more mod packs are updated
*
* Invoked when one or more mod pack have been added, updated or deleted
* in the cluster.
*
* If a mod pack has been deleted its `.isDeleted` property will be
* true.
*
* @param modPacks - Mod packs that updated.
*/
async onModPacksUpdated(modPacks: ModPack[]) { }
/**
* Called when one or more mod stored on the controller are updated
*
* Invoked when one or more mods have been added, updated or deleted
* from the pool of shared mods stored on the cluster.
*
* If a mod has been deleted its `.isDeleted` property will be true.
*
* @param mods - Mods that updated.
*/
async onModsUpdated(mods: ModInfo[]) { }
/**
* Called when a player joins or leaves an instance
*
* Invoked when a player either joins or leaves an instance in the
* cluster.
*
* @param instance -
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
*
* Sends the given event to all hosts connected to the controller.
* This does not include hosts that are in the process of closing the
* connection, which typically happens when they are shutting down.
*
* @param event - Event to send
*/
broadcastEventToHosts<T>(event: Event<T>) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
&& (!event.constructor.plugin || hostConnection.plugins.has(event.constructor.plugin))
) {
hostConnection.send(event);
}
}
}
}
@@ -0,0 +1,770 @@
import type WsServerConnector from "./WsServerConnector";
import events from "events";
import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import ControllerUser from "./ControllerUser";
import * as routes from "./routes";
import Controller from "./Controller";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
const queryLogTime = new lib.Summary(
"clusterio_controller_query_log_duration_seconds",
"Time in seconds log queries took to execute."
);
/**
* Represents the connection to a control link
*
* @extends module:controller/src/BaseConnection
* @alias module:controller/src/ControlConnection
*/
export default class ControlConnection extends BaseConnection {
private _version: string;
logTransport: lib.LinkTransport | null = null;
logSubscriptions = {
all: false,
controller: false,
hostIds: [] as number[],
instanceIds: [] as number[],
};
ws_dumper: ((...args: any[]) => void) | null = null;
declare connector: WsServerConnector;
constructor(
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: ControllerUser, // The user making this connection.
public id: number
) {
super(connector, controller);
this._version = registerData.version;
this.connector.on("connect", () => {
this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
this.connector.on("close", () => {
if (this.logTransport) {
logger.remove(this.logTransport);
this.logTransport = null;
}
if (this.ws_dumper) {
this._controller.debugEvents.off("message", this.ws_dumper);
}
});
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
for (let controllerPlugin of this._controller.plugins.values()) {
controllerPlugin.onControlConnectionEvent(this, event);
}
});
}
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
this.handle(lib.HostListRequest, this.handleHostListRequest.bind(this));
this.handle(lib.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this));
this.handle(lib.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this));
this.handle(lib.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this));
this.handle(lib.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
this.handle(lib.InstanceRenameSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
this.handle(lib.InstanceCopySaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
this.handle(lib.InstanceDeleteSaveRequest, controller.sendRequestToHostByInstanceId.bind(controller));
this.handle(lib.InstanceDownloadSaveRequest, this.handleInstanceDownloadSaveRequest.bind(this));
this.handle(lib.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this));
this.handle(lib.ModPackListRequest, this.handleModPackListRequest.bind(this));
this.handle(lib.ModPackCreateRequest, this.handleModPackCreateRequest.bind(this));
this.handle(lib.ModPackUpdateRequest, this.handleModPackUpdateRequest.bind(this));
this.handle(lib.ModPackDeleteRequest, this.handleModPackDeleteRequest.bind(this));
this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this));
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
this.handle(lib.PermissionListRequest, this.handlePermissionListRequest.bind(this));
this.handle(lib.RoleListRequest, this.handleRoleListRequest.bind(this));
this.handle(lib.RoleCreateRequest, this.handleRoleCreateRequest.bind(this));
this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this));
this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this));
this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this));
this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this));
this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this));
this.handle(lib.UserCreateRequest, this.handleUserCreateRequest.bind(this));
this.handle(lib.UserRevokeTokenRequest, this.handleUserRevokeTokenRequest.bind(this));
this.handle(lib.UserUpdateRolesRequest, this.handleUserUpdateRolesRequest.bind(this));
this.handle(lib.UserSetAdminRequest, this.handleUserSetAdminRequest.bind(this));
this.handle(lib.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this));
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
let origin = this.connector.dst;
if (origin.type !== message.src.type || origin.id !== message.src.id) {
throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
}
}
validatePermission(
message: lib.MessageRequest | lib.MessageEvent,
entry: lib.RequestEntry | lib.EventEntry
) {
try {
this.checkPermission(message, entry);
} catch (err: any) {
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`);
throw err;
}
}
checkPermission(
message: lib.MessageRequest | lib.MessageEvent,
entry: lib.RequestEntry | lib.EventEntry
) {
let permission;
if (message.type === "request") {
permission = (entry as lib.RequestEntry).Request.permission;
} else if (message.type === "event") {
permission = (entry as lib.EventEntry).Event.permission;
} else {
return;
}
if (permission === null) {
return;
}
if (typeof permission === "string") {
this.user.checkPermission(permission);
return;
}
if (typeof permission === "function") {
permission(this.user, message);
return;
}
throw new Error("Should be unreachable");
}
async handleControllerStopRequest() {
this._controller.stop();
}
async handleControllerRestartRequest() {
if (!this._controller.canRestart) {
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
}
this._controller.shouldRestart = true;
this._controller.stop();
}
async handleControllerConfigGetRequest() {
return this._controller.config.toRemote("control");
}
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
}
async handleControllerConfigSetPropRequest(request: lib.ControllerConfigSetPropRequest) {
let { field, prop, value } = request;
this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control");
}
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
const host = this._controller.hosts.get(request.hostId);
if (!host) {
throw new Error(`Unknown host id (${request.hostId})`);
}
host.tokenValidAfter = Math.floor(Date.now() / 1000);
const hostConnection = this._controller.wsServer.hostConnections.get(request.hostId);
if (hostConnection) {
hostConnection.connector.terminate();
}
this._controller.hostsUpdated([host]);
}
async handleHostListRequest(): Promise<lib.HostDetails[]> {
return [...this._controller.hosts.values()].map(host => host.toHostDetails());
}
async handleHostGenerateTokenRequest(message: lib.HostGenerateTokenRequest) {
let hostId = message.hostId;
if (hostId === undefined) {
hostId = Math.random() * 2**31 | 0;
}
return this._controller.generateHostToken(hostId);
}
async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) {
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.controller_url", this._controller.getControllerUrl());
if (request.id !== undefined) {
hostConfig.set("host.id", request.id);
}
if (request.name !== undefined) {
hostConfig.set("host.name", request.name);
}
if (request.generateToken) {
this.user.checkPermission("core.host.generate_token");
hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id")));
}
return hostConfig.toJSON();
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
return [...this._controller.instances.values()].map(instance => instance.toInstanceDetails());
}
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(request.config, false, "control");
await this._controller.instanceCreate(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instanceDelete(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
if (request.field === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new lib.RequestError("Setting instance.id is not supported");
}
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let { field, prop, value } = request;
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
await this._controller.instanceAssign(request.instanceId, request.hostId);
}
async handleInstanceSaveDetailsListRequest() {
return [...this._controller.saves.values()];
}
async handleInstanceDownloadSaveRequest(request: lib.InstanceDownloadSaveRequest) {
let { instanceId, name } = request;
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = name;
let ready = new Promise((resolve, reject) => {
stream.events.on("source", resolve);
stream.events.on("timeout", () => reject(
new lib.RequestError("Timed out establishing stream from host")
));
});
ready.catch(() => {});
await this._controller.sendToHostByInstanceId(new lib.InstancePushSaveRequest(
instanceId,
stream.id,
name,
));
await ready;
return stream.id;
}
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
if (request.sourceInstanceId === request.targetInstanceId) {
throw new lib.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
throw new lib.RequestError("Source instance is not assigned a host");
}
if (targetHostId === null) {
throw new lib.RequestError("Target instance is not assigned a host");
}
// Let host handle request if source and target is on the same host.
if (sourceHostId === targetHostId) {
return await this._controller.sendTo({ hostId: sourceHostId }, request);
}
// Check connectivity
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
if (!sourceHostConnection || sourceHostConnection.connector.closing) {
throw new lib.RequestError("Source host is not connected to the controller");
}
let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId);
if (!targetHostConnection || targetHostConnection.connector.closing) {
throw new lib.RequestError("Target host is not connected to the controller");
}
// Create stream to proxy from target to source
let stream = await routes.createProxyStream(this._controller.app);
stream.events.on("timeout", () => {
if (stream.source) {
stream.source.destroy();
}
stream.events.emit("error", new lib.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 host to stream, this is done first to
// ensure the file size is known prior to the target host pull.
await Promise.all([
this._controller.sendTo(
{ hostId: sourceHostId },
new lib.InstancePushSaveRequest(request.sourceInstanceId, stream.id, request.sourceName),
),
events.once(stream.events, "source"),
]);
// Establish pull from target host to stream and wait for completion.
let storedName = await this._controller.sendTo(
{ hostId: targetHostId },
new lib.InstancePullSaveRequest(request.targetInstanceId, stream.id, request.targetName),
);
// Delete source save if this is not a copy
if (!request.copy) {
await this._controller.sendTo(
{ hostId: sourceHostId },
new lib.InstanceDeleteSaveRequest(request.sourceInstanceId, request.sourceName),
);
}
return storedName;
}
async handleModPackListRequest() {
return [...this._controller.modPacks.values()];
}
async handleModPackCreateRequest(request: lib.ModPackCreateRequest) {
let modPack = request.modPack;
if (modPack.id === undefined) {
throw new lib.RequestError("Mod pack need an ID to be created");
}
if (this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
}
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
let modPack = request.modPack;
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
}
async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) {
let { id } = request;
let modPack = this._controller.modPacks.get(id);
if (!modPack) {
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
}
modPack.isDeleted = true;
this._controller.modPacks.delete(id);
this._controller.modPacksUpdated([modPack]);
}
async handleModGetRequest(request: lib.ModGetRequest) {
return this.getMod(request);
}
async handleModListRequest() {
return [...this._controller.modStore.mods()];
}
static termsMatchesMod(terms: lib.ParsedTerm[], mod: lib.ModInfo) {
for (let term of terms) {
if (term.type === "word") {
if (!lib.wordMatches(term,
mod.name, mod.version, mod.title, mod.author, mod.contact,
mod.homepage, mod.description, mod.filename
)) {
return false;
}
} else if (term.type === "attribute") {
if (!lib.wordMatches(term.value, mod[term.name as keyof lib.ModInfo] as string)) {
return false;
}
}
}
return true;
}
async handleModSearchRequest(request: lib.ModSearchRequest) {
let query = lib.parseSearchString(request.query, {
name: "word",
// version
title: "word",
author: "word",
contact: "word",
homepage: "word",
description: "word",
// factorioVersion
// dependencies
filename: "word",
// size
sha1: "word",
});
let factorioVersion = request.factorioVersion;
type ModVersions = { name: string, versions: lib.ModInfo[] };
let results: Map<string, ModVersions> = new Map();
for (let mod of this._controller.modStore.mods()) {
if (
mod.factorioVersion !== factorioVersion
|| !ControlConnection.termsMatchesMod(query.terms, mod)
) {
continue;
}
let result = results.get(mod.name);
if (!result) {
result = {
name: mod.name,
versions: [],
};
results.set(mod.name, result);
}
result.versions.push(mod);
}
for (let result of results.values()) {
result.versions.sort((a, b) => b.integerVersion - a.integerVersion);
result.versions.map(e => e.toJSON());
}
let resultList = [...results.values()];
const sort = request.sort;
if (sort) {
const sorters = {
name: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].name, b.versions[0].name),
title: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].title, b.versions[0].title),
author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author),
};
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
throw new lib.RequestError(`Invalid value for sort: ${sort}`);
}
resultList.sort(sorters[sort as keyof typeof sorters]);
let order = request.sortOrder;
if (order === "desc") {
resultList.reverse();
}
}
const page = request.page;
const pageSize = request.pageSize || 10;
resultList = resultList.slice((page - 1) * pageSize, page * pageSize);
return {
queryIssues: query.issues,
pageCount: Math.ceil(results.size / pageSize),
resultCount: results.size,
results: resultList,
};
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
await this._controller.modStore.deleteMod(request.name, request.version);
}
async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) {
this.logSubscriptions = {
all: request.all || false,
controller: request.controller || false,
hostIds: request.hostIds || [],
instanceIds: request.instanceIds || [],
};
this.updateLogSubscriptions();
}
updateLogSubscriptions() {
let { all, controller, hostIds, instanceIds } = this.logSubscriptions;
if (all || controller || hostIds.length || instanceIds.length) {
if (!this.logTransport) {
this.logTransport = new lib.LinkTransport({ link: this });
this._controller.clusterLogger.add(this.logTransport);
}
this.logTransport.filter = logFilter(this.logSubscriptions);
} else if (this.logTransport) {
this._controller.clusterLogger.remove(this.logTransport);
this.logTransport = null;
}
}
async handleLogQueryRequest(request: lib.LogQueryRequest) {
let _request = {
limit: request.limit,
order: request.order,
maxLevel: request.maxLevel,
all: request.all,
controller: request.controller,
hostIds: request.hostIds,
instanceIds: request.instanceIds,
};
let observeDuration = queryLogTime.startTimer();
let { all, controller, hostIds, instanceIds } = request;
let log;
if (!all && controller && !hostIds.length && !instanceIds.length) {
log = await this._controller.queryControllerLog(_request);
} else {
log = await this._controller.queryClusterLog(_request);
}
observeDuration();
return { log };
}
async handlePermissionListRequest() {
return [...lib.permissions.values()];
}
async handleRoleListRequest() {
return [...this._controller.userManager.roles.values()];
}
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]);
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
this._controller.userManager.dirty = true;
return id;
}
async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) {
let { id, name, description, permissions } = request;
let role = this._controller.userManager.roles.get(id);
if (!role) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._controller.rolePermissionsUpdated(role);
}
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
let role = this._controller.userManager.roles.get(request.id);
if (!role) {
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
}
role.grantDefaultPermissions();
this._controller.rolePermissionsUpdated(role);
}
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
let id = request.id;
if (!this._controller.userManager.roles.delete(id)) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
let name = request.name;
let user = this._controller.userManager.users.get(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user;
}
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.userManager.users.get(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
if (user.name !== this.user.name) {
this.user.checkPermission("core.user.revoke_other_token");
}
user.invalidateToken();
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.name === user.name) {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.userManager.users.get(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
for (let roleId of request.roles) {
if (!this._controller.userManager.roles.has(roleId)) {
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
}
}
user.roleIds = new Set(request.roles);
this._controller.userPermissionsUpdated(user);
this._controller.usersUpdated([user]);
}
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
let { name, create, admin } = request;
let user = this._controller.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
}
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isBanned = banned;
user.banReason = reason;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
}
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.userManager.users.get(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
user.isDeleted = true;
this._controller.userManager.users.delete(name);
this._controller.usersUpdated([user]);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
}
if (user.isWhitelisted) {
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, false));
}
if (user.isBanned) {
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, false, ""));
}
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
this.ws_dumper = data => {
if (this.connector.connected) {
this.send(new lib.DebugWsMessageEvent(data.direction, data.content));
}
};
this.connector._socket!.clusterio_ignore_dump = true;
this._controller.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
import type Controller from "./Controller";
import type BaseConnection from "./BaseConnection";
import * as lib from "@clusterio/lib";
import HostConnection from "./HostConnection";
import ControlConnection from "./ControlConnection";
const { logger } = lib;
export default class ControllerRouter {
constructor(
public controller: Controller
) {}
/**
* @param origin - Source link of the message.
* @param message - Link the message originated from.
* @param entry - message entry if message is a request or an event.
* @param hasFallback - true if fallback handling is available.
* @returns true if the message was handled
*/
forwardMessage(
origin: ControlConnection | HostConnection,
message: lib.MessageRoutable,
entry: lib.RequestEntry | lib.EventEntry | undefined,
hasFallback: boolean
): boolean {
if (!["request", "response", "responseError", "event"].includes(message.type)) {
throw new Error(`Message type ${message.type} can't be forwarded`);
}
let dst: lib.Address = message.dst;
let nextHop: ControlConnection | HostConnection | undefined;
let msg: string = "";
if (dst.type === lib.Address.broadcast) {
this.broadcastMessage(origin, message, entry as lib.EventEntry);
return true;
} else if (dst.type === lib.Address.host) {
nextHop = this.controller.wsServer.hostConnections.get(dst.id);
if (!nextHop) {
msg = `Host ${dst.id} is offline`;
}
} else if (dst.type === lib.Address.instance) {
let instance = this.controller.instances.get(dst.id);
if (!instance) {
msg = `Instance ${dst.id} does not exist`;
} else {
let assignedHost = instance.config.get("instance.assigned_host");
if (assignedHost === null) {
msg = `Instance ${dst.id} is not assigned a host`;
} else {
nextHop = this.controller.wsServer.hostConnections.get(assignedHost);
if (!nextHop) {
msg = `Assigned host for instance ${dst.id} is offline`;
}
}
}
} else if (dst.type === lib.Address.control) {
nextHop = this.controller.wsServer.controlConnections.get(dst.id);
if (!nextHop) {
msg = "Control connection does not exist";
}
} else if (dst.type === lib.Address.controller) {
msg = `Unexpected message forwarded to ${dst}`;
}
if (nextHop === origin) {
msg = `Message would return back to sender ${origin.connector.dst}.`;
nextHop = undefined;
}
if (message.type === "request") {
if (!nextHop) {
if (hasFallback) {
return false;
}
origin.connector.sendResponseError(
new lib.ResponseError(msg || "Unroutable destination"), message.src
);
return true;
}
}
if (nextHop) {
if (message.type === "request") {
nextHop.forwardRequest(message as lib.MessageRequest, origin);
} else {
nextHop.connector.forward(message);
}
} else {
this.warnUnrouted(message, msg);
}
return true;
}
broadcastMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.EventEntry,
) {
let dst = message.dst;
if (message.type !== "event") {
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
return;
}
const plugin = entry.Event.plugin;
if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (hostConnection !== origin && (!plugin || hostConnection.plugins.has(plugin))) {
hostConnection.connector.forward(message);
}
}
} else if (dst.id === lib.Address.control) {
for (let controlConnection of this.controller.wsServer.controlConnections.values()) {
if (controlConnection !== origin) {
controlConnection.connector.forward(message);
}
}
} else {
this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`);
}
}
warnUnrouted(
message: lib.MessageRoutable,
msg: string
) {
let dst = message.dst;
let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`;
logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { Static, Type } from "@sinclair/typebox";
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
import type UserManager from "./UserManager";
export default class ControllerUser extends User implements IControllerUser {
constructor(
public userManager: UserManager,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
userManager,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this.userManager.roles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this.userManager.roles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+342
View File
@@ -0,0 +1,342 @@
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
/**
* Represents the connection to a host
*
* @extends module:controller/src/BaseConnection
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostInfo;
plugins: Map<string, string>;
constructor(
registerData: lib.RegisterHostData,
connector: WsServerConnector,
controller: Controller,
remoteAddress: string,
) {
super(connector, controller);
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostInfo(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
this.plugins,
true,
remoteAddress,
previousHostInfo?.publicAddress,
previousHostInfo?.tokenValidAfter,
0,
false,
);
this._controller.hosts.set(this.id, this.info);
this._controller.hostsUpdated([this.info]);
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
}
});
}
this.connector.on("close", () => {
const now = Date.now();
// Update status to unknown for instances on this host.
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
}
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
instance.updatedAtMs = now;
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instanceDetailsUpdated(instances);
this.info.connected = false;
this._controller.hostsUpdated([this.info]);
});
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
}
validateIngress(message: lib.MessageRoutable) {
let origin = this.connector.dst;
switch (message.src.type) {
case lib.Address.control:
case lib.Address.controller:
throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
case lib.Address.host:
if (message.src.id !== origin.id) {
throw new lib.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
}
break;
case lib.Address.instance:
let instance = this._controller.instances.get(message.src.id);
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
throw new lib.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
}
break;
default:
throw new Error("Should be unreachable");
}
}
_checkPluginVersions() {
let pluginInfos = new Map(this._controller.pluginInfos.map(i => [i.name, i]));
for (let [name, version] of this.plugins) {
let info = pluginInfos.get(name);
if (!info) {
logger.warn(
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which the ` +
"controller does not have",
);
continue;
}
if (info.version !== version) {
logger.warn(
`Host ${this.info.name} (${this.id}) has plugin ${name} ${version} which does not match ` +
`the version of this plugin on the controller (${info.version})`
);
}
}
for (let [name, info] of pluginInfos) {
if (!this.plugins.has(name)) {
logger.warn(`Host ${this.info.name} (${this.id}) is missing plugin ${name} ${info.version}`);
}
}
}
async prepareDisconnect() {
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
return await super.prepareDisconnect();
}
/**
* ID of the host this connection is connected to
*
* @returns {number} host ID.
*/
get id() {
return this.info.id;
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._controller.hostsUpdated([this.info]);
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
let instance = this._controller.instances.get(request.instanceId);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the host it originated from if it was
// reassigned or deleted while the connection to the host it was
// originally on was down at the time.
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
logger.warn(`Got bogus update for instance id ${request.instanceId}`);
return;
}
// We may receive status changed where the status hasn't changed
// from our perspective if the connection was down at the time it
// changed. Hosts also send status updates on assignInstance which
// for hacky reason is also used to push config changes and
// restablish status after a connection loss.
if (instance.status === request.status) {
return;
}
let prev = instance.status;
instance.status = request.status;
instance.gamePort = request.gamePort;
instance.updatedAtMs = Date.now();
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._controller.instanceDetailsUpdated([instance]);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
// Push updated instance configs
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") === this.id) {
await this.send(
new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host"))
);
}
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceInfo[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
if (controllerInstance) {
// Check if this instance is assigned somewhere else.
if (controllerInstance.config.get("instance.assigned_host") !== this.id) {
await this.send(
new lib.InstanceUnassignInternalRequest(instanceConfig.get("instance.id"))
);
continue;
}
// Already have this instance, update state instead
if (controllerInstance.status !== instanceData.status) {
let prev = controllerInstance.status;
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.updatedAtMs = Date.now();
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
instanceUpdates.push(controllerInstance);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
}
continue;
}
instanceConfig.set("instance.assigned_host", this.id);
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
Date.now(),
);
this._controller.instances.set(instanceConfig.get("instance.id"), newInstance);
this._controller.instancesDirty = true;
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
instanceUpdates.push(newInstance);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
if (instanceUpdates.length) {
this._controller.instanceDetailsUpdated(instanceUpdates);
}
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.name);
}
if (user.isBanned) {
banlist.set(user.name, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.name);
}
}
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
const now = Date.now();
const updates: lib.SaveDetails[] = [];
for (const save of event.updates) {
const existingSave = this._controller.saves.get(save.id);
if (existingSave && save.equals(existingSave)) {
continue;
}
save.updatedAtMs = now;
this._controller.saves.set(save.id, save);
updates.push(save);
}
if (event.instanceId !== undefined) {
const updatedSaves = new Set(event.updates.map(s => s.id));
for (const [id, save] of this._controller.saves) {
if (save.instanceId === event.instanceId && !updatedSaves.has(id)) {
save.isDeleted = true;
save.updatedAtMs = now;
updates.push(save);
this._controller.saves.delete(id);
}
}
}
// Hosts eagerly send updates, which means we may get an update
// where nothing actualy changed.
if (updates.length) {
this._controller.savesUpdated(updates);
}
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
this._controller.clusterLogger.log({
...event.info,
host_id: this.id,
host_name: this.info.name,
});
}
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.users.get(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
if (event.type === "join") {
this._controller.userManager.notifyJoin(user, instanceId);
} else if (event.type === "leave") {
this._controller.userManager.notifyLeave(user, instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
this._controller.usersUpdated([user]);
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.type,
name: event.name,
reason: event.reason,
stats: event.stats,
});
}
}
+88
View File
@@ -0,0 +1,88 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
constructor(
/** Id of this host */
public id: number,
/** Name of this host */
public name: string,
/** Version this host last connected with */
public version: string,
/** Plugins this host last connected with */
public plugins: Map<string, string>,
/** True if this host is currently connected to controller */
public connected: boolean = false,
/** IP this host last connected from */
public remoteAddress: string = "",
/** Value of host.public_address configured for this host */
public publicAddress: string = "",
/** Unix timestamp in seconds host token must be issued after to be valid */
public tokenValidAfter: number = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number = 0,
/** True if this host has been deleted */
public isDeleted: boolean = false,
) { }
static jsonSchema = Type.Object({
"id": Type.Number(),
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"connected": Type.Boolean(),
"remote_address": Type.String(),
"public_address": Type.String(),
"token_valid_after": Type.Number(),
"updated_at_ms": Type.Number(),
"is_deleted": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.version,
new Map(Object.entries(json.plugins)),
json.connected,
json.remote_address,
json.public_address,
json.token_valid_after,
json.updated_at_ms,
json.is_deleted,
);
}
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
connected: this.connected,
remote_address: this.remoteAddress,
public_address: this.publicAddress,
token_valid_after: this.tokenValidAfter,
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
}
toHostDetails() {
return new HostDetails(
this.version,
this.name,
this.id,
this.connected,
this.remoteAddress,
this.publicAddress,
this.tokenValidAfter,
this.updatedAtMs,
this.isDeleted,
);
}
}
@@ -1,6 +1,7 @@
"use strict";
const http = require("http");
const https = require("https");
import type { Socket } from "net";
import http from "http";
import https from "https";
/**
* Gracefully close a Node.js HTTP(S) server
@@ -17,36 +18,40 @@ const https = require("https");
* // in the shutdown logic
* await serverCloser.close();
*
* @alias module:master/src/HttpCloser
* @alias module:controller/src/HttpCloser
*/
class HttpCloser {
export default class HttpCloser {
private _server: http.Server | https.Server;
private _sockets: Set<Socket>;
private _responses: Set<http.ServerResponse>;
/**
* Server to attach this closer to.
* @param {https.Server | http.Server} server - HTTP(S) to attach to
* Create HttpCloser attached to a server.
* @param server - Node.js HTTP(S) server to attach to.
*/
constructor(server) {
constructor(server: http.Server | https.Server) {
this._server = server;
this._sockets = new Set();
this._responses = new Set();
if (this._server instanceof http.Server) {
this._server.on("connection", this._addSocket.bind(this));
} else if (this._server instanceof https.Server) {
if (this._server instanceof https.Server) {
this._server.on("secureConnection", this._addSocket.bind(this));
} else if (this._server instanceof http.Server) {
this._server.on("connection", this._addSocket.bind(this));
} else {
throw Error("server must be an instance of either http.Server or https.Server.");
}
this._server.on("request", this._addRequest.bind(this));
}
_addSocket(socket) {
_addSocket(socket:Socket) {
this._sockets.add(socket);
socket.once("close", () => {
this._sockets.delete(socket);
});
}
_addRequest(request, response) {
_addRequest(request: http.IncomingMessage, response: http.ServerResponse) {
this._responses.add(response);
response.once("close", () => this._responses.delete(response));
response.once("finish", () => this._responses.delete(response));
@@ -62,16 +67,16 @@ class HttpCloser {
* If there's requests in progress that still haven't been completed
* after the timeout value passed they will be aborted.
*
* @param {number} timeout -
* @param timeoutMs -
* Time in ms to wait for connections to close.
*/
async close(timeout = 5000) {
async close(timeoutMs: number = 5000): Promise<void> {
let activeSockets = new Set();
for (let response of this._responses) {
if (!response.headersSent) {
response.setHeader("Connection", "close");
} else {
let socket = response.socket;
let socket = response.socket!;
response.once("finish", () => {
socket.end();
});
@@ -92,7 +97,7 @@ class HttpCloser {
// Node.js, see nodejs/node#27428.
socket.destroy();
}
}, timeout);
}, timeoutMs);
this._server.close(err => {
clearTimeout(timer);
if (err) {
@@ -104,5 +109,3 @@ class HttpCloser {
});
}
};
module.exports = HttpCloser;
+55
View File
@@ -0,0 +1,55 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.updatedAtMs,
);
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.updatedAtMs,
);
}
get isDeleted() {
return this.status === "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
}
}
+116
View File
@@ -0,0 +1,116 @@
import fs from "fs-extra";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import ControllerUser from "./ControllerUser";
/**
* Manages users and roles
* @alias module:controller/src/UserManager
*/
export default class UserManager {
roles: Map<number, lib.Role> = new Map();
users: Map<string, ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
private _config: lib.ControllerConfig
) {
}
async load(filePath: string): Promise<void> {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
for (let serializedRole of content.roles) {
let role = lib.Role.fromJSON(serializedRole);
this.roles.set(role.id, role);
}
for (let serializedUser of content.users) {
let user = ControllerUser.fromJSON(serializedUser, this);
this.users.set(user.name, user);
}
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
// Create default roles if loading failed
lib.ensureDefaultAdminRole(this.roles);
lib.ensureDefaultPlayerRole(this.roles);
}
}
async save(filePath:string): Promise<void> {
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.toJSON());
}
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.toJSON(true));
}
let serialized = {
users: serializedUsers,
roles: serializedRoles,
};
this.dirty = false;
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
}
/**
* Creates a new user and add it to the user database.
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name:string): ControllerUser {
if (this.users.has(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new ControllerUser(this, 0, name, roles);
this.users.set(name, user);
this.dirty = true;
return user;
}
/**
* Sign access token for the given user name
*
* @param name - user name to sign token for
* @returns JWT access token for the user.
*/
signUserToken(name: string): string {
return jwt.sign(
{ aud: "user", user: name },
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
}
+378
View File
@@ -0,0 +1,378 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type Controller from "./Controller";
import jwt from "jsonwebtoken";
import WebSocket, { WebSocketServer } from "ws";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import ControlConnection from "./ControlConnection";
import HostConnection from "./HostConnection";
import WsServerConnector from "./WsServerConnector";
import { version as packageVersion } from "../package.json";
const wsMessageCounter = new lib.Counter(
"clusterio_controller_websocket_message_total",
"How many messages have been received over WebSocket on the controller",
{ labels: ["direction"] }
);
const wsConnectionsCounter = new lib.Counter(
"clusterio_controller_websocket_connections_total",
"How many WebSocket connections have been initiated on the controller"
);
const wsRejectedConnectionsCounter = new lib.Counter(
"clusterio_controller_websocket_rejected_connections_total",
"How many WebSocket connections have been rejected during the handshake on the controller"
);
/**
* WebSocket server
* @alias module:controller/src/WsServer
*/
export default class WsServer {
controller: Controller;
stopAcceptingNewSessions: boolean = false;
controlConnections: Map<number, ControlConnection> = new Map();
hostConnections: Map<number, HostConnection> = new Map();
activeConnectors: Map<number, WsServerConnector> = new Map();
pendingSockets: Set<WebSocket> = new Set();
// Unique string for the session token audience
sessionAud: string = `session-${Date.now()}`;
nextSessionId: number = 1;
nextControlId: number = 1;
wss: WebSocket.Server;
constructor(controller: Controller) {
this.controller = controller;
this.wss = new WebSocket.Server({
noServer: true,
path: "/api/socket",
});
this.wss.on("connection",
(socket: lib.WebSocketClusterio, req: IncomingMessage) => this.handleConnection(socket, req),
);
}
remoteAddr(req: IncomingMessage) {
const remote = req.socket.remoteAddress ?? "";
if (
remote && req.headers["x-forwarded-for"]
&& (
(req.socket.remoteFamily === "IPv4" && this.controller.trustedProxies.check(remote, "ipv4"))
|| (req.socket.remoteFamily === "IPv6" && this.controller.trustedProxies.check(remote, "ipv6"))
)
) {
return (req.headers["x-forwarded-for"] as string).split(",").slice(-1)[0].trim();
}
return remote;
}
async stop(): Promise<void> {
this.stopAcceptingNewSessions = true;
let disconnectTasks = [];
for (let controlConnection of this.controlConnections.values()) {
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
}
for (let hostConnection of this.hostConnections.values()) {
disconnectTasks.push(hostConnection.disconnect(1001, "Server Quit"));
}
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
for (let task of disconnectTasks) {
try {
await task;
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`);
}
}
}
for (let socket of this.pendingSockets) {
socket.close(1001, "Server Quit");
}
}
handleUpgrade(
req: IncomingMessage,
socket: Duplex,
head: Buffer
) {
// For reasons that defy common sense, the connection event has
// to be emitted explictly when using noServer.
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss.emit("connection", ws, req);
});
}
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message: WebSocket.RawData) => {
wsMessageCounter.labels("in").inc();
if (!socket.clusterio_ignore_dump) {
this.controller.debugEvents.emit("message", { direction: "in", content: message.toString("utf8") });
}
});
let originalSend = socket.send;
socket.send = (...args: [string, any]) => {
wsMessageCounter.labels("out").inc();
if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) {
this.controller.debugEvents.emit("message", { direction: "out", content: args[0] });
}
return originalSend.call(socket, ...args);
};
// Start connection handshake.
let loadedPlugins: {[key:string]: string} = {};
for (let [name, plugin] of this.controller.plugins) {
loadedPlugins[name] = plugin.info.version;
}
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
this.attachHandler(socket, req);
}
attachHandler(
socket: WebSocket,
req: IncomingMessage
) {
this.pendingSockets.add(socket);
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.terminate();
this.pendingSockets.delete(socket);
}, 30*1000);
socket.once("message", (message: string) => {
clearTimeout(timeoutId);
this.pendingSockets.delete(socket);
this.handleHandshake(
message, socket, req
).catch(err => {
logger.error(`
+------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
});
});
}
async handleHandshake(rawMessage: string, socket: WebSocket, req: IncomingMessage) {
let message;
try {
const json = JSON.parse(rawMessage);
lib.Message.validate(json);
message = lib.Message.fromJSON(json);
} catch (err: any) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid message`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, `Invalid message: ${err.message}`);
return;
}
if (message.type === "resume") {
this.resumeSession((message as lib.MessageResume).data, socket, req);
return;
}
if (message.type !== "registerHost" && message.type !== "registerControl") {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Bad handshake");
return;
}
if (this.stopAcceptingNewSessions) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)}, server is shutting down`);
wsRejectedConnectionsCounter.inc();
socket.close(1001, "Shutting down");
return;
}
if (message.type === "registerHost") {
await this.registerHost((message as lib.MessageRegisterHost).data, socket, req);
return;
}
if (message.type === "registerControl") {
await this.registerControl((message as lib.MessageRegisterControl).data, socket, req);
return;
}
throw new Error("This should be unreachable");
}
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(sessionId);
});
return [connector, sessionToken];
}
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
// migrate: allow pre-rename tokens issued to hosts before alpha-14
if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) {
throw new Error("missmatched host id");
}
let host = this.controller.hosts.get(data.id);
if (!tokenPayload.iat || tokenPayload.iat < (host?.tokenValidAfter ?? 0)) {
throw new Error("invalid token");
}
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
return;
}
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
let connection = this.hostConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`);
await connection.disconnect(1008, "Registered from another connection");
}
logger.info(
`WsServer | registered host ${data.id} using version ${data.version}`
);
if (data.version !== packageVersion) {
logger.warn(
`Host ${data.id} connected using version ${data.version} which does not match the ` +
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
);
}
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
connector.on("close", () => {
if (this.hostConnections.get(data.id) === connection) {
this.hostConnections.delete(data.id);
} else {
logger.warn("Unlisted HostConnection closed");
}
});
this.hostConnections.set(data.id, connection);
let src = new lib.Address(lib.Address.host, data.id);
connector.ready(socket, src, sessionToken, undefined);
}
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if ((tokenPayload.iat || 0) < user.tokenValidAfter) {
throw new Error("invalid token");
}
user.checkPermission("core.control.connect");
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
return;
}
let id = this.nextControlId;
this.nextControlId += 1;
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.control, id));
logger.verbose(`WsServer | registered control ${id} from ${this.remoteAddr(req)}`);
let connection = new ControlConnection(data, connector, this.controller, user, id);
connector.on("close", () => {
this.controlConnections.delete(id);
});
this.controlConnections.set(id, connection);
let account = new lib.AccountDetails(
user.name,
[...user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
}))
);
let src = new lib.Address(lib.Address.control, id);
connector.ready(socket, src, sessionToken, account);
}
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
connector = this.activeConnectors.get(payload.sid);
if (!connector) {
throw new Error();
}
} catch (err) {
socket.send(JSON.stringify(new lib.MessageInvalidate()));
this.attachHandler(socket, req);
return;
}
connector.continue(socket, data.lastSeq as number);
}
}
@@ -1,24 +1,27 @@
"use strict";
const events = require("events");
import type WebSocket from "ws";
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
import events from "events";
import * as lib from "@clusterio/lib";
const { logger } = lib;
/**
* Connector for master server connections
* Connector for controller connections
*
* @extends module:lib/link.WebSocketBaseConnector
* @alias module:master/src/WsServerConnector
* @extends module:lib.WebSocketBaseConnector
* @alias module:controller/src/WsServerConnector
*/
class WsServerConnector extends libLink.WebSocketBaseConnector {
constructor(sessionId, sessionTimeout, heartbeatInterval) {
super(sessionTimeout);
export default class WsServerConnector extends lib.WebSocketBaseConnector {
_timeoutId: ReturnType<typeof setTimeout> | null = null;
this._socket = null;
this._sessionId = sessionId;
this._heartbeatInterval = heartbeatInterval;
this._timeoutId = null;
constructor(
dst: lib.Address,
private _sessionId: number,
private _sessionTimeout: number,
public _heartbeatInterval: number,
) {
super(new lib.Address(lib.Address.controller, 0), dst);
// The following states are used in the server connector
// closed: Connection is closed
@@ -27,8 +30,10 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
}
_reset() {
clearTimeout(this._timeoutId);
this._timeoutId = null;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
super._reset();
}
@@ -37,28 +42,31 @@ 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.
* @param socket - WebSocket connection to client.
* @param src - Source address for this link.
* @param sessionToken - the session token to send to the client.
* @param account - account data to provide to control connection
*/
ready(socket, sessionToken, additionalData) {
ready(
socket: WebSocket.WebSocket,
src: lib.Address,
sessionToken: string,
account: lib.AccountDetails | undefined
) {
this._socket = socket;
this._socket.send(JSON.stringify({
seq: null,
type: "ready",
data: {
session_token: sessionToken,
session_timeout: this._sessionTimeout,
heartbeat_interval: this._heartbeatInterval,
...additionalData,
},
}));
this._sendInternal(new lib.MessageReady(
new lib.ReadyData(
src,
sessionToken,
this._sessionTimeout,
this._heartbeatInterval,
account,
),
));
this._state = "connected";
this._attachSocketHandlers();
this.emit("connect");
this.emit("connect", { src: this.src });
}
/**
@@ -67,14 +75,17 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
* Terminates the current socket and contiunes the session over the
* socket given from the message sequence given.
*
* @param {module:net.Socket} socket - New socket to continue on.
* @param {number} lastSeq - The last message the client received.
* @param socket - New socket to continue on.
* @param lastSeq - The last message the client received.
*/
continue(socket, lastSeq) {
continue(
socket: WebSocket.WebSocket,
lastSeq: number
) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. Terminate it if so.
if (this._state === "connected") {
if (this._state === "connected" && this._socket !== null) {
this._socket.terminate();
this._socket.once("close", () => this.continue(socket, lastSeq));
return;
@@ -87,15 +98,9 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
this._timeoutId = null;
}
this._socket.send(JSON.stringify({
seq: null,
type: "continue",
data: {
last_seq: this._lastReceivedSeq,
session_timeout: this._sessionTimeout,
heartbeat_interval: this._heartbeatInterval,
},
}));
this._sendInternal(new lib.MessageContinue(
new lib.ContinueData(this._sessionTimeout, this._heartbeatInterval, this._lastReceivedSeq)
));
this._state = "connected";
this._attachSocketHandlers();
@@ -113,11 +118,16 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
}
_attachSocketHandlers() {
if (this._socket === null) {
throw new Error("this._socket is null");
}
this.startHeartbeat();
this._socket.on("close", (code, reason) => {
this._socket.on("close", (code: number, reason: string) => {
logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`);
this.stopHeartbeat();
this._socket = null;
if (this._closing) {
this._reset();
@@ -128,10 +138,9 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
this.emit("drop");
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
}
});
this._socket.on("error", err => {
this._socket.on("error", (err: any) => {
// It's assumed that close is always called by ws
logger.verbose("Connector | Error:", err);
});
@@ -139,27 +148,21 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
this._socket.on("open", () => {
logger.verbose("Connector | Open");
});
this._socket.on("ping", data => {
this._socket.on("ping", (data: string) => {
logger.verbose(`Connector | Ping (data: ${data}`);
});
this._socket.on("pong", data => {
this._socket.on("pong", (data: string) => {
logger.verbose(`Connector | Pong (data: ${data}`);
});
// Handle messages
this._socket.on("message", data => {
let message = JSON.parse(data);
if (["connected", "closing"].includes(this._state)) {
if (message.seq !== null) {
this._lastReceivedSeq = message.seq;
}
if (message.type === "heartbeat") {
this._processHeartbeat(message);
} else {
this.emit("message", message);
}
this._socket.on("message", (data: string) => {
let message = this._parseMessage(data);
if (!message) {
return;
}
if (this._state === "connected") {
this._processMessage(message);
} else {
throw new Error(`Received message in unexpected state ${this._state}`);
@@ -172,10 +175,13 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
*
* Sends a close frame and disconnects the connector.
*
* @param {number} code - WebSocket close code.
* @param {string} reason - WebSocket close reason.
* @param code - WebSocket close code.
* @param reason - WebSocket close reason.
*/
async close(code, reason) {
async close(
code: number,
reason: string
): Promise<void> {
if (this._state === "closed") {
return;
}
@@ -187,10 +193,26 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
}
this._closing = true;
this._socket.close(code, reason);
if (this._socket !== null) {
this._socket.close(code, reason);
}
await events.once(this, "close");
}
/**
* Forcefully close the connection immediately
*
* This should only be used if there's a security implication from
* letting the connection carry on with a normal close proceedure.
*/
terminate() {
this.setClosing();
if (this._socket) {
this._socket.terminate();
}
}
/**
* Notify the connection is expected to close
*
@@ -209,5 +231,3 @@ class WsServerConnector extends libLink.WebSocketBaseConnector {
this._closing = true;
}
}
module.exports = WsServerConnector;
+13
View File
@@ -0,0 +1,13 @@
import * as lib from "@clusterio/lib";
import util from "util";
export const endpointHitCounter = new lib.Counter(
"clusterio_controller_http_endpoint_hits_total",
"How many requests a particular HTTP endpoint has gotten",
{ labels: ["route"], register: false }
);
endpointHitCounter.labels = util.deprecate(
endpointHitCounter.labels,
"incrementing endpointHitCounter is no longer needed and has no effect"
);
+693
View File
@@ -0,0 +1,693 @@
import type { Application, Request, Response } from "express";
import type Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
import events from "events";
import fs from "fs-extra";
import JSZip from "jszip";
import jwt from "jsonwebtoken";
import path from "path";
import nodeStream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
const { logger } = lib;
const finished = util.promisify(nodeStream.finished);
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
export interface Locals {
controller: Controller,
mainBundle: string,
devPlugins: Map<string, number>,
streams: Map<string, ProxyStream>
}
}
}
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceResult: lib.CollectorResultSerialized) {
let receivedSamples = new Map(sourceResult.samples);
for (let [suffix, suffixSamples] of destinationResult.samples) {
if (receivedSamples.has(suffix)) {
suffixSamples.push(...receivedSamples.get(suffix) as any[]);
receivedSamples.delete(suffix);
}
}
for (let entry of receivedSamples) {
destinationResult.samples.push(entry);
}
}
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response, next: any) {
const controller: Controller = req.app.locals.controller;
let results: lib.CollectorResult[] = [];
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
}
}
let requests: Promise<InstanceType<typeof lib.HostMetricsRequest["Response"]> | null>[] = [];
let timeoutMs = controller.config.get("controller.metrics_timeout") * 1000;
for (let [hostId, hostConnection] of controller.wsServer.hostConnections) {
if (!hostConnection.connected) {
continue;
}
requests.push(lib.timeout(
hostConnection.send(new lib.HostMetricsRequest()).catch((err: any) => {
if (!(err instanceof lib.SessionLost)) {
logger.error(`Unexpected error gathering metrics from host:\n${err.stack}`);
}
return null;
}),
timeoutMs, null
));
}
for await (let result of lib.defaultRegistry.collect()) {
results.push(result);
}
let resultMap: Map<string, lib.CollectorResultSerialized> = new Map();
for (let response of await Promise.all(requests)) {
if (!response) {
// TODO: Log timeout occured?
continue;
}
for (let result of response.results) {
if (!resultMap.has(result.metric.name)) {
resultMap.set(result.metric.name, result);
} else {
// Merge metrics received by multiple hosts
mergeSamples(resultMap.get(result.metric.name)!, result);
}
}
}
for (let result of resultMap.values()) {
results.push(lib.deserializeResult(result));
}
let text = await lib.exposition(results);
res.set("Content-Type", lib.expositionContentType);
res.send(text);
}
function getPlugins(req: Request, res: Response) {
let plugins: lib.PluginWebApi[] = [];
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
web.main = stats.toJson().assetsByChunkName[name];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
}
res.send(plugins);
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let host = req.app.locals.controller.hosts.get(tokenPayload.host);
if (!host) {
throw new Error("invalid host");
}
if (!tokenPayload.iat || tokenPayload.iat < (host.tokenValidAfter ?? 0)) {
throw new Error("invalid token");
}
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
function validateUserToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (!tokenPayload.iat || tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
res.locals.user = user;
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
// Handle an uploaded export package.
async function uploadExport(req: Request, res: Response) {
if (req.get("Content-Type") !== "application/zip") {
res.sendStatus(415);
return;
}
if (typeof req.query.mod_pack_id !== "string") {
res.sendStatus(400);
return;
}
let modPackId = Number.parseInt(req.query.mod_pack_id, 10);
if (!Number.isInteger(modPackId)) {
res.sendStatus(400);
return;
}
let modPack = res.app.locals.controller.modPacks.get(modPackId);
if (!modPack) {
res.sendStatus(400);
return;
}
let data = [];
for await (let chunk of req) {
data.push(chunk);
}
let buffer: Buffer | null = Buffer.concat(data);
let zip = await JSZip.loadAsync(buffer);
buffer = null;
// This is hardcoded to prevent path expansion attacks
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
];
let assets: any = {};
let settingPrototypes = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
if (filePath === "export/settings.json") {
settingPrototypes = JSON.parse(await file.async("text"));
}
let { name, ext } = path.posix.parse(filePath);
let hash = await lib.hashStream(file.nodeStream());
assets[name] = `${name}.${hash}${ext}`;
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacksUpdated([modPack]);
res.sendStatus(200);
}
export interface ProxyStream {
id: string;
source: nodeStream.Readable | null;
flowing: boolean;
size?: string;
mime?: string;
filename: string | null;
events: events.EventEmitter;
timeout: ReturnType<typeof setTimeout>;
}
export async function createProxyStream(app: Application): Promise<ProxyStream> {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
let stream: ProxyStream = {
id,
source: null,
flowing: false,
size: undefined,
mime: undefined,
filename: null,
events: new events.EventEmitter(),
timeout: setTimeout(() => {
stream.events.emit("timeout");
}, app.locals.controller.config.get("controller.proxy_stream_timeout") * 1000),
};
stream.events.on("close", () => {
clearTimeout(stream.timeout);
app.locals.streams.delete(id);
});
stream.events.on("timeout", () => {
stream.events.emit("close");
});
app.locals.streams.set(id, stream);
return stream;
}
async function putStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
return;
}
stream.source = req;
stream.mime = req.get("Content-Type");
stream.size = req.get("Content-Length");
stream.events.emit("source");
stream.events.on("close", () => {
if (!stream.flowing) {
req.resume();
res.sendStatus(500);
} else {
res.sendStatus(200);
}
});
}
function startStream(res: Response, stream: ProxyStream) {
res.append("Content-Type", stream.mime);
if (stream.size) {
res.append("Content-Length", stream.size);
}
if (stream.filename) {
res.append("Content-Disposition", `attachment; filename="${stream.filename}"`);
} else {
res.append("Content-Disposition", "attachment");
}
stream.source!.pipe(res);
stream.flowing = true;
res.on("close", () => {
stream.events.emit("close");
});
clearTimeout(stream.timeout);
}
async function getStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
return;
}
if (stream.source) {
startStream(res, stream);
} else {
stream.events.on("source", () => startStream(res, stream));
stream.events.on("timeout", () => {
res.sendStatus(500);
});
}
}
const zipMimes = [
"application/zip",
"application/x-zip-compressed",
];
const contentTypeRegExp = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/;
async function uploadSave(req: Request, res: Response) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
} catch (err: any) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type") || "";
let match = contentTypeRegExp.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let contentMime = match[1].toLowerCase();
let tasks = [];
let errors: string[] = [];
let requestErrors = [];
let saves: string[] = [];
async function handleFile(instanceId:number, stream: nodeStream.Readable, filename: string, streamMime: string) {
let proxyStream = await createProxyStream(req.app);
proxyStream.source = stream;
proxyStream.mime = streamMime;
let timeout = new Promise<never>((_, reject) => {
proxyStream.events.on("timeout", () => {
stream.resume();
reject(new Error("Timed out establishing stream to host"));
});
});
try {
let storedName: string = await Promise.race([
req.app.locals.controller.sendToHostByInstanceId(
new lib.InstancePullSaveRequest(
instanceId,
proxyStream.id,
filename,
)
),
timeout,
]);
saves.push(storedName);
} catch (err: any) {
proxyStream.events.emit("close");
logger.error(`Error uploading save: ${err.message}`);
errors.push(err.message);
stream.resume();
}
}
if (contentMime === "multipart/form-data") {
await new Promise(resolve => {
let instanceId: number | undefined;
function fileHandler(
name: string,
stream: nodeStream.Readable,
{ filename, mimeType }: { filename: string, mimeType: string }
) {
if (instanceId === undefined) {
requestErrors.push("instance_id must come before files uploaded");
}
if (!zipMimes.includes(mimeType)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(instanceId as number, stream, filename, mimeType));
}
let parser = busboy({ headers: req.headers });
parser.on("file", fileHandler);
parser.on("field", (name, value, info) => {
if (name === "instance_id") {
instanceId = Number.parseInt(value, 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("invalid instance_id");
}
}
});
parser.on("close", resolve);
parser.on("error", (err: any) => {
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(parser);
});
} else if (zipMimes.includes(contentMime)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
let instanceId = Number.parseInt(String(req.query.instance_id), 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("Missing or invalid instance_id parameter");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(instanceId, req, filename as string, contentMime));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ saves });
}
function checkModName(name: string) {
try {
lib.checkFilename(name);
} catch (err: any) {
throw new lib.RequestError(`Mod name ${err.message}`);
}
}
async function uploadMod(req: Request, res: Response) {
try {
res.locals.user.checkPermission("core.mod.upload");
} catch (err: any) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type")!;
let match = contentTypeRegExp.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let contentMime = match[1].toLowerCase();
let tasks = [];
let errors: string[] = [];
let requestErrors = [];
let mods: any[] = [];
async function handleFile(stream: nodeStream.Readable, filename: string) {
try {
checkModName(filename);
} catch (err: any) {
logger.error(`Error uploading mod: ${err.message}`);
errors.push(err.message);
stream.resume();
return;
}
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream;
try {
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
checkModName(modInfo.filename);
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
req.app.locals.controller.modStore.addMod(modInfo);
mods.push(modInfo.toJSON());
} catch (err: any) {
logger.error(`Error uploading mod: ${err.message}`);
errors.push(err.message);
stream.resume();
// Attempt to clean up.
if (writeStream) {
writeStream.destroy();
}
try {
await fs.unlink(path.join(modsDirectory, tempFilename));
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
}
}
}
}
if (contentMime === "multipart/form-data") {
await new Promise(resolve => {
function fileHandler(
name: string,
stream: nodeStream.Readable,
{ filename, mimeType }: { filename: string, mimeType: string }
) {
if (!zipMimes.includes(mimeType)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(stream, filename));
}
let parser = busboy({ headers: req.headers });
parser.on("file", fileHandler);
parser.on("close", resolve);
parser.on("error", (err: any) => {
logger.error(`Error parsing multipart request in upload-mod:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(parser);
});
} else if (zipMimes.includes(contentMime)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(req, filename as string));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ mods });
}
export function addRouteHandlers(app: Application) {
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
);
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
);
app.post("/api/upload-mod",
validateUserToken,
(req:Request, res:Response, next:any) => uploadMod(req, res).catch(next)
);
}
// Routes used in the web interface and served by the controller
export const webRoutes = [
"/",
"/controller",
"/hosts",
"/hosts/:id/view",
"/instances",
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
];
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
},
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.web.json",
"references": [
{ "path": "../lib/tsconfig.web.json" },
{ "path": "../web_ui/tsconfig.web.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"rootDir": "..",
"outDir": "dist/web/build",
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+49
View File
@@ -0,0 +1,49 @@
<!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>__CLUSTER_NAME__</title>
<style>
html {
color: #fff;
background-color: #000;
}
#bootstrap-msg {
font: 20px serif;
color: #aaa;
margin: auto;
margin-top: 1em;
width: fit-content;
white-space: pre;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
<div id="bootstrap-msg">Loading web interface...</div>
</div>
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
<script>
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
function scriptError(err) {
console.error(err);
let bootstrapMsg = document.getElementById("bootstrap-msg");
if (bootstrapMsg) {
let errorMsg = err;
if (err?.message) {
errorMsg = `${err?.constructor?.name ?? "Error"}: ${err.message}`;
}
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
}
}
const bundleScript = document.getElementById("script-bundle");
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
window.addEventListener("error", event => scriptError(event.error));
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
declare function scriptError(err: any): void;
import("@clusterio/web_ui")
.then(webUi => webUi.bootstrap())
.catch(scriptError)
;
File renamed without changes.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -7,7 +7,7 @@ const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
entry: "./web/index.tsx",
devServer: {
contentBase: "./dist/web",
},
@@ -16,7 +16,7 @@ module.exports = (env = {}) => merge(common(env), {
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "master",
name: "controller",
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
+291 -92
View File
@@ -86,7 +86,8 @@ class InstallError extends Error { }
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
@@ -110,28 +111,28 @@ async function execFile(cmd, args) {
});
}
async function execMaster(args) {
async function execController(args) {
if (dev) {
return await execFile("node", [path.join("packages", "master"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args);
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
}
async function execSlave(args) {
async function execHost(args) {
if (dev) {
return await execFile("node", [path.join("packages", "slave"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join("packages", "ctl"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
function validateSlaveToken(token) {
function validateHostToken(token) {
let parts = token.split(".");
if (parts.length !== 3) {
throw new InstallError("Invalid token");
@@ -144,7 +145,7 @@ function validateSlaveToken(token) {
throw new InstallError("Invalid token");
}
if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) {
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
throw new InstallError("Invalid token");
}
}
@@ -181,9 +182,156 @@ async function validateNotRoot(args) {
}
}
async function migrateRename(args) {
function rename(string) {
return string.replace(/master/g, "controller").replace(/slave/g, "host");
}
function renameConfig(config) {
for (let group of config["groups"]) {
group["name"] = rename(group["name"]);
group["fields"] = Object.fromEntries(
Object.entries(group["fields"]).map(([k, v]) => [rename(k), v])
);
}
return config;
}
async function migrateConfig(source, destination) {
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
));
logger.info(`Migrated ${source} to ${destination}`);
}
}
await migrateConfig("config-master.json", "config-controller.json");
await migrateConfig("config-slave.json", "config-host.json");
let instancesFile = path.join("database", "instances.json");
if (await fs.pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
logger.info(`Migrated ${instancesFile}`);
}
let usersFile = path.join("database", "users.json");
if (await fs.pathExists(usersFile)) {
let users = JSON.parse(await fs.readFile(usersFile));
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
}
await safeOutputFile(usersFile, JSON.stringify(users, null, "\t"));
logger.info(`Migrated ${usersFile}`);
}
function renameLogLine(info) {
return Object.fromEntries(
Object.entries(info).map(([k, v]) => [rename(k), v])
);
}
async function migrateLog(inputFile, outputFile) {
let lineStream = new LineSplitter({ readableObjectMode: true });
let fileStream = fs.createReadStream(inputFile);
fileStream.pipe(lineStream);
let lines = [];
for await (let inputLine of lineStream) {
try {
lines.push(Buffer.from(JSON.stringify(renameLogLine(JSON.parse(inputLine)))));
lines.push(Buffer.from("\n"));
} catch (err) {
if (!(err instanceof SyntaxError)) {
throw err;
}
logger.warn(`Invalid log line: ${inputLine.toString()}`);
lines.push(inputLine);
}
}
safeOutputFile(outputFile, Buffer.concat(lines));
}
async function migrateLogsDir(inputLogs, outputLogs) {
if (await fs.pathExists(inputLogs)) {
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
for (let logFile of await fs.readdir(inputLogs)) {
if (!logFile.endsWith(".log")) {
continue;
}
let inputFile = path.join(inputLogs, logFile);
let outputFile = path.join(outputLogs, rename(logFile));
if (!await fs.pathExists(outputFile)) {
await migrateLog(inputFile, outputFile);
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
}
}
}
}
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
if (
await fs.pathExists(path.join("logs", "cluster"))
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
) {
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
}
if (
await fs.pathExists("sharedMods")
&& !await fs.pathExists("mods")
) {
logger.info("Moving sharedMods/ to mods/");
await fs.rename("sharedMods", "mods");
}
if (!args.dev) {
let pkg = JSON.parse(await fs.readFile("package.json"));
if (pkg.dependencies) {
let convert = ["@clusterio/master", "@clusterio/slave"];
let uninstall = convert.filter(c => pkg.dependencies[c] !== undefined);
let install = uninstall.map(rename);
if (uninstall.length) {
logger.info(`Replacing ${uninstall.join(" and ")}`);
await execFile(`npm${scriptExt}`, ["install", ...install]);
await execFile(`npm${scriptExt}`, ["uninstall", ...uninstall]);
}
}
logger.info("Updating packages");
await execFile(`npm${scriptExt}`, ["update"]);
}
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
if (hasRunMaster || hasRunSlave) {
logger.info("Writing run scripts");
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode);
}
logger.info(
"Migration complete, you may now delete the following left over files and directories (if present):" +
"\n- config-master.json" +
"\n- config-slave.json" +
"\n- logs/master" +
"\n- logs/slave" +
"\n- logs/cluster-prerename" +
"\n- systemd/clusteriomaster.service" +
"\n- systemd/clusterioslave.service" +
"\n- run-master.sh / run-master.cmd" +
"\n- run-slave.sh / run-slave.cmd"
);
}
async function installClusterio(mode, plugins) {
try {
await safeOutputFile("package.json", JSON.stringify({
await fs.outputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -194,11 +342,11 @@ async function installClusterio(mode, plugins) {
}
let components = [];
if (["standalone", "master"].includes(mode)) {
components.push("@clusterio/master");
if (["standalone", "controller"].includes(mode)) {
components.push("@clusterio/controller");
}
if (["standalone", "slave"].includes(mode)) {
components.push("@clusterio/slave");
if (["standalone", "host"].includes(mode)) {
components.push("@clusterio/host");
}
if (mode === "ctl") {
components.push("@clusterio/ctl");
@@ -226,12 +374,12 @@ async function installClusterio(mode, plugins) {
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()] }));
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
pluginList.set(pluginInfo.name, plugin);
}
}
try {
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
@@ -250,22 +398,38 @@ async function groupIdToName(gid) {
}
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
"run-master.cmd",
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
"run-controller.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
:restart
call .\\node_modules\\.bin\\clusteriocontroller.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
goto restart
`
);
} else {
await safeOutputFile(
"run-master.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
"run-controller.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
while true; do
./node_modules/.bin/clusteriocontroller run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
await safeOutputFile(
"systemd/clusteriomaster.service",
`[Unit]
Description=Clusterio Master
"systemd/clusteriocontroller.service",
`\
[Unit]
Description=Clusterio Controller
[Service]
User=${os.userInfo().username}
@@ -273,30 +437,50 @@ Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
Environment=NODE_OPTIONS=--enable-source-maps
ExecStart=${process.cwd()}/node_modules/.bin/clusteriocontroller run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
[Install]
WantedBy=multi-user.target
`);
`
);
}
}
if (["standalone", "slave"].includes(mode)) {
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
"run-slave.cmd",
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
goto restart
`
);
} else {
await safeOutputFile(
"run-slave.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
await safeOutputFile(
"systemd/clusterioslave.service",
`[Unit]
Description=Clusterio Slave
"systemd/clusteriohost.service",
`\
[Unit]
Description=Clusterio Host
[Service]
User=${os.userInfo().username}
@@ -304,11 +488,15 @@ Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn
Environment=NODE_OPTIONS=--enable-source-maps
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
[Install]
WantedBy=multi-user.target
`);
`
);
}
}
}
@@ -324,16 +512,16 @@ async function inquirerMissingArgs(args) {
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: "Standalone (install both controller and host on this computer)", value: "standalone" },
{ name: "Controller only", value: "controller" },
{ name: "Host only", value: "host" },
{ name: "Ctl only", value: "ctl" },
{ name: "Plugins only", value: "plugins" },
],
},
], answers);
if (["standalone", "master"].includes(answers.mode)) {
if (["standalone", "controller"].includes(answers.mode)) {
if (args.admin) { answers.admin = args.admin; }
answers = await inquirer.prompt([
{
@@ -350,42 +538,42 @@ async function inquirerMissingArgs(args) {
], answers);
}
if (answers.mode === "slave") {
if (args.slaveName) { answers.slaveName = args.slaveName; }
if (answers.mode === "host") {
if (args.hostName) { answers.hostName = args.hostName; }
answers = await inquirer.prompt([
{
type: "input",
name: "slaveName",
message: "Name of slave",
name: "hostName",
message: "Name of host",
},
], answers);
}
if (["slave", "ctl"].includes(answers.mode)) {
if (args.masterUrl) { answers.masterUrl = args.masterUrl; }
if (["host", "ctl"].includes(answers.mode)) {
if (args.controllerUrl) { answers.controllerUrl = args.controllerUrl; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterUrl",
message: "Master server URL",
name: "controllerUrl",
message: "Controller URL",
},
], answers);
if (args.masterToken) { answers.masterToken = args.masterToken; }
if (args.controllerToken) { answers.controllerToken = args.controllerToken; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterToken",
message: "Master authentication Token",
name: "controllerToken",
message: "Controller authentication Token",
},
], answers);
}
if (answers.mode === "slave") {
validateSlaveToken(answers.masterToken);
if (answers.mode === "host") {
validateHostToken(answers.controllerToken);
}
if (["standalone", "slave"].includes(answers.mode)) {
if (["standalone", "host"].includes(answers.mode)) {
let myIp = "localhost";
if (args.publicAddress) {
answers.publicAddress = args.publicAddress;
@@ -424,7 +612,7 @@ async function inquirerMissingArgs(args) {
},
], answers);
if (answers.downloadHeadless) {
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
@@ -532,25 +720,28 @@ async function main() {
})
.option("mode", {
nargs: 1, describe: "Operating mode to install",
choices: ["standalone", "master", "slave", "ctl", "plugins"],
choices: ["standalone", "controller", "host", "ctl", "plugins"],
})
.option("migrate-rename", {
nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean",
})
.option("admin", {
nargs: 1, describe: "Admin account name [standalone/master]", type: "string",
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
})
.option("slave-name", {
nargs: 1, describe: "Slave name [slave]", type: "string",
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
.option("master-url", {
nargs: 1, describe: "Master URL [slave/ctl]", type: "string",
.option("controller-url", {
nargs: 1, describe: "Controller URL [host/ctl]", type: "string",
})
.option("master-token", {
nargs: 1, describe: "Master authentication token [slave/ctl]", type: "string",
.option("controller-token", {
nargs: 1, describe: "Controller authentication token [host/ctl]", type: "string",
})
.option("public-address", {
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/slave]", type: "string",
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/host]", type: "string",
})
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/slave]", type: "string",
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
@@ -569,7 +760,7 @@ async function main() {
"Can be set to false using --no-download-headless.",
type: "boolean",
})
.conflicts("factorio-dir", "download-headless");
;
}
args = args.argv;
@@ -582,8 +773,16 @@ async function main() {
await validateNotRoot(args);
}
if (args.migrateRename) {
await migrateRename(args);
return;
}
let answers = await inquirerMissingArgs(args);
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
@@ -594,47 +793,47 @@ async function main() {
}
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]);
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["bootstrap", "create-admin", answers.admin]);
let result = await execController(["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"]);
logger.info("Setting up host");
await execHost(["config", "set", "host.name", "local"]);
let result = await execSlave(["config", "show", "slave.id"]);
let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
let result = await execHost(["config", "show", "host.id"]);
let hostId = 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];
result = await execController(["bootstrap", "generate-host-token", hostId]);
let hostToken = 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]);
await execHost(["config", "set", "host.controller_token", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.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 (answers.mode === "host") {
logger.info("Setting up host");
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
await execHost(["config", "set", "host.id", hostId]);
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
}
if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) {
if (!dev && ["standalone", "controller", "host"].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]);
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
}
/* eslint-disable no-console */
+26 -26
View File
@@ -1,28 +1,28 @@
{
"name": "@clusterio/create",
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.13",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
],
"engines": {
"node": ">=12"
},
"dependencies": {
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"inquirer": "^8.1.0",
"phin": "^3.5.1",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
"name": "@clusterio/create",
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.1.1",
"inquirer": "^8.2.4",
"phin": "^3.7.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
}
}
+3 -3
View File
@@ -37,7 +37,7 @@ For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -51,7 +51,7 @@ For example, uninstalling the Subspace Storage plugin:
npx clusterioctl plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -62,7 +62,7 @@ Lists the plugins set up to be available by name followed by path.
### `config-control`
Manage the ctl configuration.
This allows setting the url and token to the master server to manage.
This allows setting the url and token to the controller to manage.
#### `config-control set <config-entry> [value]`
-345
View File
@@ -1,345 +0,0 @@
#!/usr/bin/env node
/**
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
"use strict";
const fs = require("fs-extra");
const yargs = require("yargs");
const version = require("./package").version;
const setBlocking = require("set-blocking");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libConfig = require("@clusterio/lib/config");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libCommand = require("@clusterio/lib/command");
const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const commands = require("./src/commands");
/**
* Connector for control connection to master server
* @private
*/
class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, maxReconnectDelay, tlsCa, token) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
register() {
logger.verbose("Connector | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterioctl",
version: version,
});
}
}
/**
* Handles running the control
*
* Connects to the master server over WebSocket and sends commands to it.
* @static
*/
class Control extends libLink.Link {
constructor(connector, controlConfig, tlsCa, controlPlugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* Control config used for connecting to the master.
* @type {module:lib/config.ControlConfig}
*/
this.config = controlConfig;
/**
* Certificate authority used to validate TLS connections to the master.
* @type {?string}
*/
this.tlsCa = tlsCa;
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin);
}
/**
* Keep the control connection alive after the command completes.
* @type {boolean}
*/
this.keepOpen = false;
}
async accountUpdateEventHandler() { }
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
async saveListUpdateEventHandler() { }
async userUpdateEventHandler() { }
async setLogSubscriptions({
all = false,
master = false,
slave_ids = [],
instance_ids = [],
max_level = null,
}) {
await libLink.messages.setLogSubscriptions.send(this, {
all, master, slave_ids, instance_ids, max_level,
});
}
async logMessageEventHandler(message) {
logger.log(message.data.info);
}
async debugWsMessageEventHandler(message) {
print("WS", message.data.direction, message.data.content);
}
async shutdown() {
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
throw err;
}
}
await this.connector.close(1000, "Control Quit");
}
}
async function loadPlugins(pluginList) {
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let controlPlugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.controlEntrypoint) {
continue;
}
let ControlPluginClass = await libPluginLoader.loadControlPluginClass(pluginInfo);
let controlPlugin = new ControlPluginClass(pluginInfo, logger);
controlPlugins.set(pluginInfo.name, controlPlugin);
await controlPlugin.init();
}
return controlPlugins;
}
async function startControl() {
yargs
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stderr",
default: "server",
choices: Object.keys(levels),
type: "string",
})
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-control.json",
defaultDescription: "auto",
type: "string",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
default: "plugin-list.json",
type: "string",
})
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
.command("control-config", "Manage Control config", libSharedCommands.configCommand)
.wrap(yargs.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
let args = yargs.parse();
// Log stream for the ctl session.
logger.add(new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new libLoggingUtils.TerminalFormat(),
}));
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList);
return;
}
logger.verbose("Loading Plugins");
let controlPlugins = await loadPlugins(pluginList);
// Add all cluster management commands including ones from plugins
let rootCommands = await commands.registerCommands(controlPlugins, yargs);
// Reparse after commands have been added with help and strict checking.
args = yargs
.help()
.strict()
.parse()
;
logger.verbose(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig("control");
try {
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
} catch (err) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
await controlConfig.init();
} else {
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
if (args._.length === 0) {
yargs.showHelp();
yargs.exit();
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await libSharedCommands.handleConfigCommand(args, controlConfig, args.config);
return;
}
// Determine which command is being executed.
let commandPath = [...args._];
let targetCommand = rootCommands;
while (commandPath.length && targetCommand instanceof libCommand.CommandTree) {
targetCommand = targetCommand.get(commandPath.shift());
}
// The remaining commands require connecting to the master server.
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let tlsCa = null;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath);
}
let controlConnector = new ControlConnector(
controlConfig.get("control.master_url"),
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.master_token")
);
let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins);
try {
await controlConnector.connect();
} catch (err) {
if (err instanceof libErrors.AuthenticationFailed) {
throw new libErrors.StartupError(err.message);
}
throw err;
}
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
try {
await targetCommand.run(args, control);
} catch (err) {
control.keepOpen = false;
if (err instanceof libErrors.CommandError) {
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof libErrors.RequestError) {
logger.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else {
throw err;
}
} finally {
if (!control.keepOpen) {
await control.shutdown();
}
}
}
module.exports = {
Control,
};
if (module === require.main) {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
if (!(err instanceof libErrors.StartupError)) {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
logger.error(`
+---------------------------------+
| Unable to to start clusterioctl |
+---------------------------------+
${err.stack}`
);
}
process.exitCode = 1;
});
}
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
/**
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
import fs from "fs-extra";
import yargs from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
(global as any).lazySchemaCompilation = true;
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import * as commands from "./src/commands";
import BaseCtlPlugin from "./src/BaseCtlPlugin";
/**
* Connector for control connection to controller
* @private
*/
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
register() {
logger.verbose("Connector | registering control");
this.sendHandshake(
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
version,
)
)
);
}
}
/**
* Handles running the control
*
* Connects to the controller over WebSocket and sends commands to it.
* @static
*/
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Certificate authority used to validate TLS connections to the controller. */
tlsCa?: string;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
tlsCa: string | undefined,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = controlConfig;
this.tlsCa = tlsCa;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
}
async setLogSubscriptions({
all = false,
controller = false,
hostIds = [] as number[],
instanceIds = [] as number[],
maxLevel = undefined as keyof typeof levels | undefined,
}) {
await this.send(
new lib.LogSetSubscriptionsRequest(
all, controller, hostIds, instanceIds, maxLevel,
)
);
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
logger.log(event.info as any);
}
async handleDebugWsMessageEvent(event: lib.DebugWsMessageEvent) {
// eslint-disable-next-line no-console
console.log("WS", event.direction, event.content);
}
async shutdown() {
try {
await (this.connector as ControlConnector).disconnect();
} catch (err) {
if (!(err instanceof lib.SessionLost)) {
throw err;
}
}
}
}
async function loadPlugins(pluginList: Map<string, string>) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let ctlPlugins = new Map<string, BaseCtlPlugin>();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.ctlEntrypoint) {
continue;
}
let CtlPlugin = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
"CtlPlugin",
BaseCtlPlugin,
);
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
ctlPlugins.set(pluginInfo.name, ctlPlugin);
await ctlPlugin.init();
}
return ctlPlugins;
}
interface CtlArguments {
[index: string]: unknown;
$0: string,
_: (string | number)[],
logLevel: keyof typeof levels,
config: string,
pluginList: string,
}
async function startControl() {
yargs
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stderr",
default: "server",
choices: Object.keys(levels),
type: "string",
})
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-control.json",
defaultDescription: "auto",
type: "string",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
default: "plugin-list.json",
type: "string",
})
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(yargs.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
// eslint-disable-next-line node/no-sync
let args = yargs.parseSync() as CtlArguments;
// Log stream for the ctl session.
logger.add(
new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new lib.TerminalFormat(),
})
);
lib.handleUnhandledErrors();
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return;
}
logger.verbose("Loading Plugins");
let ctlPlugins = await loadPlugins(pluginList);
// Add all cluster management commands including ones from plugins
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
// Reparse after commands have been added with help and strict checking.
args = yargs
.help()
.strict()
.parse() as CtlArguments
;
logger.verbose(`Loading config from ${args.config}`);
let controlConfig;
try {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control");
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
controlConfig = new lib.ControlConfig("control");
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
if (args._.length === 0) {
yargs.showHelp();
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, args.config);
return;
}
// Determine which command is being executed.
let commandPath = [...args._] as string[];
let targetCommand: lib.CommandTree | lib.Command = rootCommands;
while (commandPath.length && targetCommand instanceof lib.CommandTree) {
targetCommand = targetCommand.get(commandPath.shift()!)!;
}
assert(targetCommand instanceof lib.Command);
// The remaining commands require connecting to the controller.
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let tlsCa: string | undefined;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
if (err instanceof lib.AuthenticationFailed) {
throw new lib.StartupError(err.message);
}
throw err;
}
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
try {
await targetCommand.run(args, control);
} catch (err) {
control.keepOpen = false;
if (err instanceof lib.CommandError) {
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof lib.RequestError) {
if (err.stack) {
logger.error(`Error sending request:\n${err.stack}`);
} else {
logger.error(`Error sending request: ${err.message}`);
}
process.exitCode = 1;
} else {
throw err;
}
} finally {
if (!control.keepOpen) {
await control.shutdown();
}
}
}
export function bootstrap() {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
if (err.errors) {
logger.fatal(JSON.stringify(err.errors, null, "\t"));
}
if (!(err instanceof lib.StartupError)) {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
logger.error(`
+---------------------------------+
| Unable to to start clusterioctl |
+---------------------------------+
${err.stack}`
);
}
process.exitCode = 1;
});
}
if (module === require.main) {
bootstrap();
}
+7
View File
@@ -0,0 +1,7 @@
import { bootstrap } from "./ctl";
export * from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
if (module === require.main) {
bootstrap();
}
+38 -30
View File
@@ -1,32 +1,40 @@
{
"name": "@clusterio/ctl",
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.13",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusterioctl": "./ctl.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.13",
"as-table": "^1.0.31",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
"name": "@clusterio/ctl",
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"scripts": {
"prepare": "tsc --build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusterioctl": "./dist/ctl.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"as-table": "^1.0.55",
"fs-extra": "^11.1.1",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.10.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
/**
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
export default class BaseCtlPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called to add commands to the command line interface.
*
* Invoked by clusterioctl to let plugins add commands. `rootCommand` is
* the top level command node which the plugin should add its own {@link
* CommandTree} to.
*
* @param rootCommand -
* Root of the clusterioctl command tree.
*/
async addCommands(rootCommand: CommandTree) { }
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
},
}
+4
View File
@@ -0,0 +1,4 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
@@ -1,28 +1,28 @@
# Clusterio Slave
# Clusterio Host
Node hosting Factorio servers in a Clusterio cluster.
Clusterio slaves connect to the master server and waits for commands from the master server to start up and stop instances.
A cluster can have any number of slaves in it located on different computers, and each slave can host any number of instances each of which is a Factorio server that talks with the rest of the cluster.
Clusterio hosts connect to the controller and waits for commands from the controller to start up and stop instances.
A cluster can have any number of hosts in it located on different computers, and each host can host any number of instances each of which is a Factorio server that talks with the rest of the cluster.
## Usage
npx clusterioslave <command>
npx clusteriohost <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the slave.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the host.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the slave.
Defaults to `config-slave.json` and will be created if it does not exist.
* `--config <file>` JSON file to use for storing configuration for the host.
Defaults to `config-host.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the slave.
Configure plugins available to be loaded by the host.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
@@ -34,9 +34,9 @@ A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise i
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioslave plugin add @clusterio/plugin-subspace_storage
npx clusteriohost plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -47,10 +47,10 @@ Removing and unistalling a plugin is usually not neccessary as the functions pro
For example, uninstalling the Subspace Storage plugin:
npx clusterioslave plugin remove subspace_storage
npx clusteriohost plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -60,8 +60,8 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the slave configuration offline.
This should only be used when the slave is stopped, otherwise the config read might be out of date and config changes will be overwritten when the slave shuts down.
Manage the host configuration offline.
This should only be used when the host is stopped, otherwise the config read might be out of date and config changes will be overwritten when the host shuts down.
#### `config set <config-entry> [value]`
@@ -85,7 +85,7 @@ Lists up all configuration entries with their currently configured values.
### `run`
Runs the slave.
Runs the host.
## See Also
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
/**
* Clusterio host
*
* Connects to the controller and hosts Factorio servers that can
* communicate with the cluster. It is remotely controlled by {@link
* module:controller/controller}.
*
* @module host/host
* @author Danielv123, Hornwitser
* @example
* npx clusteriohost run
*/
import fs from "fs-extra";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
import { version } from "./package.json";
import winston from "winston";
import "winston-daily-rotate-file";
// internal libraries
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host";
let host: Host | undefined;
void new lib.Gauge(
"clusterio_host_pending_requests",
"Count of pending link requests currently waiting in memory on the host.",
{
labels: ["host_id"],
callback: (gauge) => {
if (!host) {
return;
}
let count = host.pendingRequestCount;
for (const connection of host.instanceConnections.values()) {
count += connection.pendingRequestCount;
count += connection.instance.pendingRequestCount;
}
gauge.labels(String(host.config.get("host.id"))).set(count);
},
}
);
export class HostConnector extends lib.WebSocketClientConnector {
constructor(
public hostConfig: lib.HostConfig,
tlsCa: string | undefined,
public pluginInfos: lib.PluginNodeEnvInfo[]
) {
super(
hostConfig.get("host.controller_url"),
hostConfig.get("host.max_reconnect_delay"),
tlsCa
);
}
register() {
logger.info("Connector | registering host");
let plugins: Record<string, string> = {};
for (let pluginInfo of this.pluginInfos) {
plugins[pluginInfo.name] = pluginInfo.version;
}
this.sendHandshake(
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.hostConfig.get("host.controller_token"),
version,
this.hostConfig.get("host.id"),
plugins,
)
)
);
}
}
async function startHost() {
// argument parsing
// eslint-disable-next-line node/no-sync
const args = yargs
.scriptName("host")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stdout",
default: "info",
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: "host config file to use",
default: "config-host.json",
type: "string",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
default: "plugin-list.json",
type: "string",
})
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart this host on failure",
});
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.parseSync()
;
logger.add(new winston.transports.DailyRotateFile({
format: winston.format.json(),
filename: "host-%DATE%.log",
utc: true,
dirname: path.join(args.logDirectory, "host"),
}));
if (args.logLevel !== "none") {
logger.add(new ConsoleTransport({
level: args.logLevel,
format: new lib.TerminalFormat(),
filter: (info: any) => info.instance_id === undefined,
}));
}
lib.handleUnhandledErrors();
let command = args._[0];
if (command === "run") {
logger.info(`Starting Clusterio host ${version}`);
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return;
}
logger.info("Loading Plugin info");
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
logger.info(`Loading config from ${args.config}`);
let hostConfig;
try {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host");
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
hostConfig = new lib.HostConfig("host");
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, args.config);
return;
}
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
await fs.ensureDir("modules");
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
process.title = "clusteriohost";
// make sure we have the controller access token
if (hostConfig.get("host.controller_token") === "enter token here") {
logger.fatal("ERROR invalid config!");
logger.fatal(
"Controller requires an access token for socket operations. As clusterio\n"+
"hosts depends upon this, please set your token using the command npx\n"+
"clusteriohost config set host.controller_token <token>. You can generate an\n"+
"auth token using npx clusterioctl generate-host-token."
);
process.exitCode = 1;
return;
}
// make sure url ends with /
if (!hostConfig.get("host.controller_url").endsWith("/")) {
logger.fatal("ERROR invalid config!");
logger.fatal("host.controller_url must end with '/'");
process.exitCode = 1;
return;
}
let tlsCa: string | undefined;
let tlsCaPath = hostConfig.get("host.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
host = new Host(
hostConnector,
args.config,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
...await Host.bootstrap(hostConfig)
);
// Handle interrupts
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
});
try {
await host.loadPlugins();
} catch (err) {
await host.shutdown();
throw err;
}
hostConnector.once("connect", () => {
logger.add(new lib.LinkTransport({ link: host! }));
});
await hostConnector.connect();
logger.info("Started host");
}
export function bootstrap() {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startHost().catch(err => {
if (err instanceof lib.AuthenticationFailed) {
logger.fatal(err.message);
} else if (err instanceof lib.StartupError) {
logger.fatal(`
+----------------------------------+
| Unable to to start clusteriohost |
+----------------------------------+
${err.stack}`
);
} else if (err instanceof lib.PluginError) {
logger.fatal(`
${err.pluginName} plugin threw an unexpected error
during startup, please report it to the plugin author.
------------------------------------------------------
${err.original.stack}`
);
} else {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured while starting host, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
if (err instanceof lib.AuthenticationFailed) {
process.exitCode = 8;
} else {
process.exitCode = 1;
}
});
}
if (module === require.main) {
bootstrap();
}
+10
View File
@@ -0,0 +1,10 @@
import { bootstrap } from "./host";
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
export { default as Host, HostRouter } from "./src/Host";
export { default as Instance } from "./src/Instance";
export { default as InstanceConnection } from "./src/InstanceConnection";
if (module === require.main) {
bootstrap();
}
@@ -1,30 +1,3 @@
local item_types = {
-- Item categories
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"deconstruction-item",
"copy-paste-tool",
"upgrade-item",
"module",
"rail-planner",
"tool",
"armor",
"mining-tool",
"repair-tool",
-- Fluids
"fluid",
}
local function is_array(t)
local count = 0
for k in pairs(t) do
@@ -92,13 +65,9 @@ local function table_to_json(data)
end
end
local function send_json(channel, data)
local clusterio_api = {}
function clusterio_api.send_json(channel, data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
for _, group in ipairs(item_types) do
for _, item in pairs(data.raw[group]) do
send_json("item_export", item)
end
end
return clusterio_api
@@ -5,4 +5,7 @@ end
script.on_init(function()
send_json("mod_list", game.active_mods)
for name, mod_setting in pairs(game.mod_setting_prototypes) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
end)
@@ -0,0 +1,8 @@
local clusterio_api = require("clusterio_api")
log("Exporting prototypes")
for group, prototypes in pairs(data.raw) do
for name, prototype in pairs(prototypes) do
clusterio_api.send_json("prototype_export", prototype)
end
end
File renamed without changes.
@@ -0,0 +1,8 @@
local clusterio_api = require("clusterio_api")
log("Exporting settings")
for group, prototypes in pairs(data.raw) do
for name, prototype in pairs(prototypes) do
clusterio_api.send_json("settings_export", prototype)
end
end
@@ -1,16 +1,18 @@
local api = require('modules/clusterio/api')
local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = function()
local function check_patch()
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
local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = check_patch
impl.events[api.events.on_server_startup] = function()
if not global.clusterio then
global.clusterio = {
@@ -47,6 +49,7 @@ end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
check_patch()
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@clusterio/host",
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"scripts": {
"prepare": "tsc --build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriohost": "./dist/host.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.1.1",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.3",
"semver": "^7.5.4",
"set-blocking": "^2.0.0",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
}
}
+127
View File
@@ -0,0 +1,127 @@
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Host from "./Host";
/**
* Base class for host plugins
*
* Host plugins are subclasses of this class which get instantiated by
* the host when it starts up with the plugin enabled in the config. To be
* discovered the class must be exported under the name `HostPlugin` in the
* module specified by the `hostEntrypoint` in the plugin's `plugin` export.
*/
export default class BaseHostPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Host the plugin started for
*/
public host: Host,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a host config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on this host.
*
* @param field - Name of the field that changed.
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called when the host is shutting down
*/
async onShutdown() { }
/**
* Called when an event on the controller connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the controller has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the host and the
* controller. Plugins should respond to this event by throtteling messages
* it is sending to the controller to an absolute minimum.
*
* Messages sent over a dropped controller connection will get queued up in
* memory on the host and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when the connection that had previously dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the controller has been closed. This
* typically means the controller has shut down. Plugins should not
* send any messages that goes to or via the controller after the
* connection has been closed and before a new one is established.
*
* @param event - one of connect, drop, resume and close
*/
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when the controller is preparing to disconnect from the host
*
* Invoked when the controller has requested the disconnection from
* the host. This typically happens when it is in the process of
* shutting down.
*
* Plugins must stop sending messages to the controller, or are forwarded
* via the controller after the prepare disconnect has been handled.
*
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareControllerDisconnect(connection: Host) { }
}
+256
View File
@@ -0,0 +1,256 @@
import type {
CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Instance from "./Instance";
import type Host from "./Host";
/**
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the host when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's `plugin` export.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
*/
export default class BaseInstancePlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
private _pendingRconMessages: {
resolve: (result: string) => void,
reject: (err: Error) => void,
message: string,
expectEmpty: boolean,
}[] = [];
private _sendingRconMessages = false;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
/**
* Instance the plugin started for
*/
public instance: Instance,
/**
* Host running the instance
*
* With the exepction of accessing the host's config you should
* avoid ineracting with the host object directly.
*/
public host: Host,
) {
this.logger = instance.logger.child({ plugin: this.info.name }) as unknown as Logger;
this._pendingRconMessages = [];
this._sendingRconMessages = false;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed.
*
* @param field - Name of the field that changed.
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called after the Factorio server is started
*/
async onStart() { }
/**
* Called before the Factorio server is stopped
*
* This will not be called if for example the Factorio server crashes or
* is killed.
*/
async onStop() { }
/**
* Called when the instance exits
*
* Invoked when the instance is shut down. This may occur after init
* has been called if an error occurs during startup. Note that if
* the plugin's init() throws this method will still be invoked.
*/
onExit() { }
/**
* Called when the Factorio outputs a line
*
* Called when the Factorio server outputs a line on either stdout or
* stderr. The output is a parsed form of the line and contains the
* following properties:
*
* @param parsed - parsed server output.
* @param line - raw line of server output.
*/
async onOutput(parsed: ParsedFactorioOutput, line: string) { }
/**
* Called when an event on the controller connection happens
*
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the controller has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the host and the
* controller. Plugins should respond to this event by throtteling messages
* it is sending to the controller to an absolute minimum.
*
* Messages sent over a dropped controller connection will get queued up in
* memory on the host and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when the connection that had previously dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the controller has been closed. This
* typically means the controller has shut down. Plugins should not
* send any messages that goes to or via the controller after the
* connection has been closed and before a new one is established.
*
* @param event - one of connect, drop, resume and close
*/
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when the controller is preparing to disconnect from the host
*
* Invoked when the controller has requested the disconnection from
* the host. This typically happens when it is in the process of
* shutting down.
*
* Plugins must stop sending messages to the controller, or are forwarded
* via the controller after the prepare disconnect has been handled.
*
* @param connection -
* The connection to the host preparing to disconnect.
*/
async onPrepareControllerDisconnect(connection: Instance) { }
/**
* Called when a player joins or leaves the game
*
* Invoked when a player either joins or leaves the instance.
*
* @param event - Information about the event.
*/
async onPlayerEvent(event: PlayerEvent) { }
/**
* 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 message - message to send to server over RCON.
* @param expectEmpty -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns response from server.
*/
async sendRcon(message: string, expectEmpty=false): Promise<string> {
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 message - message to send to server over RCON.
* @param [expectEmpty=false] -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns response from server.
*/
async sendOrderedRcon(message: string, expectEmpty=false) {
let promise = new Promise<string>((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: any) {
task.reject(err);
}
}
this._sendingRconMessages = false;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
import * as lib from "@clusterio/lib";
import type Host from "./Host";
import type Instance from "./Instance";
export default class InstanceConnection extends lib.Link {
plugins = new Map<string, string>();
constructor(
connector: lib.VirtualConnector,
public host: Host,
public instance: Instance,
) {
super(connector);
this.router = this.host.router;
this.handle(lib.InstanceInitialisedEvent, this.handleInstanceInitialisedEvent.bind(this));
this.snoopEvent(lib.InstanceStatusChangedEvent, this.snoopInstanceStatusChangedEvent.bind(this));
}
async handleInstanceInitialisedEvent(event: lib.InstanceInitialisedEvent) {
this.plugins = new Map(Object.entries(event.plugins));
}
async snoopInstanceStatusChangedEvent(event: lib.InstanceStatusChangedEvent) {
if (this.instance.status === "stopped") {
this.host.instanceConnections.delete(this.instance.id);
}
}
}
@@ -1,12 +1,42 @@
// Export of item icons and locale
"use strict";
const fs = require("fs-extra");
const path = require("path");
const Jimp = require("jimp");
const JSZip = require("jszip");
import fs from "fs-extra";
import path from "path";
import Jimp from "jimp";
import JSZip from "jszip";
const libIni = require("../ini");
const libBuildMod = require("../build_mod");
import * as lib from "@clusterio/lib";
import * as libBuildMod from "@clusterio/lib/dist/build_mod";
import type { FactorioServer } from "./server";
interface Prototype {
[index: string]: unknown,
type: string,
name: string,
}
interface SimpleIconSpecification {
icon: string;
icon_size: number;
icon_mipmaps?: number;
}
type Color = { r: number, g: number, b: number, a: number };
interface IconLayer {
icon: string;
icon_size: number;
tint?: Color,
shift?: [number, number],
scale?: number,
icon_mipmaps?: number,
}
interface LayeredIconSpecification {
icons: IconLayer[];
icon_size: number;
icon_mipmaps?: number;
}
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
type ItemPrototype = Prototype & IconSpecification;
type Prototypes = Record<string, Record<string, Prototype>>
/**
* Generate the export mod needed for exportData
@@ -15,11 +45,10 @@ const libBuildMod = require("../build_mod");
* the server given, with dependencies generated for all the mods present in
* the server's mods folder.
*
* @param {module:lib/factorio.FactorioServer} server -
* @param server -
* The server to generate the export mod for.
* @memberof module:lib/factorio
*/
async function generateExportMod(server) {
async function generateExportMod(server: FactorioServer) {
let dependencies = [];
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
@@ -33,8 +62,7 @@ async function generateExportMod(server) {
clean: false,
build: true,
pack: true,
// XXX fix path when moving lib/factorio to slave
sourceDir: path.join(__dirname, "..", "..", "slave", "lua", "export"),
sourceDir: path.join(__dirname, "..", "..", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version.replace(/\.\d+$/, ""),
@@ -43,7 +71,7 @@ async function generateExportMod(server) {
}
let zipCache = new Map();
async function loadZip(server, modVersions, mod) {
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
let modVersion = modVersions.get(mod);
if (!modVersion) {
throw new Error(`Got path for unknown mod ${mod}`);
@@ -56,22 +84,19 @@ async function loadZip(server, modVersions, mod) {
zipCache.set(zipPath, zip);
}
return zip.folder(`${mod}_${modVersion}`);
return zip.folder(lib.findRoot(zip));
}
/**
* Load the given Factorio file path into a Buffer
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to load the file from.
* @param {Map<string, string>} modVersions - Mapping of mod to version used.
* @param {string} modPath - Factorio style path to the file to load.
* @returns {?Buffer} The content of the file or null if not found.
* @memberof module:lib/factorio
* @private
* @inner
* @param server - The server to load the file from.
* @param modVersions - Mapping of mod to version used.
* @param modPath - Factorio style path to the file to load.
* @returns The content of the file or null if not found.
* @internal
*/
async function loadFile(server, modVersions, modPath) {
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
throw new Error(`Bad mod path ${match}`);
@@ -82,7 +107,7 @@ async function loadFile(server, modVersions, modPath) {
if (["core", "base"].includes(mod)) {
try {
return await fs.readFile(server.dataPath(mod, filePath));
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
return null;
}
@@ -93,7 +118,7 @@ async function loadFile(server, modVersions, modPath) {
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
return null;
}
@@ -108,7 +133,15 @@ async function loadFile(server, modVersions, modPath) {
return await file.async("nodebuffer");
}
async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, iconCache) {
type IconCache = Map<string, Jimp | null>;
async function loadIcon(
server: FactorioServer,
modVersions: Map<string, string>,
iconPath: string,
iconSize: number,
iconMipmaps: number,
iconCache: IconCache,
) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
@@ -125,7 +158,13 @@ async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, ic
return icon;
}
async function loadSimpleIcon(server, modVersions, item, size, iconCache) {
async function loadSimpleIcon(
server: FactorioServer,
modVersions: Map<string, string>,
item: SimpleIconSpecification,
size: number,
iconCache: IconCache,
) {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache);
if (icon) {
let iconScale = size / item.icon_size;
@@ -137,7 +176,13 @@ async function loadSimpleIcon(server, modVersions, item, size, iconCache) {
return icon;
}
async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
async function loadLayeredIcon(
server: FactorioServer,
modVersions: Map<string, string>,
item: LayeredIconSpecification,
size: number,
iconCache: IconCache,
) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
let icon = await Jimp.create(size, size);
@@ -157,7 +202,7 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
iconLayer = iconLayer.clone();
let tint;
let tint: Color;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
@@ -189,7 +234,7 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = iconLayer.bitmap.data;
let sdata = iconLayer!.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
@@ -207,24 +252,57 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
return icon;
}
const itemTypes = new Set([
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"copy-paste-tool",
"deconstruction-item",
"upgrade-item",
"module",
"rail-planner",
"spidertron-remote",
"tool",
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
// TODO consider adding virtual signals and recipes
]);
function filterItems(prototypes: Prototypes): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes)) as ItemPrototype[]
;
}
/**
* Export item icons and data
*
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param {module:lib/factorio.FactorioServer} server -
* @param server -
* The server to generate the export mod for.
* @param {Map<string, string>} modVersions -
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param {Array<Object>} items - Array of item prototypes.
* @return {{itemSheet: Jimp, itemData: Map<string,Object>}}
* Item spritesheet and metadata.
* @memberof module:lib/factorio
* @private
* @inner
* @param items - Array of item prototypes.
* @return Item spritesheet and metadata.
* @internal
*/
async function exportItems(server, modVersions, items) {
async function exportItems(server: FactorioServer, modVersions: Map<string, string>, items: ItemPrototype[]) {
// Size to render icons at
let size = 32;
@@ -237,19 +315,19 @@ async function exportItems(server, modVersions, items) {
let itemData = new Map();
let pos = 0;
let iconCache = new Map();
let iconCache: IconCache = new Map();
let simpleIcons = new Map();
for (let item of items) {
let icon;
let iconPos;
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(server, modVersions, item, size, iconCache);
icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache);
iconPos = pos;
} else {
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadSimpleIcon(server, modVersions, item, size, iconCache);
icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
@@ -284,21 +362,22 @@ async function exportItems(server, modVersions, items) {
* Parses and merges all the locales for the all the mods given through
* `modVersions` and `modOrder`.
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to export the locale from.
* @param {Map<string, string>} modVersions -
* Mapping of mod name to version to export locale from.
* @param {Array<string>} modOrder - Load order of the mods.
* @param {string} languageCode - Language to export locale for.
* @returns {Map<string, string>} merged locale information
* @memberof module:lib/factorio
* @private
* @inner
* @param server - The server to export the locale from.
* @param modVersions - Mapping of mod name to version to export locale from.
* @param modOrder - Load order of the mods.
* @param languageCode - Language to export locale for.
* @returns merged locale information
* @internal
*/
async function exportLocale(server, modVersions, modOrder, languageCode) {
let mergedLocales = new Map();
async function exportLocale(
server: FactorioServer,
modVersions: Map<string, string>,
modOrder: string[],
languageCode: string
) {
let mergedLocales = new Map<string, string>();
function mergeLocale(locale) {
function mergeLocale(locale: Record<string, string | Record<string, string>>) {
for (let [category, entries] of Object.entries(locale)) {
if (typeof entries === "string") {
mergedLocales.set(category, entries);
@@ -312,7 +391,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
mergeLocale(lib.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
for (let mod of modOrder) {
if (["core", "base", "export"].includes(mod)) {
@@ -322,7 +401,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
continue;
}
@@ -330,7 +409,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(libIni.parse(content.toString("utf8")));
mergeLocale(lib.parse(content.toString("utf8")));
}
}
@@ -340,19 +419,35 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
/**
* Export the locale and item icons for the given factorio server
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to export the data from.
* @returns {JSZip} zip file with exported data.
* @memberof module:lib/factorio
* @param server - The server to export the data from.
* @returns zip file with exported data.
*/
async function exportData(server) {
export async function exportData(server: FactorioServer) {
await generateExportMod(server);
let items = [];
let settings: Prototypes = {};
let prototypes: Prototypes = {};
let modVersions = new Map();
let modOrder = [];
let modOrder: string[] = [];
server.on("ipc-item_export", data => items.push(data));
function add(obj: Prototypes, prototype: Prototype) {
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
obj[prototype.type] = {};
}
obj[prototype.type][prototype.name] = prototype;
}
server.on("ipc-prototype_export", data => add(prototypes, data));
server.on("ipc-settings_export", data => add(settings, data));
server.on("ipc-mod_setting_mod", ({ name, mod }) => {
for (let type of Object.values(settings)) {
if (Object.prototype.hasOwnProperty.call(type, name)) {
type[name].mod = mod;
return;
}
}
server._logger.error(`Unable to find ${name} in settings prototypes`);
});
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
@@ -370,8 +465,8 @@ async function exportData(server) {
await fs.unlink(server.writePath("mods", "export_0.0.0.zip"));
}
if (!items.length) {
throw new Error("No items got exported");
if (!Object.keys(prototypes).length) {
throw new Error("No prototypes got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
@@ -396,13 +491,15 @@ async function exportData(server) {
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
zipCache.clear();
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
@@ -410,9 +507,5 @@ async function exportData(server) {
}
module.exports = {
exportData,
// For testing only
_exportLocale: exportLocale,
};
// For testing only
export const _exportLocale = exportLocale;
+435
View File
@@ -0,0 +1,435 @@
// Library for patching Factorio saves with scenario code.
import events from "events";
import fs from "fs-extra";
import JSZip from "jszip";
import path from "path";
import semver from "semver";
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import BaseInstancePlugin from "./BaseInstancePlugin";
/**
* Describes a module that can be patched into a save
*/
export class SaveModule {
constructor(
/** Module */
public info: lib.ModuleInfo,
/** Files and their content that will be patched into the save */
public files = new Map<string, Buffer>(),
) { }
static moduleFilePath(filePath: string, moduleName: string) {
// Map locale files to the save's locale folder
if (filePath.startsWith("locale/")) {
const slashes = filePath.match(/\//g)!.length;
if (slashes > 1) {
const secondSlash = filePath.indexOf("/", "locale/".length);
return `${filePath.slice(0, secondSlash)}/${moduleName}-${filePath.slice(secondSlash + 1)}`;
}
const period = `${filePath}.`.indexOf(".");
return `${filePath.slice(0, period)}/${moduleName}${filePath.slice(period)}`;
}
// Map all other files into modules/name in the save.
return path.posix.join("modules", moduleName, filePath);
}
async loadFiles(moduleDirectory: string) {
let dirs: [string, string][] = [[moduleDirectory, ""]];
while (dirs.length) {
let [dir, relativeDir] = dirs.pop()!;
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
let fsPath = path.join(dir, entry.name);
let relativePath = path.posix.join(relativeDir, entry.name);
if (entry.isFile()) {
let savePath = SaveModule.moduleFilePath(relativePath, this.info.name);
this.files.set(savePath, await fs.readFile(fsPath));
if (relativePath === "module_exports.lua") {
this.files.set(
`modules/${this.info.name}.lua`,
Buffer.from(`return require("modules/${this.info.name}/module_exports")`, "utf-8")
);
}
} else if (entry.isDirectory()) {
dirs.push([fsPath, relativePath]);
}
}
}
}
static jsonSchema = Type.Object({
...lib.ModuleInfo.jsonSchema.properties,
"files": Type.Array(Type.String()),
});
toJSON() {
return {
...this.info.toJSON(),
files: [...this.files.keys()],
};
}
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, root: JSZip) {
const module = new this(lib.ModuleInfo.fromJSON(json));
module.files = new Map(
await Promise.all(json.files.map(async f => [f, await root.file(f)!.async("nodebuffer")] as const))
);
return module;
}
static async fromPlugin(plugin: BaseInstancePlugin) {
let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json"));
let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module");
if (!await fs.pathExists(moduleDirectory)) {
return null;
}
let moduleJsonPath = path.join(moduleDirectory, "module.json");
let moduleJson;
try {
moduleJson = {
name: plugin.info.name,
version: plugin.info.version,
dependencies: { "clusterio": "*" },
...JSON.parse(await fs.readFile(moduleJsonPath, "utf8")),
};
} catch (err: any) {
throw new Error(`Loading module/module.json in plugin ${plugin.info.name} failed: ${err.message}`);
}
if (!lib.ModuleInfo.validate(moduleJson)) {
throw new Error(
`module/module.json in plugin ${plugin.info.name} failed validation:\n` +
`${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`
);
}
let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson));
await module.loadFiles(moduleDirectory);
return module;
}
static async fromDirectory(moduleDirectory: string) {
let name = path.basename(moduleDirectory);
let moduleJsonPath = path.join(moduleDirectory, "module.json");
let moduleJson;
try {
moduleJson = JSON.parse(await fs.readFile(moduleJsonPath, "utf8"));
} catch (err: any) {
throw new Error(`Loading ${name}/module.json failed: ${err.message}`);
}
if (!lib.ModuleInfo.validate(moduleJson)) {
throw new Error(
`${name}/module.json failed validation:\n` +
`${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`
);
}
if (moduleJson.name !== name) {
throw new Error(`Expected name of module ${moduleJson.name} to match the directory name ${name}`);
}
let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson));
await module.loadFiles(moduleDirectory);
return module;
}
}
export class PatchInfo {
static currentVersion = 1;
constructor(
public patchNumber: number,
public scenario: lib.ModuleInfo,
public modules: SaveModule[],
public version = PatchInfo.currentVersion,
) { }
static jsonSchema = Type.Object({
"version": Type.Number(),
"patch_number": Type.Number(),
"scenario": lib.ModuleInfo.jsonSchema,
"modules": Type.Array(SaveModule.jsonSchema),
});
toJSON() {
return {
version: PatchInfo.currentVersion,
patch_number: this.patchNumber,
scenario: this.scenario.toJSON(),
modules: this.modules.map(m => m.toJSON()),
};
}
static async fromSave(json: Static<typeof PatchInfo.jsonSchema>, root: JSZip) {
if (json.version === undefined) {
interface ScenarioInfoV0 {
name: string;
modules: string[];
}
interface PatchInfoV0 {
patch_number: number;
scenario: ScenarioInfoV0;
modules: { name: string, files: { path: string, load: boolean, require: boolean }[], }[];
}
const info = json as unknown as PatchInfoV0;
return new this(
info.patch_number,
new lib.ModuleInfo(info.scenario.name, "0.0.0", info.scenario.modules),
[],
0,
);
}
return new this(
json.patch_number,
lib.ModuleInfo.fromJSON(json.scenario),
await Promise.all(json.modules.map(m => SaveModule.fromSave(m, root))),
);
}
}
const knownScenarios: Record<string, lib.ModuleInfo> = {
// First seen in 0.17.63
"4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", ["freeplay", "silo-script"]),
};
/**
* Generates control.lua code for loading the Clusterio modules
*
* @param patchInfo - The patch info files's json content
* @returns Generated control.lua code.
* @internal
*/
function generateLoader(patchInfo: PatchInfo) {
let lines = [
"-- Auto generated scenario module loader created by Clusterio",
"-- Modifications to this file will be lost when loaded in Clusterio",
`clusterio_patch_number = ${patchInfo.patchNumber}`,
"",
'local event_handler = require("event_handler")',
"",
"-- Scenario modules",
];
for (let requirePath of patchInfo.scenario.load) {
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
}
for (let requirePath of patchInfo.scenario.require) {
lines.push(`require("${requirePath}")`);
}
lines.push(...[
"",
"-- Clusterio modules",
]);
for (let module of patchInfo.modules) {
for (let file of module.info.load) {
let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`;
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
}
for (let file of module.info.require) {
let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`;
lines.push(`require("${requirePath}")`);
}
}
// End last line with a newline
lines.push("");
return lines.join("\n");
}
/**
* Reorders modules to satisfy their dependencies
*
* Looks through and reorders the array of module definitions in order to
* satisfy the property that dependencies are earlier in the array than
* their dependents. Throws an error if this is not possible.
*
* @param modules - Array of modules to reorder
* @internal
*/
function reorderDependencies(modules: SaveModule[]) {
let index = 0;
let present = new Map<string, string>();
let hold = new Map<string, [SaveModule]>();
reorder: while (index < modules.length) {
let module = modules[index];
if (semver.valid(module.info.version) === null) {
throw new Error(`Invalid version '${module.info.version}' for module ${module.info.name}`);
}
for (let [dependency, requirement] of module.info.dependencies) {
if (semver.validRange(requirement) === null) {
throw new Error(
`Invalid version range '${requirement}' for dependency ${dependency} on module ${module.info.name}`
);
}
if (present.has(dependency)) {
if (!semver.satisfies(present.get(dependency)!, requirement)) {
throw new Error(`Module ${module.info.name} requires ${dependency} ${requirement}`);
}
// We have an unmet dependency, take it out and continue
} else {
if (hold.has(dependency)) {
hold.get(dependency)!.push(module);
} else {
hold.set(dependency, [module]);
}
modules.splice(index, 1);
continue reorder;
}
}
// No unmet dependencies, record and continue
present.set(module.info.name, module.info.version);
index += 1;
if (hold.has(module.info.name)) {
modules.splice(index, 0, ...hold.get(module.info.name)!);
hold.delete(module.info.name);
}
}
if (!hold.size) {
return;
}
// There are three reasons for a module to end up being held: The module depends
// on a module that is missing, the module is part of a dependency loop, or the
// the module depends on a module that satisfy any of these conditions.
let remaining = new Map<string, SaveModule>();
for (let heldModules of hold.values()) {
for (let module of heldModules) {
remaining.set(module.info.name, module);
}
}
// Start with a random module from the remaining modules
for (let module of remaining.values()) {
let cycle: string[] = [];
while (true) {
// Find an unmet dependency
let dependency = [...module.info.dependencies.keys()].find(name => !present.has(name))!;
if (!remaining.has(dependency)) {
// There's no module being held up by this dependency, the
// dependency is missing.
throw new Error(`Missing dependency ${dependency} for module ${module.info.name}`);
}
if (cycle.includes(module.info.name)) {
cycle.push(module.info.name);
cycle.splice(0, cycle.indexOf(module.info.name));
throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`);
}
cycle.push(module.info.name);
module = remaining.get(dependency)!;
}
}
}
/**
* Patch a save with the given modules
*
* Adds the modules given by the modules parameter to the save located
* at savePath and rewrites the control.lua in the save to load the
* modules that were added. Will also remove any previous module
* located in the save.
*
* @param savePath - Path to the Factorio save to patch.
* @param modules - Description of the modules to patch.
*/
export async function patch(savePath: string, modules: SaveModule[]) {
let zip = await JSZip.loadAsync(await fs.readFile(savePath));
let root = zip.folder(lib.findRoot(zip))!;
let patchInfoFile = root.file("clusterio.json");
let patchInfo: PatchInfo;
if (patchInfoFile !== null) {
let content = await patchInfoFile.async("string");
patchInfo = await PatchInfo.fromSave(JSON.parse(content), root);
if (patchInfo.version > PatchInfo.currentVersion) {
throw new Error(
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
`version of Clusterio can load (${PatchInfo.currentVersion})`
);
}
// No info file present, try to detect if it's a known compatible scenario.
} else {
let controlFile = root.file("control.lua");
if (!controlFile) {
throw new Error("Unable to patch save, missing control.lua file.");
}
let controlStream = controlFile.nodeStream("nodebuffer");
let controlHash = await lib.hashStream(controlStream);
if (controlHash in knownScenarios) {
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
} else {
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
}
}
// Increment patch number
patchInfo.patchNumber = patchInfo.patchNumber + 1;
// Remove any existing modules from the save
if (patchInfo.version === 0) {
for (let file of root.file(/^modules\//)) {
zip.remove(file.name);
}
} else {
for (let module of patchInfo.modules) {
for (let file of module.files.keys()) {
zip.remove(root.file(file)!.name);
}
}
patchInfo.modules = [];
}
reorderDependencies(modules);
// Add the modules to the save.
for (let module of modules) {
for (let [relativePath, contents] of module.files) {
root.file(relativePath, contents);
}
patchInfo.modules.push(module);
}
// Add loading code and patch info
root.file("control.lua", generateLoader(patchInfo));
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
// Write back the save
let tempSavePath = savePath.replace(/(\.zip)?$/, ".tmp.zip");
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let fd = await fs.open(tempSavePath, "w");
try {
let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false }));
await events.once(pipe, "finish");
await fs.fsync(fd);
} finally {
await fs.close(fd);
}
await fs.rename(tempSavePath, savePath);
}
// For testing only
export const _generateLoader = generateLoader;
export const _reorderDependencies = reorderDependencies;
@@ -1,35 +1,27 @@
// Factorio Server interface
"use strict";
const fs = require("fs-extra");
const child_process = require("child_process");
const path = require("path");
const events = require("events");
const util = require("util");
const crypto = require("crypto");
const rconClient = require("rcon-client");
import fs from "fs-extra";
import child_process from "child_process";
import path from "path";
import events from "events";
import util from "util";
import crypto from "crypto";
import { Rcon } from "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");
import * as lib from "@clusterio/lib";
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
*
* @param {string} changelogPath - Path to changelog.txt.
* @memberof module:lib/factorio
* @private
* @inner
* @param changelogPath - Path to changelog.txt.
* @internal
*/
async function getVersion(changelogPath) {
async function getVersion(changelogPath: string) {
let changelog;
try {
changelog = await fs.readFile(changelogPath, "utf-8");
} catch (err) {
} catch (err: any) {
if (err.code === "ENOENT") {
return null;
}
@@ -52,14 +44,12 @@ async function getVersion(changelogPath) {
/**
* Comparison function for sorting Factorio version strings
*
* @param {string} a - Version to compare.
* @param {string} b - Version to compare.
* @returns {number} 1 if a < b, 0 if a = b and -1 if a > b.
* @memberof module:lib/factorio
* @private
* @inner
* @param a - Version to compare.
* @param b - Version to compare.
* @returns 1 if a < b, 0 if a = b and -1 if a > b.
* @internal
*/
function versionOrder(a, b) {
function versionOrder(a: string, b: string) {
let aParts = a.split(".").map(s => Number.parseInt(s, 10));
let bParts = b.split(".").map(s => Number.parseInt(s, 10));
@@ -82,16 +72,14 @@ function versionOrder(a, b) {
* Searches the given factorio dir for an installation of Factorio of
* the target version.
*
* @param {string} factorioDir - Path to Factorio installation dir(s).
* @param {string} targetVersion -
* @param factorioDir - Path to Factorio installation dir(s).
* @param targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
* @returns {Array} Array with path to data dir and version found.
* @memberof module:lib/factorio
* @private
* @inner
* @returns Array with path to data dir and version found.
* @internal
*/
async function findVersion(factorioDir, targetVersion) {
async function findVersion(factorioDir: string, targetVersion: string) {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
@@ -144,10 +132,8 @@ async function findVersion(factorioDir, targetVersion) {
* Returns a random port number in the Dynamic Ports range as defined by
* RFC 6335.
*
* @return {number} a number in the range 49152 to 65535.
* @memberof module:lib/factorio
* @private
* @inner
* @return a number in the range 49152 to 65535.
* @internal
*/
function randomDynamicPort() {
const start = 49152;
@@ -162,17 +148,15 @@ function randomDynamicPort() {
* Uses crypto.randomBytes to generate a secure alphanumeric password of
* the given length.
*
* @param {number} length - the length of the password to generate.
* @return {string} password of the given length
* @memberof module:lib/factorio
* @private
* @inner
* @param length - the length of the password to generate.
* @return password of the given length
* @internal
*/
async function generatePassword(length) {
function validChar(byte) {
async function generatePassword(length: number) {
function validChar(byte: number) {
const ranges = ["az", "AZ", "09"];
return ranges.some(range => (
range.codePointAt(0) <= byte && byte <= range.codePointAt(1)
range.codePointAt(0)! <= byte && byte <= range.codePointAt(1)!
));
}
let randomBytesAsync = util.promisify(crypto.randomBytes);
@@ -203,28 +187,22 @@ async function generatePassword(length) {
*
* TODO document output format.
*
* @param {string} line - A line of output not including the line terminator
* @param {string} source - Passed into the output structure as source
* @param line - A line of output not including the line terminator
* @param source - Passed into the output structure as source
*
* @returns {object} - An object with interpeted data.
* @memberof module:lib/factorio
* @private
* @inner
* @returns - An object with interpeted data.
* @internal
*/
function parseOutput(line, source) {
let output = {
source,
};
function parseOutput(line: string, source: "stdout" | "stderr"): lib.ParsedFactorioOutput {
// There are three broad categories of output from Factorio, the first kind
// starts with the seconds since the server started and has a format of
// " 0.704 message"
const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/;
let secMatch = secRegex.exec(line);
if (secMatch) {
output.format = "seconds";
output.time = secMatch[1];
let secContent = secMatch[2];
const format = "seconds";
const time = secMatch[1];
const secContent = secMatch[2];
// A seconds output has two different kinds of formats: The first is a
// log level and source location and has a format of
@@ -234,52 +212,79 @@ function parseOutput(line, source) {
const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/;
let secLogMatch = secLogRegex.exec(secContent);
if (secLogMatch) {
output.type = "log";
output.level = secLogMatch[1];
output.file = secLogMatch[2];
output.message = secLogMatch[5];
return {
source,
format,
time,
type: "log",
level: secLogMatch[1],
file: secLogMatch[2],
message: secLogMatch[5],
};
}
// The other possibility is that the content is a generic message
} else {
output.type = "generic";
output.message = secContent;
}
return {
source,
format,
time,
type: "generic",
message: secContent,
};
}
// The second category of output starts with a date stamp of the format
// "yyyy-mm-dd hh:mm:ss message"
} else {
const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/;
let dateMatch = dateRegex.exec(line);
if (dateMatch) {
output.format = "date";
output.time = dateMatch[1];
let dateContent = dateMatch[2];
const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/;
let dateMatch = dateRegex.exec(line);
if (dateMatch) {
const format = "date";
const time = dateMatch[1];
let dateContent = dateMatch[2];
// A date output has two general formats. The first is an action
// followed by a message and has a format of "[ACTION] message"
const dateActionRegex = /^\[(\w+)\] (.*)$/;
let dateActionMatch = dateActionRegex.exec(dateContent);
if (dateActionMatch) {
output.type = "action";
output.action = dateActionMatch[1];
output.message = dateActionMatch[2];
// The other format is a generic message
} else {
output.type = "generic";
output.message = dateContent;
}
// The last category of output is simply a generic message with no
// formating.
} else {
output.format = "none";
output.type = "generic";
output.message = line;
// A date output has two general formats. The first is an action
// followed by a message and has a format of "[ACTION] message"
const dateActionRegex = /^\[(\w+)\] (.*)$/;
let dateActionMatch = dateActionRegex.exec(dateContent);
if (dateActionMatch) {
return {
source,
format,
time,
type: "action",
action: dateActionMatch[1],
message: dateActionMatch[2],
};
}
// The other format is a generic message
return {
source,
format,
time,
type: "generic",
message: dateContent,
};
}
return output;
// The last category of output is simply a generic message with no
// formating.
return {
source,
format: "none",
type: "generic",
message: line,
};
}
// https://stackoverflow.com/a/49402091
type KeysOfUnion<T> = T extends T ? keyof T : never;
interface Heuristic {
filter: {
[P in KeysOfUnion<lib.ParsedFactorioOutput>]?: string | string[] | RegExp;
},
action(this: FactorioServer, parsed: lib.ParsedFactorioOutput): void;
}
// These are filters applied to the output from Factorio. The filter
@@ -290,7 +295,7 @@ function parseOutput(line, source) {
//
// If the filter matches the action is called with the FactorioServer
// instance bound as this.
const outputHeuristics = [
const outputHeuristics: Heuristic[] = [
// Message indicating the RCON interface has started
{
filter: { type: "log", message: /^Starting RCON interface/ },
@@ -314,7 +319,7 @@ const outputHeuristics = [
message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/,
},
action: function(parsed) {
let name = /^Saving to (_autosave\d+) /.exec(parsed.message)[1];
let name = /^Saving to (_autosave\d+) /.exec(parsed.message)![1];
this.emit("_autosave", name);
},
},
@@ -359,7 +364,7 @@ const outputHeuristics = [
// when an error occurs. Since we can't send input there we terminate the server
// process when this happens.
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
this._server.kill();
this._server!.kill();
}
},
},
@@ -394,6 +399,37 @@ const outputHeuristics = [
];
export interface FactorioServerOptions {
/** Logger to use for reporting errors. */
logger?: lib.Logger,
/**
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: string,
/** UDP port to host game on. */
gamePort?: number,
/** TCP port to use for RCON. */
rconPort?: number,
/** Password use for RCON. */
rconPassword?: string,
/** Turn on whitelisting. */
enableWhitelist?: boolean,
/** Enable Factorio.com based multiplayer bans. */
enableAuthserverBans?: boolean,
/** Enable verbose logging. */
verboseLogging?: boolean,
/** Strip paths in the console. */
stripPaths?: boolean,
/**
* Maximum number of RCON commands transmitted in parallel on the RCON
* connection.
*/
maxConcurrentCommands?: number
/** Timeout in ms to wait for a response to a shutdown command. */
hangTimeoutMs?: number,
}
/**
* Factorio Server interface
*
@@ -412,90 +448,97 @@ const outputHeuristics = [
* - 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
*/
class FactorioServer extends events.EventEmitter {
export class FactorioServer extends events.EventEmitter {
/** UDP port used for hosting the Factorio game server on */
gamePort: number;
/** TCP port used for RCON on the Factorio game server */
rconPort: number;
/** Password used for RCON on the Factorio game server */
rconPassword: string;
/** Enable player whitelist */
enableWhitelist: boolean;
/** Enable Factorio.com based multiplayer bans **/
enableAuthserverBans: boolean;
/** Enable verbose logging */
verboseLogging: boolean;
/** Timeout in ms to wait for a response to a shutdown command */
hangTimeoutMs = 5000;
_factorioDir: string;
_writeDir: string;
// Resolved in init
_version: string | null = null;
_dataDir: string | null = null;
_logger: lib.Logger;
_targetVersion: string;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
_rconReady = false;
_gameReady = false;
_stripRegExp?: RegExp;
_maxConcurrentCommands = 5;
// Array of possible causes for an unexpected shutdown
_unexpected: string[] = [];
_killed = false;
_runningAutosave: string | null = null;
/**
* Create a Factorio server interface
*
* @param {string} factorioDir - Directory of the Factorio install(s).
* @param {string} writeDir - Directory to write runtime data to.
* @param {object} options - Optional parameters.
* @param {Logger} options.logger - Logger to use for reporting errors.
* @param {string} options.version -
* Version of Factorio to use. Can also be the string "latest" to
* use the latest version found in `factorioDir`.
* @param {number} options.gamePort - UDP port to host game on.
* @param {number} options.rconPort - TCP port to use for RCON.
* @param {string} options.rconPassword - Password use for RCON.
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
* @param {boolean} options.enableAuthserverBans -
* Enable Factorio.com based multiplayer bans.
* @param {boolean} options.verboseLogging - Enable verbose logging.
* @param {boolean} options.stripPaths - Strip paths in the console.
* @param {number} options.maxConcurrentCommands -
* Maximum number of RCON commands transmitted in parallel on the
* RCON connection.
* @param {number} options.hangTimeout -
* Timeout in ms to wait for a response to a shutdown command.
* @param factorioDir - Directory of the Factorio install(s).
* @param writeDir - Directory to write runtime data to.
* @param options - Optional parameters.
*/
constructor(factorioDir, writeDir, options) {
constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
super();
this._factorioDir = factorioDir;
this._writeDir = writeDir;
// Resolved in init
this._version = null;
this._dataDir = null;
this._logger = options.logger || logger;
this._logger = options.logger || lib.logger;
this._targetVersion = options.version || "latest";
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
/** TCP port used for RCON on the Factorio game server */
this.rconPort = options.rconPort || randomDynamicPort();
/** Password used for RCON on the Factorio game server */
this.rconPassword = options.rconPassword;
this.rconPassword = options.rconPassword as string; // init will generate one if not there
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist;
this.enableWhitelist = options.enableWhitelist || false;
/** Enable Factorio.com based multiplayer bans **/
this.enableAuthserverBans = options.enableAuthserverBans;
this.enableAuthserverBans = options.enableAuthserverBans || false;
/** Enable verbose logging */
this.verboseLogging = options.verboseLogging;
this.verboseLogging = options.verboseLogging || false;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
/** Timeout in ms to wait for a response to a shutdown command */
this.hangTimeout = options.hangTimeout || 5000;
this._state = "new";
this._server = null;
this._rconClient = null;
this._rconReady = false;
this._gameReady = false;
this.hangTimeoutMs = options.hangTimeoutMs ?? 5000;
if (options.stripPaths) {
let chars = new Set(path.resolve(this.writePath("temp")));
chars.delete(":"); // Having a colon could lead to matching the line number
chars = [...chars].join("");
let charSet = new Set(path.resolve(this.writePath("temp")));
charSet.delete(":"); // Having a colon could lead to matching the line number
let chars = [...charSet].join("");
let tempPath = `${path.resolve(this.writePath("temp", "currently-playing"))}${path.sep}`;
let writePath = `${path.resolve(this.writePath())}${path.sep}`;
this._stripRegExp = new RegExp(
// The script printer formats paths using / on both windows and linux.
// But the save path is printed with \ on windows and / on linux.
`(${escapeRegExp(tempPath.replace(/\\/g, "/"))})|` +
`(${escapeRegExp(writePath)})|` +
`(\\.\\.\\.[${escapeRegExp(chars)}/]*?currently-playing/)`,
`(${lib.escapeRegExp(tempPath.replace(/\\/g, "/"))})|` +
`(${lib.escapeRegExp(writePath)})|` +
`(\\.\\.\\.[${lib.escapeRegExp(chars)}/]*?currently-playing/)`,
"g"
);
}
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
this._killed = false;
// Track autosaving
this._runningAutosave = null;
this.on("_autosave", name => {
this.emit("autosave-start", name);
this._runningAutosave = name;
@@ -511,7 +554,7 @@ class FactorioServer extends events.EventEmitter {
});
}
_check(expectedStates) {
_check(expectedStates: FactorioServer["_state"][]) {
if (!expectedStates.includes(this._state)) {
throw new Error(
`Expected state ${expectedStates} but state is ${this._state}`
@@ -519,31 +562,31 @@ class FactorioServer extends events.EventEmitter {
}
}
async _handleIpc(line) {
async _handleIpc(line: Buffer) {
let channelEnd = line.indexOf("?");
if (channelEnd === -1) {
throw new Error(`Malformed IPC line "${line.toString()}"`);
}
let channel = line
.slice(6, channelEnd)
.subarray(6, channelEnd)
.toString("utf-8")
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
String.fromCharCode(parseInt(p1, 16))
))
;
let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8");
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
if (type === "j") {
try {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8"));
} catch (err) {
throw new Error(`Malformed JSON to ${channel}: ${line.slice(channelEnd + 2).toString("utf-8")}`);
throw new Error(`Malformed JSON to ${channel}: ${line.subarray(channelEnd + 2).toString("utf-8")}`);
}
} else if (type === "f") {
let fileName = line.slice(channelEnd + 2).toString("utf-8");
let fileName = line.subarray(channelEnd + 2).toString("utf-8");
let filePath = this.writePath("script-output", fileName);
// Prevent malicious names
@@ -569,15 +612,15 @@ class FactorioServer extends events.EventEmitter {
}
}
_handleOutput(line, source) {
if (line.slice(0, 6).equals(Buffer.from("\f$ipc:"))) {
this._handleIpc(line).catch(err => this.emit("error", err));
_handleOutput(rawLine: Buffer, source: "stdout" | "stderr") {
if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) {
this._handleIpc(rawLine).catch(err => this.emit("error", err));
return;
}
this.emit(source, line);
this.emit(source, rawLine);
line = line.toString("utf-8");
let line = rawLine.toString("utf-8");
if (this._stripRegExp) {
line = line.replace(this._stripRegExp, "");
}
@@ -591,14 +634,14 @@ class FactorioServer extends events.EventEmitter {
}
if (expected instanceof RegExp) {
if (!expected.test(parsed[name])) {
if (!expected.test((parsed as any)[name])) {
continue heuristicLoop;
}
} else if (expected instanceof Array) {
if (!expected.includes(parsed[name])) {
if (!expected.includes((parsed as any)[name])) {
continue heuristicLoop;
}
} else if (expected !== parsed[name]) {
} else if (expected !== (parsed as any)[name]) {
continue heuristicLoop;
}
}
@@ -627,7 +670,7 @@ class FactorioServer extends events.EventEmitter {
maxPending: this.maxConcurrentCommands,
};
this._rconClient = new rconClient.Rcon(config);
this._rconClient = new Rcon(config);
this._rconClient.on("error", () => { /* Ignore */ });
this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); });
this._rconClient.on("end", () => {
@@ -640,15 +683,18 @@ class FactorioServer extends events.EventEmitter {
await this._rconClient.connect();
}
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
get maxConcurrentCommands() {
return this._maxConcurrentCommands;
}
set maxConcurrentCommands(value) {
set maxConcurrentCommands(value: number) {
this._maxConcurrentCommands = value;
this.setMaxListeners(value + 5);
if (this._rconClient) {
// @ts-expect-error sendQueue is private
if (this._rconClient.sendQueue) {
// @ts-expect-error sendQueue is private
this._rconClient.sendQueue.maxConcurrent = value;
}
// XXX: Workaround to suppress bogus event listener warning
@@ -667,7 +713,7 @@ class FactorioServer extends events.EventEmitter {
async init() {
this._check(["new"]);
[this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion);
this.rconPassword = this.rconPassword || await generatePassword(10);
this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10);
this._state = "init";
}
@@ -677,7 +723,7 @@ class FactorioServer extends events.EventEmitter {
* constructor.
*/
get version() {
return this._version;
return this._version!;
}
/**
@@ -691,22 +737,32 @@ class FactorioServer extends events.EventEmitter {
}
_attachStdio() {
let stdout = new LineSplitter({ readableObjectMode: true });
let stdout = new lib.LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { this._handleOutput(line, "stdout"); });
this._server.stdout.pipe(stdout);
let stderr = new LineSplitter({ readableObjectMode: true });
this._server!.stdout.pipe(stdout);
let stderr = new lib.LineSplitter({ readableObjectMode: true });
stderr.on("data", line => { this._handleOutput(line, "stderr"); });
this._server.stderr.pipe(stderr);
this._server!.stderr.pipe(stderr);
}
_resetState() {
this._state = "init";
this._server = null;
this._rconClient = null;
this._rconReady = false;
this._gameReady = false;
this._unexpected = [];
this._runningAutosave = null;
}
_watchExit() {
this._server.on("exit", (code, signal) => {
this._server!.on("exit", (code, signal) => {
if (this._state !== "stopping") {
if (signal === "SIGKILL") {
if (this._killed) {
this.emit("error", new libErrors.EnvironmentError("Factorio server was killed"));
this.emit("error", new lib.EnvironmentError("Factorio server was killed"));
} else {
this.emit("error", new libErrors.EnvironmentError(
this.emit("error", new lib.EnvironmentError(
"Factorio server was unexpectedly killed, is the system low on memory?"
));
}
@@ -719,13 +775,13 @@ class FactorioServer extends events.EventEmitter {
msg = `Factorio server was unexpectedly shut by down by signal ${signal}`;
}
this.emit("error", new libErrors.EnvironmentError(msg));
this.emit("error", new lib.EnvironmentError(msg));
} else if (this._unexpected.length === 1) {
this.emit("error", new libErrors.EnvironmentError(this._unexpected[0]));
this.emit("error", new lib.EnvironmentError(this._unexpected[0]));
} else {
this.emit("error", new libErrors.EnvironmentError(
this.emit("error", new lib.EnvironmentError(
"Factorio server unexpectedly shut down. Possible causes:"+
`\n- ${this._unexpected.join("\n- ")}`
));
@@ -736,15 +792,21 @@ class FactorioServer extends events.EventEmitter {
this._rconClient.end().catch(() => {});
}
// Reset server state
this._state = "init";
this._server = null;
this._rconClient = null;
this._rconReady = false;
this._gameReady = false;
this._unexpected = [];
this._runningAutosave = null;
this._resetState();
this.emit("exit");
});
this._server!.on("error", (err: any) => {
if (err.code === "EACCES") {
this.emit("error", new lib.EnvironmentError("Unable to run server: Permission denied"));
} else {
this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`));
}
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._resetState();
this.emit("exit");
});
}
@@ -766,15 +828,15 @@ class FactorioServer extends events.EventEmitter {
* Spawns the Factorio server with the --create argument to create a new
* map save with the given name.
*
* @param {string} name -
* @param name -
* Name of the save to create. Should end with ".zip".
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* @param seed - Seed to pass via --map-gen-seed
* @param mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* @param mapSettings -
* Map setting to pass via --map-settings.
*/
async create(name, seed = null, mapGenSettings = null, mapSettings = null) {
async create(name: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
this._check(["init"]);
this._state = "create";
@@ -785,9 +847,11 @@ class FactorioServer extends events.EventEmitter {
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
@@ -801,10 +865,17 @@ class FactorioServer extends events.EventEmitter {
try {
let [code, signal] = await events.once(this._server, "exit");
void signal;
if (signal) {
throw new Error(`Factorio exited with signal ${signal}`);
}
if (code !== 0) {
throw new Error(`Factorio exited with status ${code}`);
}
} catch (err: any) {
if (err.code === "EACCES") {
throw new lib.EnvironmentError("Unable to run server: Permission denied");
}
throw err;
} finally {
this._state = "init";
}
@@ -818,7 +889,7 @@ class FactorioServer extends events.EventEmitter {
*
* @param {string} save - Name of the save to run.
*/
async start(save) {
async start(save: string) {
this._check(["init"]);
this._state = "running";
@@ -828,8 +899,8 @@ class FactorioServer extends events.EventEmitter {
[
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
"--port", this.gamePort,
"--rcon-port", this.rconPort,
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
@@ -852,14 +923,14 @@ class FactorioServer extends events.EventEmitter {
* Spawn the Factorio server with the --start-server-load-scenario
* argument to start the given scenario.
*
* @param {string} scenario - Name of the scenario to run.
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* @param scenario - Name of the scenario to run.
* @param seed - Seed to pass via --map-gen-seed
* @param mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* @param mapSettings -
* Map setting to pass via --map-settings.
*/
async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) {
async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
this._check(["init"]);
this._state = "running";
@@ -870,11 +941,13 @@ class FactorioServer extends events.EventEmitter {
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", this.gamePort,
"--rcon-port", this.rconPort,
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
@@ -898,13 +971,13 @@ class FactorioServer extends events.EventEmitter {
* If the rcon connection hasn't been established yet, this will
* wait until it is establied and then send the message.
*
* @param {string} message - message to send to server over RCON.
* @param {boolean} expectEmpty -
* @param message - message to send to server over RCON.
* @param expectEmpty -
* if true throw if the response is not empty. Useful for detecting
* errors that might have been sent in response.
* @returns {string} response from server.
* @returns response from server.
*/
async sendRcon(message, expectEmpty) {
async sendRcon(message: string, expectEmpty?: boolean) {
this._check(["running", "stopping"]);
if (!this._rconReady) {
await events.once(this, "rcon-ready");
@@ -939,13 +1012,13 @@ class FactorioServer extends events.EventEmitter {
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server.kill("SIGKILL");
this._server!.kill("SIGKILL");
}
}, this.hangTimeout);
}, this.hangTimeoutMs);
// It's possible the server decides to exit on its own, in which
// case the stop operation has to be cancelled.
this._server.once("exit", () => {
this._server!.once("exit", () => {
stopped = true;
clearTimeout(timeoutId);
});
@@ -991,7 +1064,7 @@ class FactorioServer extends events.EventEmitter {
// complaint for the lack of graceful shutdown to the Factorio
// developers. Rcon does not recognize /quit, stdin is not
// recognized, and there's no "send CTRL+C to process" on Windows.
this._server.kill();
this._server!.kill();
// There appears to be an race condition where sending SIGTERM
// immediatly before the RCON interface comes online causes the
@@ -1000,7 +1073,7 @@ class FactorioServer extends events.EventEmitter {
// case there's no recovering from it.
if (process.platform !== "win32") {
this.on("_quitting", setAlive);
await events.once(this._server, "exit");
await events.once(this._server!, "exit");
clearTimeout(timeoutId);
this.off("_quitting", setAlive);
@@ -1008,7 +1081,7 @@ class FactorioServer extends events.EventEmitter {
// On windows the process is terminated immediately, but to keep
// ordering wait until after the exit event here.
} else {
await events.once(this._server, "exit");
await events.once(this._server!, "exit");
}
}
@@ -1016,18 +1089,18 @@ class FactorioServer extends events.EventEmitter {
* Kill the server
*
* Terminates the server without any cleanup or saving.
* @param {boolean} [unexpected=false] -
* @param unexpected -
* If true raise an error event as a result of killing the Factorio
* process.
*/
async kill(unexpected = false) {
this._check(["running", "stopping"]);
this._check(["running", "stopping", "create"]);
this._killed = true;
if (!unexpected) {
this._state = "stopping";
}
this._server.kill("SIGKILL");
await new Promise(resolve => this._server.once("exit", resolve));
this._server!.kill("SIGKILL");
await new Promise(resolve => this._server!.once("exit", resolve));
}
/**
@@ -1036,7 +1109,7 @@ class FactorioServer extends events.EventEmitter {
* Ensures achievements are disabled on the save that's running. This is
* necessary in order to run any commands at all.
*
* @returns {boolean}
* @returns
* True if acheivements got disabled and false if they already where
* disabled.
*/
@@ -1065,11 +1138,11 @@ class FactorioServer extends events.EventEmitter {
* the data directory of the Factorio server. Not valid before init has
* been called.
*
* @param {...string} parts - Extra parts to add to the data path.
* @returns {string} Data directory path.
* @param parts - Extra parts to add to the data path.
* @returns Data directory path.
*/
dataPath(...parts) {
return path.join(this._dataDir, ...parts);
dataPath(...parts: string[]) {
return path.join(this._dataDir!, ...parts);
}
/**
@@ -1077,7 +1150,7 @@ class FactorioServer extends events.EventEmitter {
*
* Get the path to the factorio binary depending on the platform (MacOS support)
*
* @returns {string} Path to factorio binary
* @returns Path to factorio binary
*/
binaryPath() {
if (process.platform === "darwin") {
@@ -1092,10 +1165,10 @@ class FactorioServer extends events.EventEmitter {
* Creates a path using path.join with the given parts that's relative to
* the write directory of the Factorio server.
*
* @param {...string} parts - Extra parts to add to the write path.
* @returns {string} Write directory path.
* @param parts - Extra parts to add to the write path.
* @returns Write directory path.
*/
writePath(...parts) {
writePath(...parts: string[]) {
return path.join(this._writeDir, ...parts);
}
@@ -1104,45 +1177,41 @@ class FactorioServer extends events.EventEmitter {
*
* Loads server-settings.example.json from the data dir.
*
* @returns {object} the parsed server-settings.
* @returns the parsed server-settings.
*/
async exampleSettings() {
return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8"));
}
async _writeConfigIni() {
let content = libIni.stringify({
let content = lib.stringify({
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
},
});
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
await lib.safeOutputFile(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings, mapSettings) {
async _writeMapSettings(mapGenSettings?: object, mapSettings?: object) {
if (mapGenSettings) {
await libFileOps.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
await lib.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t")
);
}
if (mapSettings) {
await libFileOps.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
await lib.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t")
);
}
}
}
module.exports = {
FactorioServer,
// For testing only
_getVersion: getVersion,
_versionOrder: versionOrder,
_findVersion: findVersion,
_randomDynamicPort: randomDynamicPort,
_generatePassword: generatePassword,
_parseOutput: parseOutput,
};
// For testing only
export const _getVersion = getVersion;
export const _versionOrder = versionOrder;
export const _findVersion = findVersion;
export const _randomDynamicPort = randomDynamicPort;
export const _generatePassword = generatePassword;
export const _parseOutput = parseOutput;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib" },
],
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist",
},
}
+4
View File
@@ -0,0 +1,4 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+1
View File
@@ -0,0 +1 @@
/src/data/message_validate.js
+1
View File
@@ -0,0 +1 @@
dist/web/build
-84
View File
@@ -1,84 +0,0 @@
"use strict";
/**
* Stastics collected about a player.
* @alias module:lib/PlayerStats
*/
class PlayerStats {
/**
* Count of the number of times this player has been seen joining this server.
* @type {number}
*/
joinCount = 0;
/**
* Time in ms this player has been seen online on this instance.
* @type {number}
*/
onlineTimeMs = 0;
/**
* Timestamp the player was last seen joining.
* @type {Date=}
*/
lastJoinAt;
/**
* Timestamp the player was last seen leaving.
* @type {Date=}
*/
lastLeaveAt;
/**
* Reason the player was last seen leaving with.
* @type {string=}
*/
lastLeaveReason;
static jsonSchema = {
type: "object",
properties: {
"join_count": { type: "integer" },
"online_time_ms": { type: "number" },
"last_join_at": { type: "number" },
"last_leave_at": { type: "number" },
"last_leave_reason": { type: "string" },
},
};
constructor(json = {}) {
if (json["join_count"]) {
this.joinCount = json["join_count"];
}
if (json["online_time_ms"]) {
this.onlineTimeMs = json["online_time_ms"];
}
if (json["last_join_at"]) {
this.lastJoinAt = new Date(json["last_join_at"]);
}
if (json["last_leave_at"]) {
this.lastLeaveAt = new Date(json["last_leave_at"]);
this.lastLeaveReason = json["last_leave_reason"] || "quit";
}
}
toJSON() {
let json = {};
if (this.joinCount) {
json["join_count"] = this.joinCount;
}
if (this.onlineTimeMs) {
json["online_time_ms"] = this.onlineTimeMs;
}
if (this.lastJoinAt) {
json["last_join_at"] = this.lastJoinAt.getTime();
}
if (this.lastLeaveAt) {
json["last_leave_at"] = this.lastLeaveAt.getTime();
json["last_leave_reason"] = this.lastLeaveReason;
}
return json;
}
}
module.exports = PlayerStats;
+1 -1
View File
@@ -1,5 +1,5 @@
# Common Library for Clusterio
This package contains functionality shared between master, slave, ctl and/or plugins of Clusterio.
This package contains functionality shared between controller, host, ctl and/or plugins of Clusterio.
See [the Clusterio repository](https://github.com/clusterio/clusterio) for more information on Clusterio.
-12
View File
@@ -1,12 +0,0 @@
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";
+17
View File
@@ -0,0 +1,17 @@
export * from "./src/api";
export * from "./src/config";
export * from "./src/data";
export * from "./src/errors";
export * from "./src/factorio/exchange_string";
export * from "./src/helpers";
export * from "./src/ini";
export * from "./src/link";
export * from "./src/logging";
export * from "./src/permissions";
export * from "./src/plugin";
export * from "./src/schema";
export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
+10
View File
@@ -0,0 +1,10 @@
export interface BuildArgs {
factorioVersion?: string;
dependencies?: Record<string, string>;
clean?: boolean;
pack?: boolean;
build?: boolean;
outputDir: string;
sourceDir: string;
};
export function build(args: BuildArgs): Promise<void>;
+4 -4
View File
@@ -12,10 +12,10 @@ const yargs = require("yargs");
const finished = util.promisify(stream.finished);
async function buildMod(args, info) {
async function buildMod(args, info, modName) {
if (args.build) {
await fs.ensureDir(args.outputDir);
let modName = `${info.name}_${info.version}`;
modName = args.modName ?? `${info.name}_${info.version}`;
if (args.pack) {
let zip = new JSZip();
@@ -41,7 +41,7 @@ async function buildMod(args, info) {
}
delete info.additional_files;
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, 4));
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, "\t"));
let modPath = path.join(args.outputDir, `${modName}.zip`);
console.log(`Writing ${modPath}`);
@@ -62,7 +62,7 @@ async function buildMod(args, info) {
}
delete info.additional_files;
await fs.writeFile(path.join(modDir, "info.json"), JSON.stringify(info, null, 4));
await fs.writeFile(path.join(modDir, "info.json"), JSON.stringify(info, null, "\t"));
}
}
}
-248
View File
@@ -1,248 +0,0 @@
/**
* Command library
*
* Data types and utilites for commands to clusterioctl's CLI.
*
* @author Hornwitser
* @module lib/command
*/
"use strict";
const libLink = require("./link");
const libErrors = require("./errors");
/**
* Represents a command that can be runned by clusterioctl
* @static
*/
class Command {
/**
* Define an executable command.
*
* @param {Object} cmd - Command definiton.
* @param {Array} cmd.definiton -
* Arguments to pass to yargs .command method to define this
* command.
* @param {function(args, control)} handler -
* Async function invoked when the command is executed. Is given
* the parsed args and a reference to the {@link
* module:ctl/ctl.Control} instance.
*/
constructor({ definition, handler }) {
if (definition[0] instanceof Array) {
this.name = definition[0][0].split(" ")[0];
this.alias = definition[0].slice(1);
} else {
this.name = definition[0].split(" ")[0];
this.alias = [];
}
this._definition = definition;
this._handler = handler;
}
register(yargs) {
yargs.command(...this._definition);
}
async run(args, control) {
await this._handler(args, control);
}
}
/**
* A node in the command tree that can hold commands and sub trees
*
* Container which can contain {@link module:lib/command.Command}s and other
* CommandTrees. This is used by clusterioctl to hold the full tree of
* available commands, you may extend this tree by using a control plugin,
* see {@link module:lib/plugin.BaseControlPlugin#addCommands}
*
* @static
*/
class CommandTree {
/**
* Define a command containing sub commands.
*
* @param {Object} cmd - Command definition
* @param {string} cmd.name - Name of the command.
* @param {Array<string>} cmd.alias - Aliases for this tree.
* @param {description} cmd.description -
* Descripton to provide for this command tree node.
*/
constructor({ name, alias, description }) {
if (typeof name !== "string") {
throw new Error("name must be a string");
}
this.name = name;
this.alias = alias || [];
this.description = description;
this.subCommands = new Map();
}
/**
* Add a command or command tree to this tree
*
* @param {module:lib/command.Command|module:lib/command.CommandTree} command -
* The command to add to this command tree.
*/
add(command) {
if (this.subCommands.has(command.name)) {
throw new Error(`Command ${command.name} already exists`);
}
this.subCommands.set(command.name, command);
for (let alias of command.alias) {
if (this.subCommands.has(alias)) {
throw new Error(`Alias ${alias} for command ${command.name} already exists`);
}
this.subCommands.set(alias, command);
}
}
/**
* Get a command or command tree from this tree
*
* @param {string} name -
* The name of the command or command tree to retrieve.
* @return {?module:lib/command.Command|module:lib/command.CommandTree}
* The command to add to this command tree.
*/
get(name) {
return this.subCommands.get(name) || null;
}
/**
* Insert the registered commands into the given yargs parser
*
* Traverses the the tree of sub-commands recursively and adds all of
* them to the given yargs parser.
*
* @param {Object} yargs - yargs parser to register with.
*/
register(yargs) {
yargs.command([this.name].concat(this.alias), this.description, (yargs) => {
for (let [name, command] of this.subCommands) {
// Check if the entry is not an alias.
if (name === command.name) {
command.register(yargs);
}
}
}, () => {
yargs.showHelp();
yargs.exit();
});
}
}
/**
* Resolve a string into a slave ID
*
* Resolves a string with either an slave name or an id into an integer with
* the slave ID.
*
* @param {module:lib/link.Link} client - link to master server to query slave on.
* @param {string} slaveName - string with name or id of slave.
* @returns {number} slave ID.
* @static
*/
async function resolveSlave(client, slaveName) {
let slaveId;
if (/^-?\d+$/.test(slaveName)) {
slaveId = parseInt(slaveName, 10);
} else {
let response = await libLink.messages.listSlaves.send(client);
for (let slave of response.list) {
if (slave.name === slaveName) {
slaveId = slave.id;
break;
}
}
if (slaveId === undefined) {
throw new libErrors.CommandError(`No slave named ${slaveName}`);
}
}
return slaveId;
}
/**
* Resolve a string to an instance ID
*
* Resolves a string with either an instance name or an id into an integer
* with the instance ID.
*
* @param {module:lib/link.Link} client - link to master server to query instance on.
* @param {string} instanceName - string with name or id of instance.
* @returns {number} instance ID.
* @static
*/
async function resolveInstance(client, instanceName) {
let instanceId;
if (/^-?\d+$/.test(instanceName)) {
instanceId = parseInt(instanceName, 10);
} else {
let response = await libLink.messages.listInstances.send(client);
for (let instance of response.list) {
if (instance.name === instanceName) {
instanceId = instance.id;
break;
}
}
if (instanceId === undefined) {
throw new libErrors.CommandError(`No instance named ${instanceName}`);
}
}
return instanceId;
}
/**
* Retrieve role object from string
*
* Resolves a string with either a role name or an id into an object
* representing the role.
*
* @param {module:lib/link.Link} client - link to master server to query role on.
* @param {string} roleName - string with name or id of role.
* @returns {Object} Role info.
* @static
*/
async function retrieveRole(client, roleName) {
let response = await libLink.messages.listRoles.send(client);
let resolvedRole;
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
for (let role of response.list) {
if (role.id === roleId) {
resolvedRole = role;
break;
}
}
} else {
for (let role of response.list) {
if (role.name === roleName) {
resolvedRole = role;
break;
}
}
}
if (!resolvedRole) {
throw new libErrors.CommandError(`No role named ${roleName}`);
}
return resolvedRole;
}
module.exports = {
Command,
CommandTree,
resolveSlave,
resolveInstance,
retrieveRole,
};
-834
View File
@@ -1,834 +0,0 @@
// Configuration classes
"use strict";
const events = require("events");
const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("../helpers");
/**
* Invalid Access exception
*
* Exception class for when an access attempt is made to a field that
* does not permitt this access is done.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidAccess extends Error { };
/**
* Invalid Value exception
*
* Exception class for when invalid values are attempted to be set on a
* field.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidValue extends Error { };
/**
* Invalid Field exception
*
* Exception class for when a group or field that does not exist is
* attempted to be accessed.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidField extends Error { };
/**
* Split the given string on the first instance of separator
*
* Splits `string` on the first instance of `separator` and returns an
* array consisting of the string up to the separator and the string
* after the separator. Returns an array with the string and an empty
* string if the separator is not present.
*
* @param {string} separator - Separator to split string by.
* @param {string} string - String to split
* @returns {Array} string split on separator.
* @memberof module:lib/config
* @private
* @inner
*/
function splitOn(separator, string) {
let index = string.indexOf(separator);
if (index === -1) {
return [string, ""];
}
return [string.slice(0, index), string.slice(index + separator.length)];
}
/**
* Collection of config groups
*
* Represents a collection of ConfigGroup instances that can be
* managed as one.
*
* This is an EventEmitter with the following events:
* - fieldChanged -
* invoked after a field has been updated in one of the config groups
* belonging to this config. Takes three parameters: group, field,
* prev.
*
* @extends events.EventEmitter
*
* @memberof module:lib/config
*/
class Config extends events.EventEmitter {
/**
* Create a new instance of the given config
*
* @param {string} location -
* Location to evaluate access for this instance from
*/
constructor(location) {
super();
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`);
}
if (typeof location !== "string") {
throw new Error("location must be a string");
}
this.location = location;
this._groups = new Map();
this._unknownGroups = new Map();
}
async init() {
for (let [name, GroupClass] of this.constructor._groups) {
if (!this._groups.has(name)) {
let group = new GroupClass(this);
await group.init();
this._groups.set(name, group);
}
}
this._initialized = true;
}
_validate(serializedConfig) {
if (basicType(serializedConfig) !== "object") {
throw new Error(`Expected object, not ${basicType(serializedConfig)} for config`);
}
if (basicType(serializedConfig.groups) !== "array") {
throw new Error(`Expected groups to be an array, not ${basicType(serializedConfig.groups)}`);
}
}
/**
* Load config from a serialized object
*
* @param {object} serializedConfig - Serialized config to load.
* @param {string=} location - Location used for access control.
*/
async load(serializedConfig, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let GroupClass = this.constructor._groups.get(serializedGroup.name);
if (!GroupClass) {
this._unknownGroups.set(serializedGroup.name, serializedGroup);
continue;
}
let group = new GroupClass(this);
await group.load(serializedGroup, location);
this._groups.set(group.name, group);
}
await this.init();
}
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the config.
*/
serialize(location = this.location) {
let groups = [...this._unknownGroups.values()];
for (let group of this._groups.values()) {
groups.push(group.serialize(location));
}
return { groups };
}
/**
* Update the configuration based on a serialized config
*
* Updates all groups in the config with groups in the serialized
* config passed.
*
* @param {Object} serializedConfig - Output from .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedConfig, notify, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let group = this._groups.get(serializedGroup.name);
if (group) {
group.update(serializedGroup, notify, location);
} else {
this._unknownGroups.set(serializedGroup.name, serializedGroup);
}
}
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of the field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).canAccess(field, location);
}
/**
* Get the value for a config field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} the value for the field.
*/
get(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).get(field, location);
}
/**
* Set the value for a config field
*
* @param {string} name - Name of field to set.
* @param {*} value - the value to set on field
* @param {string=} location - Location used for access control.
*/
set(name, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).set(field, value, location);
}
/**
* Set property of an object value for a config field
*
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
*/
setProp(name, prop, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).setProp(field, prop, value, location);
}
/**
* Get the config group instance with the given name
*
* @param {string} name - Name of group to get.
* @returns {module:lib/config.ConfigGroup} config group.
*/
group(name) {
if (!this._initialized) {
throw new Error(`${this.constructor.name} instance is uninitialized`);
}
let group = this._groups.get(name);
if (!group) {
throw new InvalidField(`No config group named '${name}'`);
}
return group;
}
static _initSubclass() {
// These properties are initialized here in order to ensure that
// they end up on the sub-class.
if (!this._initialized) {
this._initialized = true;
this._finalized = false;
this._groups = new Map();
}
}
/**
* Mapping of group name to group class
*
* Returns the internal mapping of group names to group classes that
* mave been registered with this config. Note that mutating this
* object will lead to unexpected behaviour.
*
* @returns {Map<string, GroupClass>} config groups
*/
static get groups() {
this._initSubclass();
return this._groups;
}
/**
* Locks this Config class from adding more groups.
*/
static finalize() {
this._initSubclass();
this._finalized = true;
}
/**
* Register a ConfigGroup with this Config
*
* Adds the ConfigGroup to this config as a group that will get
* initialized, loaded and serialized with this config class.
*
* @param {module:lib/config.ConfigGroup} group - class to register.
*/
static registerGroup(group) {
this._initSubclass();
if (this._finalized) {
throw new Error("Cannot register group on finalized config");
}
if (!group._finalized) {
throw new Error("Group must be finalized before it can be registered");
}
if (this._groups.has(group.groupName)) {
throw new Error(`${group.groupName} has already been registered`);
}
this._groups.set(group.groupName, group);
}
}
/**
* Collection of related config entries
*
* Type checks and stores a collection of related config entries. Attempt
* to set invalid values for fields will result in an error thrown while
* loading a config with invalid values will result in those being replaced
* by the innitial value for those fields.
*
* @memberof module:lib/config
*/
class ConfigGroup {
/**
* Creates a new config group.
*
* After the creation of the new config group you have to call
* either .init() or .load() on it in order to fully initialize it.
*
* @param {module:lib/config.Config} config -
* Parent config for this group instance.
*/
constructor(config) {
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`);
}
this._fields = new Map();
this._unknownFields = new Map();
this.config = config;
}
/**
* Check access for a field
*
* @param {object} def - definition for for the field to check.
* @param {string} remote - location of remote access to check for.
* @param {boolean} error - throw on failure to pass check.
* @throws {module:lib/config.InvalidAccess}
* if access is not granted and error is true.
* @returns {boolean} true if access is granted
* @private
*/
_checkAccess(def, remote, error) {
for (let loc of [remote, this.config.location]) {
if (!def.access.includes(loc)) {
if (error) {
throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`);
} else {
return false;
}
}
}
return true;
}
/**
* Initialize new config group
*
* Computes and assigns the initial values for all fields of the
* config group.
*/
async init() {
for (let [name, def] of this.constructor._definitions) {
if (this._fields.has(name)) {
continue;
}
if (!def.access.includes(this.config.location)) {
continue;
}
let value = null;
if (typeof def.initial_value === "function") {
value = await def.initial_value();
} else if (def.initial_value !== undefined) {
value = def.initial_value;
}
this._fields.set(name, value);
}
}
/**
* Load from serialized group
*
* Loads all the values from the passed serialized version of the
* group and then initializes any possible missing fields from it to
* their default values.
*
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {string=} location - Location used for access control.
*/
async load(serializedGroup, location = this.config.location) {
this.update(serializedGroup, false, location);
await this.init();
}
/**
* Name of the group
*
* Shortcut for `group.constructor.groupName`
*/
get name() {
return this.constructor.groupName;
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
return this._checkAccess(def, location, false);
}
/**
* Get value for field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} Value stored for the field.
*/
get(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
return this._fields.get(name);
}
/**
* Set value of field
*
* The field must be defined for the config group and the value is
* type checked against the field definition.
*
* @param {string} name - Name of field to set.
* @param {*} value - Value to set for field.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if value is not allowed for the field.
*/
set(name, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
// Empty strings are treates as null
if (value === "") {
value = null;
}
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
if (basicType(value) === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
}
}
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
}
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
let prev = this._fields.get(name);
this._fields.set(name, value);
if (this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
/**
* Set property of object field
*
* Update value of stored object field by setting the specified property
* on it. The field must be defined as an object for the config group.
*
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if field is not an object.
*/
setProp(name, prop, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
}
let prev = this._fields.get(name);
let updated = {...prev || {}};
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
this._fields.set(name, updated);
if (this.config && !isDeepStrictEqual(updated, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
/**
* Update a config group from a serialized group
*
* Overwrites the values of all the fields in this config group that
* has a valid value in the passed serialized group with that value.
*
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedGroup, notify, location = this.config.location) {
if (basicType(serializedGroup) !== "object") {
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
}
if (serializedGroup.name !== this.name) {
throw new Error(`Expected group name ${this.name}, not ${serializedGroup.name}`);
}
if (basicType(serializedGroup.fields) !== "object") {
throw new Error(`Expected fields to be an object, not ${basicType(serializedGroup.fields)}`);
}
for (let [name, value] of Object.entries(serializedGroup.fields)) {
let def = this.constructor._definitions.get(name);
if (!def) {
this._unknownFields.set(name, value);
continue;
}
if (!this._checkAccess(def, location, false)) {
continue;
}
if (def.optional) {
if (value !== null && basicType(value) !== def.type) {
continue;
}
} else if (basicType(value) !== def.type) {
continue;
}
if (def.enum && !def.enum.includes(value)) {
continue;
}
let prev = this._fields.get(name);
this._fields.set(name, value);
if (notify && this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
}
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the group.
*/
serialize(location = this.config.location) {
let fields = {};
for (let [name, value] of this._fields) {
let def = this.constructor._definitions.get(name);
if (!this._checkAccess(def, location, false)) {
continue;
}
fields[name] = value;
}
for (let [name, value] of this._unknownFields) {
fields[name] = value;
}
return {
name: this.name,
fields,
};
}
static _initSubclass() {
// These properties are initialized here in order to ensure that
// they end up on the sub-class.
if (!this._initialized) {
this._initialized = true;
this._definitions = new Map();
}
}
/**
* Mapping of config name to field meta data
*
* Returns the internal meta data for the fields of this class.
* Note that mutating this object will lead to unexpected behaviour.
*
* @returns {Map<string, Object>} field meta data
*/
static get definitions() {
this._initSubclass();
return this._definitions;
}
/**
* Define a new configuration field
*
* @param {Object} item - Field definition.
* @param {Array<string>=} item.access -
* Array of strings declaring where this config entry can be read
* and modified from.
* @param {string} item.type -
* string declaring the type of the config value, supports
* boolean, string, number and object.
* @param {string} item.name -
* Name of the configuration field. Should follow the
* lower_case_underscore style.
* @param {string} item.title -
* Text used to identify the config field in user interfaces.
* @param {string} item.description -
* Text used to describe the config field in user interfaces.
* @param {Array=} item.enum -
* Array of all values that are acceptible. Useful for making
* enumerated values.
* @param {boolean=} item.optional -
* True if this field can be set to null. Defaults to false.
* @param {*=} item.initial_value -
* Value this config field should take in a newly initialized
* config. This can also be an async function returning the
* value to use.
*/
static define(item) {
if (this._finalized) {
throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`);
}
if (basicType(this.groupName) !== "string") {
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
}
this._initSubclass();
let itemKeys = Object.keys(item);
// Check for required properties
["name", "type"].forEach(prop => {
if (!itemKeys.includes(prop)) {
throw new Error(`${prop} is required when defining an field`);
}
});
// Validate properties
let validTypes = ["boolean", "string", "number", "object"];
Object.keys(item).forEach(key => {
let value = item[key];
let valueType = basicType(value);
switch (key) {
case "type":
if (!validTypes.includes(value)) {
throw new Error(`${value} is not a valid type`);
}
break;
case "access":
if (valueType !== "array") {
throw new Error("access must be an array");
}
break;
case "enum":
if (valueType !== "array") {
throw new Error("enum must be an array");
}
break;
case "name":
if (valueType !== "string") {
throw new Error("name must be a string");
}
break;
case "title":
if (valueType !== "string") {
throw new Error("title must be a string");
}
break;
case "description":
if (valueType !== "string") {
throw new Error("description must be a string");
}
break;
case "optional":
if (valueType !== "boolean") {
throw new Error("optional must be a boolean");
}
break;
case "initial_value":
if (valueType !== item.type && valueType !== "function") {
throw new Error("initial_value must match the type or be a function");
}
break;
default:
throw new Error(`Unknown property ${key}`);
}
});
if (this._definitions.has(item.name)) {
throw new Error(`Config field ${item.name} has already been defined`);
}
let computed = {
access: this.defaultAccess,
optional: false,
fullName: `${this.groupName}.${item.name}`,
...item,
};
if (!computed.access) {
throw new Error(`access not set and no defaultAccess defined for ${this.name}`);
}
if (!computed.optional && computed.initial_value === undefined) {
throw new Error(`Non-optional field ${item.name} needs an initial_value`);
}
this._definitions.set(item.name, computed);
}
/**
* Locks this ConfigGroup class from adding more definitons.
*/
static finalize() {
if (basicType(this.groupName) !== "string") {
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
}
this._initSubclass();
this._finalized = true;
}
}
/**
* Config group for plugins
*
* Grouping that includes per plugin fields clusterio uses to manage
* plugins. This is currently only the enabled field. It is otherwise
* identical to the ConfigGroup class.
*
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class PluginConfigGroup extends ConfigGroup {
static _initSubclass() {
if (!this._initialized) {
super._initSubclass();
this.define({
name: "load_plugin",
title: "Load Plugin",
type: "boolean",
initial_value: true,
});
}
}
}
module.exports = {
InvalidAccess,
InvalidField,
InvalidValue,
Config,
ConfigGroup,
PluginConfigGroup,
};
-468
View File
@@ -1,468 +0,0 @@
// Core definitions for the configuration system
"use strict";
const classes = require("./classes");
/**
* Master config group for {@link module:lib/config.MasterConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class MasterGroup extends classes.ConfigGroup { }
MasterGroup.defaultAccess = ["master", "slave", "control"];
MasterGroup.groupName = "master";
MasterGroup.define({
name: "name",
title: "Name",
description: "Name of the cluster.",
type: "string",
initial_value: "Your Cluster",
});
MasterGroup.define({
name: "database_directory",
title: "Database directory",
description: "Directory where item and configuration data is stored.",
type: "string",
initial_value: "database",
});
MasterGroup.define({
name: "http_port",
title: "HTTP Port",
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
type: "number",
optional: true,
initial_value: 8080,
});
MasterGroup.define({
name: "https_port",
title: "HTTPS Port",
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
type: "number",
optional: true,
});
MasterGroup.define({
name: "bind_address",
title: "Bind Address",
description: "IP address to bind the HTTP and HTTPS ports on.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "external_address",
title: "External Address",
description: "Public facing address the master server is hosted on.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "tls_certificate",
title: "TLS Certificate",
description: "Path to the certificate to use for HTTPS.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "tls_private_key",
title: "TLS Private Key",
description: "Path to the private key to use for HTTPS.",
type: "string",
optional: true,
});
MasterGroup.define({
access: ["master"],
name: "auth_secret",
title: "Master Authentication Secret",
description:
"Secret used to generate and verify authentication tokens. " +
"Should be a long string of random letters and numbers. " +
"Do not share this.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "heartbeat_interval",
title: "Heartbeat Interval",
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "session_timeout",
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
type: "number",
initial_value: 60,
});
MasterGroup.define({
name: "metrics_timeout",
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from slaves.",
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
description: "ID of role assigned by default to new users.",
type: "number",
optional: true,
initial_value: 1,
});
MasterGroup.finalize();
/**
* Master Config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class MasterConfig extends classes.Config { }
MasterConfig.registerGroup(MasterGroup);
/**
* Slave config group for {@link module:lib/config.SlaveConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class SlaveGroup extends classes.ConfigGroup {}
SlaveGroup.defaultAccess = ["master", "slave", "control"];
SlaveGroup.groupName = "slave";
SlaveGroup.define({
name: "name",
description: "Name of the slave",
type: "string",
initial_value: "New Slave",
});
SlaveGroup.define({
name: "id",
description: "ID of the slave",
type: "number",
initial_value: () => Math.random() * 2**31 | 0,
});
SlaveGroup.define({
name: "factorio_directory",
description: "Path to directory to look for factorio installs",
type: "string",
initial_value: "factorio",
});
SlaveGroup.define({
name: "instances_directory",
description: "Path to directory to store instances in.",
type: "string",
initial_value: "instances",
});
SlaveGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
initial_value: "http://localhost:8080/",
});
SlaveGroup.define({
name: "master_token",
description: "Token to authenticate to master server with.",
type: "string",
initial_value: "enter token here",
});
SlaveGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
SlaveGroup.define({
name: "public_address",
description: "Public facing address players should connect to in order to join instances on this slave",
type: "string",
initial_value: "localhost",
});
SlaveGroup.define({
name: "max_reconnect_delay",
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 60,
});
SlaveGroup.finalize();
/**
* Slave Config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class SlaveConfig extends classes.Config { }
SlaveConfig.registerGroup(SlaveGroup);
/**
* Instance config group for {@link module:lib/config.InstanceConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class InstanceGroup extends classes.ConfigGroup { }
InstanceGroup.defaultAccess = ["master", "slave", "control"];
InstanceGroup.groupName = "instance";
InstanceGroup.define({
name: "name",
type: "string",
initial_value: "New Instance",
});
InstanceGroup.define({
name: "id",
description: "ID of the slave",
type: "number",
initial_value: () => Math.random() * 2**31 | 0,
});
InstanceGroup.define({
name: "assigned_slave",
type: "number",
optional: true,
});
InstanceGroup.define({
name: "auto_start",
description: "Automatically start this instance when the slave hosting it is started up",
type: "boolean",
initial_value: false,
});
InstanceGroup.finalize();
/**
* Factorio config group for {@link module:lib/config.InstanceConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class FactorioGroup extends classes.ConfigGroup { }
FactorioGroup.defaultAccess = ["master", "slave", "control"];
FactorioGroup.groupName = "factorio";
FactorioGroup.define({
name: "version",
description: "Version of the game to run, use latest to run the latest installed version",
type: "string",
initial_value: "latest",
});
FactorioGroup.define({
name: "game_port",
description: "UDP port to run game on, uses a random port if null",
type: "number",
optional: true,
});
FactorioGroup.define({
name: "rcon_port",
description: "TCP port to run RCON on, uses a random port if null",
type: "number",
optional: true,
});
FactorioGroup.define({
name: "rcon_password",
description: "Password for RCON, randomly generated if null",
type: "string",
optional: true,
});
FactorioGroup.define({
name: "player_online_autosave_slots",
description:
"Rename autosaves where players have been online since the previous autosave into a separate autosave " +
"pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initial_value: 5,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "enable_whitelist",
description: "Turn on whitelist for joining the server.",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "enable_authserver_bans",
description: "Turn on Factorio.com based multiplayer bans.",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
type: "object",
initial_value: {}, // See create instance handler in master.
});
FactorioGroup.define({
name: "verbose_logging",
description: "Enable verbose logging on the Factorio server",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "strip_paths",
description: "Strip down instance paths in the log",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_adminlist",
description: "Synchronize adminlist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_whitelist",
description: "Synchronize whitelist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_banlist",
description: "Synchronize banlist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "max_concurrent_commands",
description: "Maximum number of RCON commands trasmitted in parallel",
type: "number",
initial_value: 5,
});
FactorioGroup.finalize();
/**
* Instance config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class InstanceConfig extends classes.Config { }
InstanceConfig.registerGroup(InstanceGroup);
InstanceConfig.registerGroup(FactorioGroup);
/**
* Control config group for {@link module:lib/config.ControlConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class ControlGroup extends classes.ConfigGroup {}
ControlGroup.defaultAccess = ["control"];
ControlGroup.groupName = "control";
ControlGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
optional: true,
});
ControlGroup.define({
name: "master_token",
description: "Token to authenticate to master server with.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "max_reconnect_delay",
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 60,
});
ControlGroup.finalize();
/**
* Control config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class ControlConfig extends classes.Config { }
ControlConfig.registerGroup(ControlGroup);
function validateGroup(pluginInfo, groupName) {
if (!(pluginInfo[groupName].prototype instanceof classes.PluginConfigGroup)) {
throw new Error(
`Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup`
);
}
if (pluginInfo[groupName].groupName !== pluginInfo.name) {
throw new Error(
`Expected ${groupName} for ${pluginInfo.name} to be named after the plugin`
);
}
}
/**
* Registers the config groups for the provided plugin infos
*
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
* @memberof module:lib/config
*/
function registerPluginConfigGroups(pluginInfos) {
for (let pluginInfo of pluginInfos) {
if (pluginInfo.MasterConfigGroup) {
validateGroup(pluginInfo, "MasterConfigGroup");
MasterConfig.registerGroup(pluginInfo.MasterConfigGroup);
} else {
class MasterConfigGroup extends classes.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = pluginInfo.name;
MasterConfigGroup.finalize();
MasterConfig.registerGroup(MasterConfigGroup);
}
if (pluginInfo.instanceEntrypoint) {
if (pluginInfo.InstanceConfigGroup) {
validateGroup(pluginInfo, "InstanceConfigGroup");
InstanceConfig.registerGroup(pluginInfo.InstanceConfigGroup);
} else {
class InstanceConfigGroup extends classes.PluginConfigGroup { }
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
InstanceConfigGroup.groupName = pluginInfo.name;
InstanceConfigGroup.finalize();
InstanceConfig.registerGroup(InstanceConfigGroup);
}
}
}
}
/**
* Lock configs from adding more groups and make them usable
*
* @memberof module:lib/config
*/
function finalizeConfigs() {
MasterConfig.finalize();
SlaveConfig.finalize();
InstanceConfig.finalize();
ControlConfig.finalize();
}
module.exports = {
MasterGroup,
SlaveGroup,
InstanceGroup,
FactorioGroup,
ControlGroup,
MasterConfig,
SlaveConfig,
InstanceConfig,
ControlConfig,
registerPluginConfigGroups,
finalizeConfigs,
};
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more