Compare commits

..
Author SHA1 Message Date
Cooldude2606 90c742ace6 2.0.0-alpha.23 2026-03-10 19:03:18 +00:00
Hornwitser 0d9573b949 Merge pull request #868 from Hornwitser/small-things-16
Small things 16
2026-03-10 02:51:31 +01:00
Cooldude2606 b1f63c9cac Merge pull request #870 from Cooldude2606/fix/missing-await-prepare
Add missing awaits to Instance.prepare
2026-03-10 01:50:00 +00:00
Hornwitser 76099d2a42 Document unreachable code paths in keyToLabels
These were added to as a diagnostic in case the logic turns out to be
broken.  They should not be possible to reach as any value produced by
labelsToKeys will not cause the error conditions to occur.
2026-03-10 02:35:09 +01:00
Hornwitser 60eb2389a3 Test every ASCII character in prometheus test
The a and A felt lonely being the only letters included. Add the other
letters in the ASCII character set as there was no good reason to leave
them out.
2026-03-10 02:19:01 +01:00
Hornwitser 86a648e759 Detail requirements for instance waking requests
These need to make sure instance status is correctly handled, otherwise
they may cause the instance or host to become inoperable.
2026-03-10 02:17:36 +01:00
Hornwitser 66c066616b Add test for Controller.factorioVersions failure
Check that errors from fetchFactorioVersions is correctly handled in the
fetch method provided by the Controller class.
2026-03-10 02:14:44 +01:00
Hornwitser 30f552847a Move FactorioVersionsRequest to BaseConnection
No reason to duplicate this in both the instance and host connection if
the implementation is identical in both.
2026-03-10 02:13:13 +01:00
Cooldude2606 b75e2da480 Add missing awaits to Instance.prepare 2026-03-10 00:34:41 +00:00
Cooldude2606 eb09413d8b Merge pull request #866 from Cooldude2606/fix/server-whitelist
Always create a whitelist file so it can be watched for changes
2026-03-10 00:32:19 +00:00
Hornwitser 05ae607a6a Fix tests for checkForUpdates
Fix incorrect suite name and attempting to test no version matches using
the "latest" version which always matches in the tests for the
FactorioServer.checkForUpdates method.
2026-03-09 20:09:07 +01:00
Hornwitser f6eb55163f Fix metrics broken by comma and equal in labels
The logic for turning a key back into the labelles that defined it would
not account for the labels values possibly including a comma or equal
sign, which in turn broke the logic for handling these metrics.

Fix by properly parsing the keys.
2026-03-09 19:35:02 +01:00
Hornwitser b0595a6502 Fix identical updatedAtMs values in datastore
The assumption with the subscription system is that new updates will
always increment the updatedAtMs value to something greater than what
has been seen before.  This might not happen if the system clock is
adjusted, or updates are sent so fast that Date.now() returns the same
value.

Fix these edge cases so that the timestamp is always incremented.
2026-03-09 17:41:11 +01:00
Hornwitser 2399fb9d15 Fix NpmButton fetching config on every render
The useEffect updates list has the major pitfall that passing an object
via for example a prop like foo={{ bar: 1 }} to it causes it to to
detect it as a change on every re-render due to the newly created object
comparing unqual to the previously passed in object.

Unpack the object to prevent the identity from changing every time the
metrics update on the Host page.
2026-03-09 15:58:35 +01:00
Hornwitser 41b9eb1005 Avoid redirect in fetchFactorioVersions
Speed up fetching of result by avoiding a roundtrip due to path being
redirected to a path ending in a /.
2026-03-09 15:32:43 +01:00
Hornwitser 2f70db4b7e Ignore errors fetching Factorio versions
If an error occurs trying to fetch the list of Factorio versions log a
warning and then return an empty list instead of preventing the system
from operating.
2026-03-09 15:16:46 +01:00
Hornwitser 25d98d2ab3 Fix error starting instance on unlisted versions
The FactorioServer.checkForUpdates function would wrongfully assume that
the version running would always be listed on Wube's archive page, and
throw an error if this was not the case.  Ignore version checking logic
if no suitable latest version could be found.
2026-03-09 15:16:26 +01:00
Hornwitser f56605600f Fix host bricked on Factorio version check
If the FactorioVersionRequest throws the instance and host ends up
bricked as the instance is left in an invalid starting state that
can't be stopped.
2026-03-09 14:12:40 +01:00
Hornwitser 0617552956 Fix circular import breaking --dev mode
A circular import caused code to attempt to use symbols before they were
defined.  Due to the --dev mode on the controller running with different
chunck boundaries this only appeared in the --dev mode.
2026-03-09 13:54:08 +01:00
Ryan WalkerandSolar ff784cc772 Differentiate RCON output for improved clarity (#864)
* #793 - Differentiate RCON output

* -removed <div>
-simplified animation logic.
-removed box-shadow in favor of outline

* -remove Rube Goldberg machine
-removed un needed .css rule

* removed redundant null check

* added an extra line  (2 spaces between imports and rest of file)

---------

Co-authored-by: Solar <solar@homelab.local>
2026-03-08 19:44:42 +00:00
Cooldude2606 5b5b2c28f7 Always write a whitelist on server start 2026-03-08 18:51:45 +00:00
Cooldude2606 101886e522 Downgrade error to a warning as it does not prevent server startup. 2026-03-08 17:52:35 +00:00
Hornwitser 276c36b6e9 Throw error if lib is imported twice (#863)
The lib library was written with the assumption that there's only one
copy of it in the whole application and everything imports that one
copy.  Unfortunately there are quite a few circumstances were package
managers end up installing multiple copies of lib and then different
parts use different versions of the library.

This causes Clusterio to break with strange and hard to track down
errors.  Most notably permissions and link messages for a plugin may get
registered to one copy of lib while the controller uses a different copy
and thus does not see or register that what the plugin added exists.

Detect this condition when importing @clusterio/lib and throw an error
when it has been imported twice in order to prevent the broken
installation from causing future hard to debug issues.
2026-03-06 18:50:18 +01:00
Cooldude2606 45f0b94971 Merge pull request #845 from Cooldude2606/feature/sync-recipe-notifications
Add recipe notification sync to inventory sync
2026-03-06 17:23:33 +00:00
Cooldude2606 5d9a6cdff6 Moved nil failed_deserialization to remove_player 2026-03-06 17:17:15 +00:00
Cooldude2606 f2833eec33 Add script data to allow persistence of unknown recipes 2026-03-06 16:05:57 +00:00
Cooldude2606 4badc2e9c1 Fix sync when joining a new server 2026-03-06 15:35:49 +00:00
Hornwitser 05b6bf8c04 Replace remaining usage of phin (#862)
Replace the remaining places in the codebase using phin with fetch.
With the exception of HttpCloser which uses the http/https request
method instead.
2026-03-06 15:27:12 +01:00
Cooldude2606 3f6203f125 Add valid recipe check and remove empty table check 2026-03-06 13:27:45 +00:00
Hornwitser ef0baaa30f Merge pull request #857 from Hornwitser/remote-tls-ca
Remove tls_ca config option
2026-03-05 17:22:17 +01:00
Hornwitser aef5d57776 Upload test logs for debugging CI failures (#858)
The CI often fails with cryptic errors that would be much easier to
debug if the logs from Factorio was provided.  Upload the cluster log
that was written during the tests which includes Factorio logs so that
these can be inspected after a test failure.
2026-03-05 01:32:00 +01:00
Hornwitser 8a51e9cf4d Use an absolute NODE_EXTRA_CA_CERTS path in test
Remove some complexity around correctly handling a relative path to
extra CA certs by setting it to an absolute path.
2026-03-04 18:27:07 +01:00
Hornwitser fb3fe09466 Remove tlsCa option
The migration from phin to fetch in #755 was hampred by the lack of an
option to set the CA when using fetch.  This meant places that used phin
to talk to the controller with the tlsCa option could not be migrated to
use fetch.

Remove the tlsCa option to unblock this dependency. The replacement for
this option is to set the NODE_EXTRA_CA_CERTS environment variable to a
path to the certificate file instead, which works with fetch.
2026-03-04 17:43:32 +01:00
Hornwitser a106851cdc Enable pnpm shell emulator
Some of our scripts assume a POSIX sh like shell to work, and writing
any sort midly complex script that works with both cmd and sh is next
to impossible.

Fortunately pnpm has an option to emulate a sh like shell on Windows.
See https://pnpm.io/cli/run#shellemulator for more info.
2026-03-04 17:30:28 +01:00
Cooldude2606 cdfa9860bb Fix IPC type 2026-03-03 20:30:18 +00:00
Hornwitser 9877946d68 Split up commands into separate modules (#844)
Ctl's commands.ts was starting to grow rather big, and future features
and refactors are only going to make it bigger.  Split it up into
separate modules grouped by which category of commands it serves.
2026-03-03 01:18:09 +01:00
Cooldude2606 3e7b65264e Invert notifications array to prevent loss of new notifications 2026-03-02 17:58:45 +00:00
Cooldude2606 54557a79c5 Do not add empty array for recipe notifications 2026-03-02 16:39:20 +00:00
Cooldude2606 412c291193 Add recipe notification sync to inventory sync 2026-03-01 23:58:57 +00:00
HornwitserandHornwitser 94692dc418 Fix tests failing due broken Factorio detection (#839)
* Fix tests failing due to missmatched Factorio detection

When the configured factorio directory is full of junk then the tests
use the first directory it finds for one of its tests, while the server
code uses a more elaborate logic to figure this out.

Turns out this is complicating things more than needed. Simply trying
each entry in the directory and seeing if it works when treated as a
directory always work, regardsless of symlinks, junctions,
reparse-points or whatever else filesystem magic there is.

* Skip tests requiring Factorio if it's not present

Prevent confusing test failures by skipping all the tests that require a
working Factorio installation to sucteed, and print a warning at the end
of the test suite if tests may have been skipped due to this.

* Handle ENOTDIR when determining Factorio version

If the path to the factorio install contains a component that is not a
file then ENOTDIR may be returned on POSIX like systems.  Treat this the
same as ENOENT so that detection logic works when used on a file.

---------

Co-authored-by: Hornwitser <code@hornwitser.no>
2026-03-01 23:06:03 +00:00
Ryan WalkerandSolar 6733aed3fb Extended export data to cover more icon types (#838)
* Enhance export functionality and add icon reference page

- Expanded exported assets to include additional spritesheets and metadata for recipes, signals, technologies, planets, qualities, and entities.
- added logging metrics to game logs
- Introduced a new Icon Reference page to display available icons with filtering capabilities.
- Updated ExportManifest documentation to reflect new asset categories.

* Add support for exporting static icons and update metadata handling

* -added data export snapshot metrics

* Fix HTML attribute syntax in IconReferencePage component

* Applied PR sugestions:
-removed un needed x === "string"s
-removed isntancename / modpack from icon reference and export
-logging per line on export
-fixed variable names
-linting

* linted

* Shows  "no export data available" when no export happens

* -single sprite sheet
-removed static icons with no prototype

* Fix formatting

* Rename types to allowedTypes  for clarity

* -removed duplication check (redundant)
-fixed bug overwriting metadata with same name from previous categories

* Rename asset keys for consistency in export validation  test

---------

Co-authored-by: Solar <solar@homelab.local>
2026-03-01 23:05:39 +00:00
Cooldude2606andHornwitser 2235294c59 Refactor user and instance manager (#837)
* Refactor UserManager to use Datastore

* Rename lib.User to UserDetails also adds to/fromUserDetails

* Fix existing tests

* Add user class tests

* Add tests for finaliseUsers

* Move HostDetails to own file with tests

* Move InstanceDetails to its own file with tests

* Rename HostInfo and add tests

* Rename InstanceInfo and add tests

- Also changes the names of the host properties related to instances
- Also removes the need to specify the location of InstanceInfo

* Make json round trip test validate schema

* Add InstanceManager

* Fix lint errors

* Extra tests

* Add final test coverage

* Add one last test to InstanceManager

* Response to comments

* Use generator rather than return anonumous generator

Co-authored-by: Hornwitser <github@hornwitser.no>

* Fix lint issue with generator

---------

Co-authored-by: Hornwitser <github@hornwitser.no>
2026-03-01 23:04:37 +00:00
Daniel 5bea930d05 Merge pull request #836 from Cooldude2606/fix/singleplayer-inv-sync
Don't attempt to inc sync in single player
2026-02-26 10:51:53 +01:00
Daniel 385affbcaf Merge pull request #831 from Cooldude2606/feature/automatic-factorio-download
Add automatic headless download and update on linux
2026-02-19 21:46:35 +01:00
Daniel c5c6759868 Merge pull request #832 from Cooldude2606/refactor/move-ensure-role
Move ensure default roles onto Role class
2026-02-19 21:45:45 +01:00
Cooldude2606 02c407ef9f Don't attempt to inc sync in single player 2026-02-10 21:54:28 +00:00
Daniel dd5e3b876e Merge pull request #834 from Cooldude2606/fix/mod-dep-parser
Fix mod dependency parser breaking with inconsistent spaces
2026-02-10 16:34:10 +01:00
Cooldude2606 acf711fbb0 Refactor how dep resolution errors are displayed 2026-02-09 21:35:54 +00:00
Cooldude2606 aeb0c3f5f7 Fix mod dep parser breaking with spaces 2026-02-08 22:56:51 +00:00
Cooldude2606 0a5d0c374b Response to comments
- Increase timeout on download test
- Fix wrong function being called
2026-02-08 21:44:04 +00:00
Cooldude2606 e9974c0f21 Fix whitespace and test names 2026-02-07 01:34:22 +00:00
Cooldude2606 1228baa9a3 Move ensure default roles onto Role class 2026-02-07 01:27:59 +00:00
Cooldude2606 098aceaf10 Forgot to restore fetch in live test 2026-02-05 16:34:43 +00:00
Cooldude2606 fd43a0d238 Fix two tests 2026-02-05 16:26:17 +00:00
Cooldude2606 dc3205148e Add missing tests 2026-02-05 16:17:34 +00:00
Cooldude2606 372c1bffe1 Add more tests and missing return 2026-02-05 15:43:28 +00:00
Cooldude2606 b4338a209c Readd support for direct installs 2026-02-05 14:30:48 +00:00
Cooldude2606 4cbc30f627 Fix tar expecting gz when xz was provided 2026-02-05 13:16:03 +00:00
Cooldude2606 44abeace88 Add automatic headless download and update 2026-02-05 00:45:53 +00:00
Cooldude2606 1c7a32113a Remove single dir factorio install 2026-02-04 22:16:56 +00:00
Daniel 68a3c11d41 Merge pull request #830 from Cooldude2606/webui/better-host-tokens
Improve the generate host token modal
2026-02-03 20:56:40 +01:00
Cooldude2606 c0fd21a665 Improve the generate host token modal 2026-02-02 22:17:52 +00:00
Daniel 12b52a854e Merge pull request #827 from Cooldude2606/feature/startedAtMs
Feature/started at ms
2026-02-02 22:26:17 +01:00
Cooldude2606 ccd5232e70 Fix system info migration logic 2026-02-01 22:35:32 +00:00
Cooldude2606 567a27f0c4 Fix migration tests 2026-02-01 22:23:00 +00:00
Cooldude2606 b019c37d3d Change title on instance start time 2026-02-01 22:11:38 +00:00
Cooldude2606 dec3ad9430 Move and add icons to status tags 2026-02-01 22:07:23 +00:00
Cooldude2606 9729743a29 Add startedAtMs to all processes 2026-02-01 21:30:00 +00:00
Cooldude2606 a4e4621eaa Git ignore config lock files 2026-01-30 21:22:36 +00:00
Cooldude2606 4337555ea1 Fix gitignore "factorio" rule 2026-01-30 20:54:59 +00:00
CopilotandDanielv123 9018397b09 Fix logistics data on servers without logistic researched (#809)
* Initial plan

* Add logistics technology validation to prevent load failures

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2026-01-28 15:03:47 +00:00
Danielv123 1c9aae5909 Fix crash in logging 2026-01-27 15:59:35 +01:00
Daniel 6489fa4048 Merge pull request #823 from Cooldude2606/fix/update-plugin-ts-config
Updates package jsons and tsconfigs to have catalogs and correct peer deps
2026-01-25 20:10:14 +01:00
Daniel 22850b7cd0 Merge pull request #815 from Cooldude2606/feature/database-lock
Add extra startup warnings to CLI: User count and Lock file
2026-01-25 20:08:36 +01:00
Daniel f65c8fb9a0 Merge pull request #816 from Cooldude2606/fix/download-headless
Fix headless download
2026-01-25 20:08:18 +01:00
Daniel 3232da71e2 Merge pull request #817 from Cooldude2606/fix/disable-achivement
Fix achievements not being disabled when save patching disabled
2026-01-25 20:08:05 +01:00
Daniel c9fc4f1c8f Merge pull request #821 from Cooldude2606/feature/version-selector
Add drop down version selector based on Wube version archive
2026-01-25 20:07:38 +01:00
Cooldude2606 0c56c56f74 Fixed duplicate pnpm version spec 2026-01-25 00:26:53 +00:00
Cooldude2606 2928b9b7c5 Bump node and pnpm version 2026-01-25 00:23:09 +00:00
Cooldude2606 1fc6971278 Switch to catalogs 2026-01-25 00:02:20 +00:00
Cooldude2606 2679851d66 Update plugin template 2026-01-24 22:54:59 +00:00
Cooldude2606 ab86cd2b1c Update package json and tsconfig 2026-01-24 22:47:40 +00:00
Cooldude2606 02fce63e11 Fix git ignore 2026-01-24 21:03:57 +00:00
Cooldude2606 8981a018b0 Fix lint issue 2026-01-24 18:33:28 +00:00
Cooldude2606 bbf5df6d01 Add remaining tests 2026-01-24 17:58:36 +00:00
Cooldude2606 6f68cf74a7 Add network events and switch to tree select 2026-01-24 16:27:28 +00:00
Cooldude2606 07f501a67a Add ValueCache and fetchFactorioVersions 2026-01-23 01:50:20 +00:00
Daniel 4caf06aafc Merge pull request #819 from Cooldude2606/feature/login-screen-name
Display cluster name on login screen
2026-01-22 15:45:00 +01:00
Cooldude2606 4901d76ebe Refactor version inputs 2026-01-22 00:37:34 +00:00
Cooldude2606 6b0dd4e9cf Add tests for new api route 2026-01-21 23:47:47 +00:00
Cooldude2606 a2c38ad4e8 Add cluster name to login form 2026-01-21 23:42:53 +00:00
Cooldude2606 438ccbdc7f Fix achievements not being disabled 2026-01-14 21:24:03 +00:00
Cooldude2606 d6c5430e0c Fix headless download 2026-01-11 17:05:21 +00:00
Cooldude2606 d48b731ea4 Add lockfile bypass 2026-01-08 22:48:00 +00:00
Cooldude2606 bccb6527a7 Add lock file for configs 2026-01-07 01:20:40 +00:00
Cooldude2606 00cf0f59ec Refuse to start controller with no users 2026-01-07 00:21:56 +00:00
Double 3a823bee01 Update link to message passing protocol specification (#813) 2026-01-01 19:23:54 +00:00
Cooldude2606 98650b9bfb 2.0.0-alpha.22b (plugins)
Due to the bump of lib version, all plugins must also be bumped to 22b
2025-11-30 22:07:37 +00:00
Daniel a781af5b6a Merge pull request #811 from clusterio/copilot/fix-add-mods-button-visibility
Fix button overflow on narrow screens across web interface
2025-11-22 23:21:55 +01:00
copilot-swe-agent[bot]andDanielv123 f202a0975a Add wrap to more Space components and fix table scroll on narrow screens
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 10:55:42 +00:00
copilot-swe-agent[bot]andDanielv123 ea4280d184 Add wrap prop to Space components in SectionHeader for mobile responsiveness
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 07:52:22 +00:00
copilot-swe-agent[bot] c519ee1389 Initial plan 2025-11-20 07:45:31 +00:00
CopilotandDanielv123 c61b9dbe60 Remove debug log statement from serialize.lua (#808)
* Initial plan

* Remove debug log statement from serialize.lua

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-11 12:21:07 +01:00
Cooldude2606 77f4aa6296 2.0.0-alpha.22b 2025-11-07 21:55:35 +00:00
Cooldude2606 86a9084d56 Fix package updater 2025-11-07 21:48:09 +00:00
Cooldude2606 43def44136 Fix package path for restart required 2025-11-07 21:47:48 +00:00
Cooldude2606 8c1be7210e Skip git checks on npm publish 2025-11-07 20:08:22 +00:00
Cooldude2606 24e290746d 2.0.0-alpha.22 2025-11-07 19:48:21 +00:00
Cooldude2606 d52931a326 Better handling of mod dependencies (#801)
* Add ModDependency and version types

* Add warnings for mod dependencies

* Add warning for wrong factorio version

* Add fix issues button

* Add automatic dependency resolution and download

* Add abilty to check for updates

* Add extra test coverage

* One extra test

* Fix copy paste error
2025-11-07 19:22:17 +00:00
Cooldude2606 73cc019d2f Sets restart required when a plugin is updated (#794)
* Template type for load plugin config

* Add plugin update checking

* Add restart icon to instance list

* Add tests

* Log package name when failing to read package json
2025-11-01 16:11:11 +00:00
Cooldude2606 41151d47f0 Add publish workflow (#798)
* Change build-mod to belong to packages

* Add publish script and workflow

* Fix typo in variable name
2025-11-01 16:10:41 +00:00
Cooldude2606 3fbd9a995d Add better plugin index missing error (#799)
* Add better plugin load errors

* Remove check for permission

* Account for packages using different main file
2025-11-01 16:10:24 +00:00
Daniel af299bc08d Merge pull request #800 from Cooldude2606/fix/template-ignore
Fix plugin template npm and git ignore
2025-10-30 13:54:29 +01:00
Cooldude2606 732cee4837 Fix plugin template npm and git ignore 2025-09-19 23:45:31 +01:00
208 changed files with 14167 additions and 4685 deletions

No files matched your search

+8 -8
View File
@@ -20,8 +20,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@@ -42,17 +40,21 @@ jobs:
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -61,11 +63,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
+48
View File
@@ -0,0 +1,48 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+3 -8
View File
@@ -29,18 +29,13 @@ dist/
node_modules
Factorio*
/factorio
!/test/file/factorio
!/test/lib/factorio
!/packages/lib/src/factorio
/factorio*
/instances
/mods
/database
/plugin-list.json
/config-controller.json
/config-control.json
/config-host.json
/config-*.json
/config-*.json.lock
pnpm-lock.yaml
mod_cache.json
/static/
+105
View File
@@ -31,6 +31,111 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.23
### Major Features
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
### Features
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce separate spritesheets for recipes, signals, technologies, planets, qualities, entities, and static UI icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifing a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and modpacks can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where muliple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#XXX](https://github.com/clusterio/clusterio/pull/XXX)
- Fix that the documentation had a dead link to details of the message passing protocol (#812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#1234](https://github.com/clusterio/clusterio/issues/1234)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
### Breaking Changes
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Daniel](https://github.com/Danielv123)
[@Double](https://github.com/DoubleThoughtTheProgrammer)
[@Hornwitser](https://github.com/Hornwitser)
[@Ryan Walker](https://github.com/solarcloud7)
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
+1 -1
View File
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation start up the controller and at least one host separately.
After completing the installation and creating your admin user, 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.
+4 -4
View File
@@ -86,8 +86,8 @@ async function removeExecutionArtifacts() {
async function main() {
await yargs(process.argv.slice(2))
.scriptName("clean")
.usage("$0 <command> [options]")
.option("dry", {
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not delete any files",
default: false,
@@ -104,11 +104,11 @@ async function main() {
removeBuildArtifacts();
removeTestArtifacts();
})
.command("fast", "Remove all build artifacts", () => {}, argv => {
.command("$0", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "You need to specify a command to run")
.demandCommand(1, "A command must be specified")
.strict()
.parse();
}
-16
View File
@@ -182,14 +182,6 @@ You can generate an access token with `clusterioctl host generate-token --id <ho
Defaults to "enter token here".
### host.tls_ca
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.
### host.public_address
External address instances hosted on this host can be accessed on.
@@ -413,14 +405,6 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
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 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 controller after the connection is dropped.
+1 -1
View File
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
## Communicating with Clusterio
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [socket.md](socket.md) for the implementation details of it.
See [protocol.md](devs/protocol.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
+19 -4
View File
@@ -63,10 +63,25 @@ This creates a private key file named `key.pem` and a public certificate file na
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 hosts and configure it as the `host.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 node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
npx clusteriohost config set host.tls_ca cert.pem
**For run-host.cmd**
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:
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
npx clusterioctl control-config set control.tls_ca cert.pem
**For run-host.sh**
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
**For clusteriohost.service**
Environment=NODE_EXTRA_CA_CERTS=path/to/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 set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
# On Windows
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
# On Linux
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
# You can now use clusterioctl
npx clusterioctl instance list
+5 -7
View File
@@ -2,11 +2,12 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
@@ -14,9 +15,7 @@
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"clean": "node ./clean.js",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -27,7 +26,7 @@
]
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"workspaces": [
"packages/*",
@@ -57,7 +56,6 @@
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"phin": "^3.7.1",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
+44 -16
View File
@@ -32,6 +32,7 @@ const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
// globals
@@ -127,35 +128,45 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
): Promise<void> {
let subCommand = args._[1];
const databaseDirectory = controllerConfig.get("controller.database_directory");
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
const userManager = new UserManager(controllerConfig, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
).bootstrap());
const userManager = new UserManager(users, roles, controllerConfig);
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
let admin = userManager.getByName(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
const user = userManager.getOrCreateUser(args.name);
user.roleIds.add(lib.Role.DefaultAdminRoleId);
user.isAdmin = true;
user.saveRecord();
let adminRole = lib.ensureDefaultAdminRole(roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await userManager.records.save();
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
let user = userManager.getByName(args.name);
@@ -251,6 +262,7 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
@@ -281,6 +293,7 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -350,8 +363,14 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let controllerConfig;
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
@@ -377,10 +396,11 @@ async function initialize(): Promise<InitializeParameters> {
}
if (command === "config") {
await lib.handleConfigCommand(args, controllerConfig);
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
return {
@@ -417,6 +437,14 @@ async function startup() {
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.users.records.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
@@ -462,7 +490,7 @@ I version of clusterio. Expect things to break. I
`
);
startup().catch(err => {
if (err instanceof lib.StartupError) {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
+5 -3
View File
@@ -1,13 +1,15 @@
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 HostInfo } from "./src/HostInfo";
export { default as InstanceRecord } from "./src/InstanceRecord";
export { default as InstanceManager } from "./src/InstanceManager";
export { default as HostRecord } from "./src/HostRecord";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as UserRecord } from "./src/UserRecord";
export { default as User } from "./src/User";
export { default as WsServer } from "./src/WsServer";
if (module === require.main) {
+29 -29
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -17,45 +17,45 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"express": "^4.21.2",
"finalhandler": "^1.3.1",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.13.3",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.14",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.29.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
"@types/compression": "^1.7.2",
"@types/busboy": "^1.5.0",
"@swc/core": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
+6 -1
View File
@@ -32,6 +32,7 @@ export default class BaseConnection extends lib.Link {
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.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -78,7 +79,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: string, sha1?: string }) {
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -106,4 +107,8 @@ export default class BaseConnection extends lib.Link {
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
@@ -3,7 +3,7 @@ import type {
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceInfo from "./InstanceInfo";
import type InstanceRecord from "./InstanceRecord";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
@@ -91,7 +91,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -117,7 +117,7 @@ export default class BaseControllerPlugin {
* @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) { }
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -261,7 +261,7 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
+245 -100
View File
@@ -6,7 +6,8 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -40,7 +41,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: ControllerUser, // The user making this connection.
public user: User, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -100,6 +101,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.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));
@@ -124,7 +127,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -248,7 +250,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
@@ -266,20 +268,20 @@ export default class ControlConnection extends BaseConnection {
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instanceCreate(instanceConfig);
await this._controller.instances.createInstance(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instanceDelete(request.instanceId);
await this._controller.instances.deleteInstance(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
@@ -294,14 +296,14 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(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);
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
}
async handleInstanceSaveDetailsListRequest() {
@@ -335,8 +337,8 @@ export default class ControlConnection extends BaseConnection {
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 sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
@@ -564,52 +566,205 @@ export default class ControlConnection extends BaseConnection {
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
const { name, version, factorioVersion } = request;
const filename = lib.ModInfo.filename(name, version);
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
const factorioVersion = request.factorioVersion;
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
// Check if mod already exists
if (this._controller.modStore.files.has(filename)) {
logger.verbose(`Mod ${filename} already exists, skipping download.`);
return this._controller.modStore.files.get(filename)!;
}
if (toDownload.length > 0) {
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || !token) {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
const modMap = new Map([[name, version]]);
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
// Verify download and return ModInfo
const downloadedMod = this._controller.modStore.files.get(filename);
if (!downloadedMod) {
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
if (!username || username === "" || !token || token === "") {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
logger.info(`Successfully downloaded mod ${filename}.`);
return downloadedMod;
} catch (error: any) {
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = `Mod portal download failed for ${name} v${version}`;
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
try {
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
} catch (error: any) {
logger.error(`Error downloading mods from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = "Mod portal download failed";
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
// Verify all mods are present
const modInfos = [];
const installedMods = [...this._controller.modStore.mods()];
for (const mod of request.mods) {
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
if (!modInfo) {
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
}
modInfos.push(modInfo);
}
return modInfos;
}
// eslint-disable-next-line complexity
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const skipSearch = new Set(builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
let releases = _modPortalReleases.get(modName);
if (releases === undefined) {
try {
releases = await lib.ModStore.fetchModReleases(modName);
} catch (error: any) {
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
logger.error(`Error fetching mod from portal: ${error.message}`);
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
} else {
releases = null;
}
}
}
_modPortalReleases.set(modName, releases);
return releases;
}
function selectModRelease(releases: ModPortalModType | null, versionRange: lib.ModVersionRange) {
if (!releases || !releases.releases) {
return null;
}
// Select the latest matching version
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
return null;
}
// Create the mod info class from the selected
try {
return lib.ModInfo.fromJSON({
...release.info_json, // includes dependencies
sha1: release.sha1,
version: release.version,
name: releases.name,
author: releases.owner,
title: releases.title,
});
} catch {
// Can error if there are invalid dependencies
errors.set(releases.name, "invalidDependency");
return null;
}
}
while (searchQueue.length > 0) {
// Dequeue the next mod, skip if incompatible
const mod = searchQueue.shift()!;
if (mod.incompatible) {
errors.set(mod.name, "incompatible");
continue;
}
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
}
// Update the version range if needed
if (mod.version) {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
}
}
// Do not add dependencies for errors, skips, optionals, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (errors.has(mod.name) || skipSearch.has(mod.name) || optionalRequirements.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
}
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const releases = await fetchModReleases(mod.name);
const release = selectModRelease(releases, versionRange);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || release.integerVersion > candidate.integerVersion)) {
candidate = release;
}
}
// Queue the dependencies of the new candidate
if (candidate) {
candidateReleases.set(mod.name, candidate);
for (const dep of candidate.dependencies) {
searchQueue.push(dep);
}
} else {
errorMessage += `: ${error.message}`;
candidateReleases.delete(mod.name);
skipSearch.add(mod.name);
}
throw new lib.RequestError(errorMessage);
}
// Add the not found dependencies as errors
for (const modName of dependencyRequirements.keys()) {
if (!candidateReleases.has(modName) && !errors.has(modName)) {
errors.set(modName, "notFound");
}
}
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()], errors,
);
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
@@ -715,35 +870,33 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.roles.delete(role);
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
for (const user of this._controller.users.values()) {
if (user.removeRole(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
let name = request.name;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user;
return user.toUserDetails();
}
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
async handleUserListRequest(): Promise<lib.UserDetails[]> {
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
this._controller.users.createUser(request.name);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.userManager.getByName(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -757,11 +910,10 @@ export default class ControlConnection extends BaseConnection {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.userManager.getByName(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -772,35 +924,33 @@ export default class ControlConnection extends BaseConnection {
}
}
user.roleIds = new Set(request.roles);
user.set("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.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this._controller.usersUpdated([user]);
user.set("isAdmin", admin);
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.getByName(name);
let user = this._controller.users.getByNameMutable(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
@@ -808,37 +958,34 @@ export default class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
this._controller.usersUpdated([user]);
user.saveRecord();
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.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
user.set("isWhitelisted", whitelisted);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByName(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]);
this._controller.users.deleteUser(user);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -851,14 +998,15 @@ export default class ControlConnection extends BaseConnection {
}
}
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
if (request.importType === "users") {
// Update all fields for all users
const users = request.users as Extract<typeof request.users, { username: string }[]>;
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
@@ -879,7 +1027,7 @@ export default class ControlConnection extends BaseConnection {
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
@@ -891,7 +1039,7 @@ export default class ControlConnection extends BaseConnection {
const users = request.users as string[];
const expected = new Set(users.map(u => u.toLowerCase()));
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
@@ -900,11 +1048,9 @@ export default class ControlConnection extends BaseConnection {
}
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<ControllerUser["id"], ControllerUser>();
const updated = new Map<User["id"], User>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
@@ -912,11 +1058,12 @@ export default class ControlConnection extends BaseConnection {
}
// Will get a user or attempt to create one
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
const getUserOrCreate = (username: string) => {
const user = this._controller.userManager.getByName(username);
if (user) { return user; }
const user = this._controller.users.getByNameMutable(username);
if (user) { return updated.get(user.id) ?? user; }
this.user.checkPermission("core.user.create");
return this._controller.userManager.createUser(username);
return this._controller.users.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
@@ -974,14 +1121,14 @@ export default class ControlConnection extends BaseConnection {
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.usersUpdated(updatedUsers);
this._controller.users.records.setMany(updatedUsers);
// Resync the host user lists
const adminlist: Set<string> = new Set();
const banlist: Map<string, string> = new Map();
const whitelist: Set<string> = new Set();
for (let user of this._controller.userManager.users.values()) {
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -1005,7 +1152,7 @@ export default class ControlConnection extends BaseConnection {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
@@ -1031,7 +1178,7 @@ export default class ControlConnection extends BaseConnection {
} else if (request.exportType === "bans") {
// Bans have extra logic to handle ban reason
const usersToSend = [] as Array<string | { username: string, reason: string }>;
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.values()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
@@ -1046,7 +1193,7 @@ export default class ControlConnection extends BaseConnection {
// This is a whitelist or admins export
const usersToSend = [] as string[];
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
@@ -1058,7 +1205,7 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/controller");
return await lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1079,9 +1226,7 @@ export default class ControlConnection extends BaseConnection {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
) as boolean,
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
+143 -217
View File
@@ -18,16 +18,17 @@ import * as lib from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import HttpCloser from "./HttpCloser";
import InstanceInfo from "./InstanceInfo";
import InstanceRecord from "./InstanceRecord";
import * as metrics from "./metrics";
import * as routes from "./routes";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import HostRecord from "./HostRecord";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -79,6 +80,10 @@ export default class Controller {
/** Event subscription controller */
subscriptions = new lib.SubscriptionController();
/** User manager for the cluster, also contains access to the user records */
users: UserManager;
/** Instance manager for the cluster, also contains access to the user records */
instances: InstanceManager;
// Possible states are new, starting, running, stopping, stopped
private _state: string = "new";
@@ -96,6 +101,7 @@ export default class Controller {
devMiddleware: any | null = null;
startedAtMs = Date.now();
autosaveInterval?: ReturnType<typeof setInterval>;
systemMetricsInterval?: ReturnType<typeof setInterval>;
@@ -105,11 +111,26 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(async () => {
try {
return await lib.fetchFactorioVersions();
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio versions ${err.message}`);
return [];
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
@@ -118,13 +139,13 @@ export default class Controller {
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostInfo.fromJSON.bind(HostInfo),
HostRecord.fromJSON.bind(HostRecord),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
json => InstanceInfo.fromJSON(json, "controller"),
InstanceRecord.fromJSON.bind(InstanceRecord),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
@@ -135,7 +156,8 @@ export default class Controller {
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack)
lib.ModPack.fromJSON.bind(lib.ModPack),
this.migrateModPacks,
).bootstrap());
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
@@ -144,8 +166,11 @@ export default class Controller {
this.migrateRoles,
).bootstrap());
const userManager = new UserManager(config, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
undefined, this.finaliseUsers,
).bootstrap());
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
@@ -157,8 +182,8 @@ export default class Controller {
}
// Add default roles
lib.ensureDefaultAdminRole(roles);
lib.ensureDefaultPlayerRole(roles);
lib.Role.ensureDefaultAdminRole(roles);
lib.Role.ensureDefaultPlayerRole(roles);
return [
systems,
@@ -168,7 +193,7 @@ export default class Controller {
modPacks,
modStore,
roles,
userManager,
users,
] as const;
}
@@ -188,10 +213,10 @@ export default class Controller {
*/
public recoveryMode: boolean = false,
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** Mapping of host id to host info */
public hosts = new lib.SubscribableDatastore<HostInfo>(),
/** Mapping of instance id to instance info */
public instances = new lib.SubscribableDatastore<InstanceInfo>(),
/** Mapping of host id to host record */
public hosts = new lib.SubscribableDatastore<HostRecord>(),
/** Mapping of instance id to instance record */
instances = new lib.SubscribableDatastore<InstanceRecord>(),
/** Mapping of save id to save details */
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
/** Mapping of mod pack id to mod pack */
@@ -200,8 +225,8 @@ export default class Controller {
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
/** Mapping of mod pack id to mod pack */
public roles = new lib.SubscribableDatastore<lib.Role>(),
/** User and roles manager for the cluster */
public userManager = new UserManager(config, roles),
/** Mapping of user id to user record */
users = new lib.SubscribableDatastore<UserRecord>(),
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
@@ -214,6 +239,9 @@ export default class Controller {
this.trustedProxies = this.parseTrustedProxies();
this.wsServer = new WsServer(this);
this.users = new UserManager(users, roles, config);
this.instances = new InstanceManager(instances, this);
this.modStore.on("change", mod => {
this.modsUpdated([mod]);
});
@@ -233,10 +261,11 @@ export default class Controller {
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
this.roles.on("update", this.rolesUpdated.bind(this));
this.users.records.on("update", this.usersUpdated.bind(this));
}
async start(args: ControllerArgs) {
@@ -301,9 +330,6 @@ export default class Controller {
}
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
});
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
// Make sure we're actually going to listen on a port
let httpPort = this.config.get("controller.http_port");
@@ -394,6 +420,7 @@ export default class Controller {
this.onSystemMetricsIntervalChanged();
logger.info("Started controller");
this.startedAtMs = Date.now();
this._state = "running";
}
@@ -549,19 +576,8 @@ export default class Controller {
}
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("controller.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
}
requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired));
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -571,6 +587,46 @@ export default class Controller {
}
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/controller";
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -609,16 +665,13 @@ export default class Controller {
this.config.save(),
this.systems.save(),
this.hosts.save(),
this.instances.save(),
this.instances.records.save(),
this.saves.save(),
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
]);
if (this.userManager.dirty) {
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
}
await lib.invokeHook(this.plugins, "onSaveData");
}
@@ -631,11 +684,17 @@ export default class Controller {
if (!json.restartRequired) { // Added in 2.0.0.alpha.21
json.restartRequired = false;
}
if (json.systemStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.systemStartedAtMs = Date.now();
}
if (json.processStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.processStartedAtMs = Date.now();
}
return json;
});
}
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
static migrateHosts(rawJson: unknown[]): Static<typeof HostRecord.jsonSchema>[] {
let serialized = rawJson as any;
// New format 2.0.0.alpha.19
@@ -646,7 +705,7 @@ export default class Controller {
return serialized;
}
static finaliseHosts(host: HostInfo): HostInfo {
static finaliseHosts(host: HostRecord): HostRecord {
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
@@ -654,8 +713,8 @@ export default class Controller {
return host;
}
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceRecord.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceRecord.jsonSchema>[];
return serialized.map(json => {
if (!json.config) { // New format 2.0.0.alpha.14
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
@@ -664,7 +723,7 @@ export default class Controller {
});
}
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
static finaliseInstances(instance: InstanceRecord): InstanceRecord {
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
@@ -673,6 +732,17 @@ export default class Controller {
return instance;
}
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
}
return json;
});
}
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
return serialized.map(json => {
@@ -687,6 +757,20 @@ export default class Controller {
});
}
static finaliseUsers(user: UserRecord, users: Map<UserRecord["id"], UserRecord>): UserRecord {
const existingUser = users.get(user.id);
if (existingUser) {
// All lowercase ids in 2.0.0.alpha.19
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
// We assume the user with the lower online time is the duplicate
existingUser.merge(user);
return existingUser;
}
user.merge(existingUser);
}
return user;
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
let manifest = {};
try {
@@ -805,7 +889,7 @@ export default class Controller {
);
}
hostsUpdated(hosts: HostInfo[]) {
hostsUpdated(hosts: HostRecord[]) {
let updates = hosts.map(host => host.toHostDetails());
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
@@ -817,75 +901,6 @@ export default class Controller {
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
}
/**
* Get instance by ID for a request
*
* @param instanceId - ID of instance to get.
* @returns Info for the given instance if it exists
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId:number): InstanceInfo {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/**
* Create a new instance
*
* Adds common Factorio settings to the provided instance config then
* creates an instance using that config in the cluster.
*
* @example
* let instanceConfig = new lib.InstanceConfig("controller");
* instanceConfig.set("instance.name", "My instance");
* let instance = await controller.instanceAssign(instanceConfig);
* await controller.instanceAssign(instance.id, hostId);
*
* @param instanceConfig -
* Config to base newly created instance on.
* @returns The created instance
*/
async instanceCreate(instanceConfig: lib.InstanceConfig): Promise<InstanceInfo> {
let instanceId = instanceConfig.get("instance.id");
if (this.instances.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${this.config.get("controller.name")}`,
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
"max_upload_slots": 5,
"ignore_player_limit_for_returning_players": false,
"allow_commands": "admins-only",
"autosave_interval": 10,
"autosave_slots": 5,
"afk_autokick_interval": 0,
"auto_pause": false,
"only_admins_can_pause_the_game": true,
"autosave_only_on_server": true,
"non_blocking_saving": false,
...instanceConfig.get("factorio.settings"),
};
instanceConfig.set("factorio.settings", settings);
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
this.instances.set(instance);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
this.addInstanceHooks(instance);
return instance;
}
/**
* Removes all saves currently stored for the given instance, if any
* @param instanceId - Id of Instance to clear saves for.
@@ -895,84 +910,6 @@ export default class Controller {
this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId));
}
/**
* Change assigned host of an instance
*
* Unassigns instance from existing host if already assigned to one and
* then assigns it to the given host. This is the only supported way of
* changing the instance.assigned_host config entry.
*
* Note: this will not transfer any files or saves stored on the host
* the instance was previously assgined to the new one.
*
* @param instanceId - ID of Instance to assign.
* @param hostId - ID of host to assign instance to.
*/
async instanceAssign(instanceId: number, hostId?: number) {
let instance = this.getRequestInstance(instanceId);
// Check if target host is connected
let newHostConnection: HostConnection | undefined;
if (hostId !== undefined) {
newHostConnection = this.wsServer.hostConnections.get(hostId);
if (!newHostConnection) {
// The case of the host not getting the assign instance message
// still have to be handled, so it's not a requirement that the
// target host be connected to the controller while doing the
// assignment, but it is IMHO a better user experience if this
// is the case.
throw new lib.RequestError("Target host is not connected to the controller");
}
}
// Unassign from currently assigned host if it is connected.
let currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost !== null && hostId !== currentAssignedHost) {
let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId));
}
}
// Remove saves recorded from currently assigned host if any
this.clearSavesOfInstance(instanceId);
// Assign to target
instance.config.set("instance.assigned_host", hostId ?? null);
if (hostId !== undefined && newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host"))
);
} else {
instance.status = "unassigned";
}
this.instances.set(instance);
}
/**
* Delete an instance
*
* Permanently deletes the instance from its assigned host (if assigned)
* and the controller. This action cannot be undone.
*
* @param instanceId - ID of instance to delete.
*/
async instanceDelete(instanceId: number) {
let instance = this.getRequestInstance(instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId));
}
let prev = instance.status;
this.instances.delete(instance);
this.clearSavesOfInstance(instanceId);
this.userManager.clearStatsOfInstance(instanceId);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
}
/**
* Notify the config of an instance updated
*
@@ -982,7 +919,7 @@ export default class Controller {
* @param instance -
* Instance to nodify the config updated.
*/
async instanceConfigUpdated(instance: InstanceInfo) {
async instanceConfigUpdated(instance: InstanceRecord) {
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
let connection = this.wsServer.hostConnections.get(hostId);
@@ -994,16 +931,7 @@ export default class Controller {
}
}
addInstanceHooks(instance: InstanceInfo) {
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
instance.updatedAtMs = Date.now();
this.instances.set(instance); // Trigger update logic
lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev);
});
}
instanceDetailsUpdated(instances: InstanceInfo[]) {
instanceUpdated(instances: InstanceRecord[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
@@ -1055,17 +983,12 @@ export default class Controller {
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
}
usersUpdated(users: ControllerUser[]) {
const now = Date.now();
for (const user of users) {
user.updatedAtMs = now;
}
this.userManager.dirty = true;
usersUpdated(users: UserRecord[]) {
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
const users = [...this.userManager.users.values()].filter(
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
return users.length ? new lib.UserUpdatesEvent(users) : null;
@@ -1076,7 +999,7 @@ export default class Controller {
* permissions for this user may have changed.
* @param user - User permisions updated for.
*/
userPermissionsUpdated(user: ControllerUser) {
userPermissionsUpdated(user: Readonly<User>) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
@@ -1127,7 +1050,7 @@ export default class Controller {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
continue;
}
@@ -1152,6 +1075,9 @@ export default class Controller {
this.plugins.set(pluginInfo.name, controllerPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -1335,7 +1261,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
let instance = this.getRequestInstance(dst.id);
const instance = this.instances.getForRequest(dst.id);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1370,7 +1296,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
let instance = this.instances.get(dst.id);
const instance = this.instances.get(dst.id);
if (!instance) {
throw new Error(`Instance with ID ${dst.id} does not exist`);
}
@@ -1429,7 +1355,7 @@ export default class Controller {
}
async sendRequestToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>) {
let instance = this.getRequestInstance(request.instanceId);
let instance = this.instances.getForRequest(request.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1442,7 +1368,7 @@ export default class Controller {
}
sendEventToHostByInstanceId<T extends { instanceId: number }>(event: T & lib.Event<T>) {
let instance = this.getRequestInstance(event.instanceId);
let instance = this.instances.getForRequest(event.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
return;
-96
View File
@@ -1,96 +0,0 @@
import { Static, Type } from "@sinclair/typebox";
import {
IControllerUser, PermissionError, PlayerStats,
Role, SubscribableDatastore, User, permissions,
} from "@clusterio/lib";
export default class ControllerUser extends User implements IControllerUser {
constructor(
private _controllerRoles: SubscribableDatastore<Role>,
/** 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>, controllerRoles: SubscribableDatastore<Role>) {
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(
controllerRoles,
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._controllerRoles.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._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+42 -49
View File
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
/**
@@ -16,7 +16,7 @@ import HostInfo from "./HostInfo";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostInfo;
private info: HostRecord;
plugins: Map<string, string>;
constructor(
@@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostInfo(
this.info = new HostRecord(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
@@ -57,7 +57,7 @@ export default class HostConnection extends BaseConnection {
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
const instances: InstanceInfo[] = [];
const instances: InstanceRecord[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
@@ -68,7 +68,7 @@ export default class HostConnection extends BaseConnection {
instance.status = "unknown";
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instances.setMany(instances);
this._controller.instances.records.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
@@ -207,9 +207,14 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
// If the instance has transitioned to running then updated the started at time
if (request.status === "running" && prev === "starting") {
instance.startedAtMs = Date.now();
}
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.set(instance);
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -225,7 +230,7 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceInfo[] = [];
const instanceUpdates: InstanceRecord[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
@@ -246,6 +251,9 @@ export default class HostConnection extends BaseConnection {
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.factorioVersion = instanceData.factorioVersion;
if (instanceData.status === "running" && prev === "starting") {
controllerInstance.startedAtMs = Date.now();
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
@@ -256,31 +264,30 @@ export default class HostConnection extends BaseConnection {
}
instanceConfig.set("instance.assigned_host", this.id);
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
instanceUpdates.push(newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
this._controller.instances.setMany(instanceUpdates);
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
// 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()) {
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -329,21 +336,18 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
if (event.type === "join") {
this._controller.userManager.notifyJoin(user, instanceId);
user.notifyJoin(instanceId);
} else if (event.type === "leave") {
this._controller.userManager.notifyLeave(user, instanceId);
user.notifyLeave(instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
this._controller.usersUpdated([user]);
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
@@ -359,13 +363,9 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
user.isAdmin = event.admin;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
@@ -379,14 +379,11 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.isBanned = event.banned;
user.banReason = event.reason;
this._controller.usersUpdated([user]);
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
@@ -401,13 +398,9 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
user.isWhitelisted = event.whitelisted;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
@@ -1,10 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
/** Underlying data class for hosts on the controller */
export default class HostRecord {
constructor(
/** Id of this host */
public id: number,
@@ -33,12 +31,12 @@ export default class HostInfo {
"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(),
"connected": Type.Optional(Type.Boolean()),
"remote_address": Type.Optional(Type.String()),
"public_address": Type.Optional(Type.String()),
"token_valid_after": Type.Optional(Type.Number()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -56,19 +54,50 @@ export default class HostInfo {
);
}
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
toJSON() {
const json = {
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,
};
} as Static<typeof HostRecord.jsonSchema>;
if (this.connected !== false) {
json.connected = this.connected;
}
if (this.remoteAddress !== "") {
json.remote_address = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.public_address = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.token_valid_after = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.is_deleted = this.isDeleted;
}
return json;
}
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
return new HostRecord(
details.id,
details.name,
details.version,
plugins,
details.connected,
details.remoteAddress,
details.publicAddress,
details.tokenValidAfter,
details.updatedAtMs,
details.isDeleted,
);
}
toHostDetails() {
@@ -85,4 +114,3 @@ export default class HostInfo {
);
}
}
+274
View File
@@ -0,0 +1,274 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
tags: ["clusterio"],
max_players: 0,
visibility: { public: true, lan: true },
game_password: "",
require_user_verification: true,
max_upload_in_kilobytes_per_second: 0,
max_upload_slots: 5,
ignore_player_limit_for_returning_players: false,
allow_commands: "admins-only",
autosave_interval: 10,
autosave_slots: 5,
afk_autokick_interval: 0,
auto_pause: false,
only_admins_can_pause_the_game: true,
autosave_only_on_server: true,
non_blocking_saving: false,
} as const;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
) {
for (const instance of records.values()) {
this.addInstanceHooks(instance);
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: InstanceRecord["id"]): boolean {
return this.records.has(id);
}
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
return this.records.get(id);
}
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
return this.getMutable(id);
}
valuesMutable(): IterableIterator<InstanceRecord> {
return this.records.values();
}
values(): IterableIterator<Readonly<InstanceRecord>> {
return this.valuesMutable();
}
/**
* Get instance by ID for a request.
*
* Convenience wrapper around {@link getMutable} that throws
* if the instance does not exist.
*
* @param instanceId - ID of instance to get.
* @returns The instance record.
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getForRequest(instanceId: number): InstanceRecord {
const instance = this.records.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/* ------------------------------------------------------------------------- */
/* Internal Helpers */
/* ------------------------------------------------------------------------- */
private async _notifyStatusChanged(
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
}
private async _notifyConfigFieldChanged(
instance: InstanceRecord,
field: string,
curr: unknown,
prev: unknown,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
}
/**
* Attach change listeners to an instance record.
*
* Hooks into configuration field changes and ensures the datastore
* is updated and plugins are notified when a config field changes.
*
* @param instance - The instance record to attach hooks to.
* @internal
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
});
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Create a new instance.
*
* @example
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
* const instance = await controller.instances.createInstance(config);
*
* @param instanceConfig - Config to base the newly created instance on.
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
* @returns The created instance record.
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
*/
async createInstance(
instanceConfig: lib.InstanceConfig,
_suppressChanges?: boolean,
): Promise<InstanceRecord> {
const instanceId = instanceConfig.get("instance.id");
if (this.records.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
if (!_suppressChanges) {
this.records.set(instance);
await this._notifyStatusChanged(instance);
}
return instance;
}
/**
* Change the assigned host of an instance.
*
* Unassigns the instance from the currently assigned host (if connected)
* and assigns it to the provided host.
*
* This is the only supported way of modifying the
* `instance.assigned_host` configuration entry.
*
* Note:
* - This does NOT transfer any files or saves between hosts.
* - If the instance is already assigned to the specified host, no action is taken.
*
* @param instanceId - ID of instance to assign.
* @param hostId - ID of host to assign the instance to. If undefined,
* the instance will be unassigned.
*
* @throws {module:lib.RequestError} if the instance does not exist.
* @throws {module:lib.RequestError} if the target host is not connected.
*/
async assignInstance(
instanceId: number,
hostId: number | null,
): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostConnections = this._controller.wsServer.hostConnections;
const currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost === hostId) {
return;
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
newHostConnection = hostConnections.get(hostId);
if (!newHostConnection) {
throw new lib.RequestError("Target host is not connected to the controller");
}
}
if (currentAssignedHost !== null) {
const oldHostConnection = hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(
new lib.InstanceUnassignInternalRequest(instanceId),
);
}
}
this._controller.clearSavesOfInstance(instanceId);
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} else {
instance.status = "unassigned";
}
this.records.set(instance);
}
/**
* Unassign an instance from its current host.
*
* Convenience wrapper around {@link assignInstance} that removes
* the assigned host.
*
* @param instanceId - ID of instance to unassign.
*/
async unassignInstance(instanceId: number): Promise<void> {
return this.assignInstance(instanceId, null);
}
/**
* Delete an instance permanently.
*
* Sends a delete request to the assigned host (if any),
* removes the instance from the datastore,
* clears associated saves and user statistics.
*
* @param instanceId - ID of instance to delete.
*
* @throws {module:lib.RequestError} if the instance does not exist.
*/
async deleteInstance(instanceId: number): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this._controller.sendTo(
{ hostId },
new lib.InstanceDeleteInternalRequest(instanceId),
);
}
const prevStatus = instance.status;
this.records.delete(instance);
this._controller.clearSavesOfInstance(instanceId);
this._controller.users.clearStatsOfInstance(instanceId);
await this._notifyStatusChanged(instance, prevStatus);
}
}
@@ -1,52 +1,22 @@
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 {
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: lib.TargetVersion,
public startedAtMs = 0,
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()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
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.factorioVersion,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
get id(): number {
return this.config.get("instance.id");
}
get isDeleted() {
@@ -60,8 +30,70 @@ export default class InstanceInfo {
this.status = "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
);
}
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
const json: any = {
config: this.config.toJSON(),
status: this.status,
};
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
return json;
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
}
}
+146
View File
@@ -0,0 +1,146 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord";
import { Static } from "@sinclair/typebox";
/**
* Represents a user as viewed from the controller.
*
* All methods are safe to use when readonly.
* Only use mutable when needing to change multiple values.
*/
export default class User extends UserRecord implements IUser {
constructor(
private _controllerUsers: SubscribableDatastore<UserRecord>,
private _controllerRoles: SubscribableDatastore<Role>,
...args: ConstructorParameters<typeof UserRecord>
) {
super(...args);
}
static fromJSON(
json: Static<typeof this.jsonSchema>,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
) {
return this.fromUserRecord(
UserRecord.fromJSON(json),
_controllerUsers,
_controllerRoles,
);
}
static fromUserRecord(
userRecord: UserRecord,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
): User {
return new this(
_controllerUsers,
_controllerRoles,
userRecord.tokenValidAfter,
userRecord.name,
userRecord.roleIds,
userRecord.instances,
userRecord.isAdmin,
userRecord.isBanned,
userRecord.isWhitelisted,
userRecord.banReason,
userRecord.updatedAtMs,
userRecord.isDeleted,
userRecord.instanceStats,
);
}
/** Roles this user has */
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/** Save the underlying user record */
saveRecord() {
this._controllerUsers.set(this);
}
/** Set a property on the underlying record, then save it. */
set<K extends keyof this>(key: K, value: this[K]) {
this[key] = value;
this.saveRecord();
}
/**
* 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);
this.saveRecord();
}
/** Add a role to this user */
addRole(roleId: number) {
const role = this._controllerRoles.get(roleId);
if (!role) {
throw new Error(`role ${roleId} does not exist`);
}
this.roleIds.add(roleId);
this.saveRecord();
}
/** Remove a role from user */
removeRole(roleId: number) {
if (!this.roleIds.delete(roleId)) {
return false;
}
this.saveRecord();
return true;
}
/** Set a user as being online on a specific instance */
notifyJoin(instanceId: number) {
this.instances.add(instanceId);
this.saveRecord();
}
/** Set a user as being offline on a specific instance */
notifyLeave(instanceId: number) {
this.instances.delete(instanceId);
this.saveRecord();
}
/** Clear the stats of a player on a specific instance */
clearInstanceStats(instanceId: number) {
this.instances.delete(instanceId);
this.instanceStats.delete(instanceId);
this.recalculatePlayerStats();
this.saveRecord();
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (const roleId of this.roleIds) {
const role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+105 -75
View File
@@ -3,65 +3,45 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
/**
* Manages users and roles
* Access layer for users on the controller.
*
* In performance critical paths the raw records can be accessed directly.
* Otherwise this class will wrap data in a view class with a set and save method.
*
* @alias module:controller/src/UserManager
*/
export default class UserManager {
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
) {
}
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
/**
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
* This migration separates them so they can be loaded by datastore.
*
* @param usersFilePath The src and dst file path for the users json file
* @param rolesFilePath The dst for the roles json file
*/
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
await this._roles.save(); // Force a save to prevent data loss
content = content.users;
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
}
let duplicates = 0;
for (const serializedUser of content) {
const user = ControllerUser.fromJSON(serializedUser, this._roles);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
duplicates += 1;
// We assume the user with the lower online time is the duplicate
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
existingUser.merge(user);
continue; // Skip users.set
}
user.merge(existingUser);
}
this.users.set(user.id, user);
}
if (duplicates) {
const backupPath = `${filePath}.${Date.now()}.bak`;
lib.logger.warn(
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
);
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
@@ -69,66 +49,116 @@ export default class UserManager {
}
}
async save(filePath: string): Promise<void> {
this.dirty = false;
await lib.safeOutputFile(filePath,
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: UserRecord["id"]) {
return this.records.has(id);
}
getMutable(id: UserRecord["id"]) {
const userRecord = this.records.getMutable(id);
if (!userRecord) {
return undefined;
}
return User.fromUserRecord(
userRecord,
this.records,
this._controllerRoles,
);
}
get(id: UserRecord["id"]): Readonly<User> | undefined {
return this.getMutable(id);
}
hasName(name: UserRecord["name"]) {
return this.has(name.toLowerCase());
}
getByNameMutable(name: UserRecord["name"]) {
return this.getMutable(name.toLowerCase());
}
getByName(name: UserRecord["name"]) {
return this.get(name.toLowerCase());
}
* valuesMutable(): IterableIterator<User> {
const roles = this._controllerRoles;
const users = this.records;
for (const record of users.values()) {
yield User.fromUserRecord(record, users, roles);
}
}
values(): IterableIterator<Readonly<User>> {
return this.valuesMutable();
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* 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 {
createUser(name: string): User {
if (this.getByName(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)) {
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
return user;
}
/** Get or create a user with a given name. */
getOrCreateUser(name: string): User {
return this.getByNameMutable(name) ?? this.createUser(name);
}
/**
* Deletes an existing user
*
* @param user - user to delete
*/
deleteUser(user: UserRecord) {
return this.records.delete(user);
}
/* ------------------------------------------------------------------------- */
/* Helpers */
/* ------------------------------------------------------------------------- */
/**
* Sign access token for the given user
*
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(user: ControllerUser): string {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: user.id },
Buffer.from(this._config.get("controller.auth_secret"), "base64")
Buffer.from(this._controllerConfig.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);
}
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
}
this.dirty = true;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { UserDetails } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/** Underlying data class for users on the controller */
export default class UserRecord extends UserDetails {
constructor(
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof UserDetails>
) {
super(...args);
}
static jsonSchema = Type.Object({
token_valid_after: Type.Optional(Type.Number()),
...UserDetails.jsonSchema.properties,
});
// _a and _b are a ts workaround for allowing User to have extra arguments
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
return this.fromUserDetails(
UserDetails.fromJSON(json),
json.token_valid_after,
);
}
toJSON() {
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
if (this.tokenValidAfter > 0) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
static fromUserDetails(
userDetails: UserDetails,
tokenValidAfter = 0,
) {
return new this(
tokenValidAfter,
userDetails.name,
userDetails.roleIds,
userDetails.instances,
userDetails.isAdmin,
userDetails.isBanned,
userDetails.isWhitelisted,
userDetails.banReason,
userDetails.updatedAtMs,
userDetails.isDeleted,
userDetails.instanceStats,
);
}
toUserDetails() {
return new UserDetails(
this.name,
this.roleIds,
this.instances,
this.isAdmin,
this.isBanned,
this.isWhitelisted,
this.banReason,
this.updatedAtMs,
this.isDeleted,
this.instanceStats,
);
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: UserDetails) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
// Player stats must be recalculated after modifying instance stats
this.recalculatePlayerStats();
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ ${err.stack}`
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.userManager.getByName(tokenPayload.user);
user = this.controller.users.getByNameMutable(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
+13 -7
View File
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
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;
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// 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.
@@ -136,6 +134,12 @@ function getPlugins(req: Request, res: Response) {
res.send(plugins);
}
function getClusterName(req: Request, res: Response) {
const controller: Controller = req.app.locals.controller;
const clusterName = controller.config.get("controller.name");
res.json({ name: clusterName });
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
@@ -185,7 +189,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -236,9 +240,9 @@ async function uploadExport(req: Request, res: Response) {
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
@@ -259,7 +263,7 @@ async function uploadExport(req: Request, res: Response) {
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacks.set(modPack);
@@ -664,6 +668,7 @@ async function uploadMod(req: Request, res: Response) {
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.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
@@ -696,5 +701,6 @@ export const webRoutes = [
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+21 -12
View File
@@ -335,7 +335,7 @@ async function migrateRename(args) {
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode);
await writeScripts(mode, "");
}
logger.info(
@@ -420,7 +420,7 @@ async function groupIdToName(gid) {
}
}
async function writeScripts(mode) {
async function writeScripts(mode, selfSignedCert) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
@@ -474,12 +474,13 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
:restart
${extraCaCertsLine}:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
@@ -487,18 +488,20 @@ goto restart
`
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
while true; do
${extraCaCertsLine}while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
"systemd/clusteriohost.service",
`\
@@ -512,7 +515,7 @@ WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -754,8 +757,8 @@ async function inquirerMissingArgs(args) {
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
@@ -772,10 +775,7 @@ async function downloadLinuxServer() {
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await fetch(url.href);
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
@@ -794,8 +794,10 @@ async function downloadLinuxServer() {
throw e;
}
await fs.unlink(archivePath);
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
@@ -821,6 +823,10 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -836,6 +842,9 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("self-signed-cert", {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
@@ -944,7 +953,7 @@ async function main() {
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode);
await writeScripts(answers.mode, args.selfSignedCert);
}
if (answers.mode === "ctl") {
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21c",
"version": "2.0.0-alpha.23",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,11 +13,11 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.3.0",
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+2 -2
View File
@@ -94,8 +94,8 @@ async function copyPluginTemplates(pluginName, templates) {
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
@@ -15,6 +15,18 @@
"node": "__node_version__"
},
"peerDependencies": {
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"devDependencies": {
@@ -36,7 +48,18 @@
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
//%endif
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -37,7 +37,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
//%if instance
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
@@ -50,7 +50,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
+12 -16
View File
@@ -27,8 +27,8 @@ import BaseCtlPlugin from "./src/BaseCtlPlugin";
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
constructor(url: string, maxReconnectDelay: number, token: string) {
super(url, maxReconnectDelay);
this._token = token;
}
@@ -54,8 +54,6 @@ class ControlConnector extends lib.WebSocketClientConnector {
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. */
@@ -64,12 +62,10 @@ export class Control extends lib.Link {
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));
@@ -189,6 +185,7 @@ export async function initialize(
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
@@ -234,8 +231,14 @@ export async function initialize(
.parse(argv) as CtlArguments
;
let controlConfig;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
@@ -257,7 +260,7 @@ export async function initialize(
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig);
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
@@ -292,20 +295,13 @@ async function startControl() {
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);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
+9 -10
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,25 +17,24 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"as-table": "^1.0.55",
"fs-extra": "^11.3.0",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+4
View File
@@ -0,0 +1,4 @@
export function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
File diff suppressed because it is too large. Load diff
+201
View File
@@ -0,0 +1,201 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
controllerCommands.add(new lib.Command({
definition: ["stop", "Stop controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerStopRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
},
}));
const controllerConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "controller config management",
});
controllerConfigCommands.add(new lib.Command({
definition: ["list", "List controller configuration"],
handler: async function(args: object, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set <field> [value]", "Set field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set-prop <field> <prop> [value]", "Set property of field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["edit [editor]", "Edit controller configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { editor: string }, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(
config, lib.ControllerConfig, {}
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
controllerCommands.add(controllerConfigCommands);
const controllerPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "controller plugin inspection",
});
controllerPluginCommands.add(new lib.Command({
definition: ["list", "List plugins on controller"],
handler: async function(args: object, control: Control) {
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
controllerCommands.add(controllerPluginCommands);
+11
View File
@@ -0,0 +1,11 @@
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
handler: async function(args: object, control: Control) {
await control.send(new lib.DebugDumpWsRequest());
control.keepOpen = true;
},
}));
+305
View File
@@ -0,0 +1,305 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, control: Control) {
let hosts = await control.send(new lib.HostListRequest());
print(asTable(hosts));
},
}));
hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, control: Control) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
hostCommands.add(new lib.Command({
definition: ["create-config", "Create host config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
});
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
control: Control
) {
let config = await control.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
let content = JSON.stringify(config, null, "\t");
if (args.output === "-") {
print(content);
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostUpdateRequest());
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const hostPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "host plugin inspection",
});
hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
}))));
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to update on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to install on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
hostCommands.add(hostPluginCommands);
+685
View File
@@ -0,0 +1,685 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import os from "os";
import child_process from "child_process";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
instanceCommands.add(new lib.Command({
definition: [["list", "l"], "List instances known to the controller"],
handler: async function(args: object, control: Control) {
let list = await control.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
},
}));
instanceCommands.add(new lib.Command({
definition: ["create <name>", "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
const instanceConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Instance config management",
});
instanceConfigCommands.add(new lib.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// If this is from stdin or looks like an array, object or string
// literal throw the parse error, otherwise assume this is a string.
// The resoning behind this is that correctly quoting the string
// with the all the layers of quote removal at play is difficult.
// See the following table for how to pass "That's a \" quote" in
// different environments:
// ----------------------- WARNING -------------------------------
// This table is most likely invalid due a change in yargs parsing
// ---------------------------------------------------------------
// cmd : """""That's a \\"" quote"""""
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
// PowerShell : '"""""That''s a \\"" quote"""""'
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
// bash : '""That'\''s a \" quote""'
// bash + npx : '""That'\''s a \" quote""'
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { instance: string, editor: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new lib.Command({
definition: ["assign <instance> [host]", "Assign instance to a host", (yargs) => {
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
}],
handler: async function(args: { instance: string, host?: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined;
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
},
}));
async function loadMapSettings(
args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string},
) {
let seed = args.seed;
let mapGenSettings;
let mapSettings;
if (args.mapExchangeString) {
let parsed = lib.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString());
}
if (args.mapSettings) {
mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString());
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
const instanceSaveCommands = new lib.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
instanceSaveCommands.add(new lib.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
)));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(
args: {
instance: string,
name: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
},
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args: { instance: string, oldName: string, newName: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args: { instance: string, source: string, destination: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Save name must end with .zip");
}
let instanceId = await lib.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
const response = await fetch(url, {
method: "POST",
body: await fs.createReadStream(args.filepath),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] };
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.saves && result.saves.length) {
logger.info(`Successfully uploaded as ${result.saves[0]}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading save failed");
}
},
}));
instanceSaveCommands.add(new lib.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(
args: {
sourceInstance: string,
sourceSave: string,
targetInstance: string,
targetSave: string,
copy: boolean,
},
control: Control
) {
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
targetInstanceId,
args.targetSave || args.sourceSave,
args.copy
)
);
print(`Transferred as ${storedName} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server.");
}
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
let filename = await lib.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
},
}));
instanceCommands.add(instanceSaveCommands);
instanceCommands.add(new lib.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["restart <instance>", "Restart instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
yargs.options({
"force": {
describe: "Start instances even if they have exclude_from_start_all set",
nargs: 0,
type: "boolean",
default: false,
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
// Only start instances that are currently stopped
if (instance.status !== "stopped") {
return false;
}
// Respect exclude_from_start_all setting unless --force is used
if (!args.force && instance.excludeFromStartAll) {
return false;
}
return true;
});
if (instancesToStart.length === 0) {
print("No instances to start.");
return;
}
print(`Starting ${instancesToStart.length} instance(s):`);
for (const instance of instancesToStart) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
}
});
await Promise.all(startPromises);
print("Start all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
instance: string,
scenario: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
keepOpen: boolean,
},
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStopRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
if (instancesToStop.length === 0) {
print("No instances to stop.");
return;
}
print(`Stopping ${instancesToStop.length} instance(s):`);
for (const instance of instancesToStop) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
}
});
await Promise.all(stopPromises);
print("Stop all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceKillRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteRequest(instanceId));
},
}));
instanceCommands.add(new lib.Command({
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args: { instance: string, command: string }, control: Control) {
let result = await control.sendTo(
{ instanceId: await lib.resolveInstance(control, args.instance) },
new lib.InstanceSendRconRequest(args.command),
);
// Factorio includes a newline in its response output.
process.stdout.write(result);
},
}));
+90
View File
@@ -0,0 +1,90 @@
import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Control } from "../ctl";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
yargs.options({
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
});
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
control.keepOpen = true;
},
}));
logCommands.add(new lib.Command({
definition: ["query", "Query cluster log", (yargs) => {
yargs.options({
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Query log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: undefined },
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
all: boolean,
controller: boolean,
host: string | null,
instance: string | null,
maxLevel?: string,
limit: number,
start: boolean,
},
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
let result = await control.send(new lib.LogQueryRequest(
args.all,
args.controller,
hostIds,
instanceIds,
args.maxLevel as keyof typeof levels,
args.limit,
args.start ? "asc" : "desc",
));
if (!args.start) {
result.log.reverse();
}
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
format: new lib.TerminalFormat({ showTimestamp: true }),
transports: [
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
],
});
for (let info of result.log) {
stdoutLogger.log(info as any);
}
},
}));
+237
View File
@@ -0,0 +1,237 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new lib.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
for (let entry of value) {
print(` ${entry}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modCommands.add(new lib.Command({
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
yargs.options({
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
print(asTable(mods));
},
}));
modCommands.add(new lib.Command({
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
yargs.options({
"page": {
describe: "Result page to show",
type: "number",
default: 1,
},
"page-size": {
describe: "Results per page to show",
type: "number",
default: 10,
},
"sort": {
describe: "sort results by given field",
type: "string",
},
"sort-order": {
describe: "order to sort results in (asc/desc)",
type: "string",
default: "asc",
},
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(
args: {
factorioVersion: string,
query: string,
page: number,
pageSize: number,
sort?: string,
sortOrder: "asc" | "desc",
fields: string[],
},
control: Control
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
args.sortOrder
));
let results = response.results.flatMap(result => result.versions);
if (!args.fields.includes("all")) {
for (let entry of results) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
for (let issue of response.queryIssues) {
print(issue);
}
print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`);
print(asTable(results));
},
}));
modCommands.add(new lib.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args: { file: string }, control: Control) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Mod filename must end with .zip");
}
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let response = await fetch(url, {
method: "POST",
body: fs.createReadStream(args.file),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: [], request_errors?: [], mods: Parameters<typeof lib.ModInfo.fromJSON>[0][]};
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.mods && result.mods.length) {
const mod = lib.ModInfo.fromJSON(result.mods[0]);
logger.info(`Successfully uploaded ${mod.filename}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading mod failed");
}
},
}));
modCommands.add(new lib.Command({
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server");
}
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${filename}`);
},
}));
modCommands.add(new lib.Command({
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
+315
View File
@@ -0,0 +1,315 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" });
modPackCommands.add(new lib.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
let mods = new Map(
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
if (field === "mods") {
print(`${field}:`);
for (let [name, entry] of value) {
const mod = mods.get(`${name}_${entry.version}`);
const missing = !mod && "? ";
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
const warn = missing || badChecksum || "";
const enabled = entry.enabled ? "" : "(disabled) ";
print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of settings as any) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else if (field === "exportManifest") {
print(`${field}:`);
if (value && value.assets) {
print(" assets:");
for (let [name, fileName] of Object.entries(value.assets)) {
print(` ${name}: ${fileName}`);
}
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modPackCommands.add(new lib.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args: object, control: Control) {
let modPacks = await control.send(new lib.ModPackListRequest());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
print(asTable(modPacks));
},
}));
function setModPackSettings(
modPack: lib.ModPack,
args: {
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
) {
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
modPack.settings[checkedScope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new lib.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new lib.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
doSettings(args.colorSetting || [], value => {
let color = JSON.parse(value);
if (typeof color !== "object" || color === null) {
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
}
return color;
});
}
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new lib.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, enabled: true, version, sha1 });
}
}
function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) {
for (let mod of mods || []) {
if (!modPack.mods.has(mod)) {
throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`);
}
modPack.mods.get(mod)!.enabled = enabled;
}
}
modPackCommands.add(new lib.Command({
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(
args: {
name: string,
factorioVersion: string,
description?: string,
mods?: string[],
disabledMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args: { string: string }, control: Control) {
const modPack = lib.ModPack.fromModPackString(args.string);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
print(modPack.toModPackString());
},
}));
modPackCommands.add(new lib.Command({
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(
args: {
modPack: string,
name?: string,
description?: string,
factorioVersion?: string,
addMods?: string[],
enableMods?: string[],
disableMods?: string[],
removeMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackModsEnabled(modPack, args.disableMods, false);
setModPackModsEnabled(modPack, args.enableMods, true);
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = args.removeSetting.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
if (modPack.settings[checkedScope].has(name)) {
modPack.settings[checkedScope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await control.send(new lib.ModPackUpdateRequest(modPack));
},
}));
modPackCommands.add(new lib.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const id = await lib.resolveModPack(control, args.modPack);
await control.send(new lib.ModPackDeleteRequest(id));
},
}));
+16
View File
@@ -0,0 +1,16 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new lib.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args: object, control: Control) {
let permissions = await control.send(new lib.PermissionListRequest());
print(asTable(permissions));
},
}));
+100
View File
@@ -0,0 +1,100 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
roleCommands.add(new lib.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args: object, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
print(asTable(roles));
},
}));
roleCommands.add(new lib.Command({
definition: ["create <name>", "Create a new role", (yargs) => {
yargs.positional("name", { describe: "Name of role to create", type: "string" });
yargs.options({
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args: { name: string, description: string, permissions: string[] }, control: Control) {
let id = await control.send(new lib.RoleCreateRequest(
args.name,
args.description,
args.permissions,
));
logger.info(`Created role ID ${id}`);
},
}));
roleCommands.add(new lib.Command({
definition: ["edit <role>", "Edit existing role", (yargs) => {
yargs.positional("role", { describe: "Role to edit", type: "string" });
yargs.options({
"name": { describe: "New name for role", nargs: 1, type: "string" },
"description": { describe: "New description for role", nargs: 1, type: "string" },
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
"remove-perms": {
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
},
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: {
role: string,
name?: string,
description?: string,
setPerms?: string[],
addPerms?: string[],
removePerms?: string[],
grantDefault?: boolean,
},
control: Control
) {
let role = await lib.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
}
if (args.description !== undefined) {
role.description = args.description;
}
if (args.addPerms) {
for (const perm of args.addPerms) {
role.permissions.add(perm);
}
}
if (args.removePerms) {
for (let perm of args.removePerms) {
role.permissions.delete(perm);
}
}
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
roleCommands.add(new lib.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args: { role: string }, control: Control) {
let role = await lib.retrieveRole(control, args.role);
await control.send(new lib.RoleDeleteRequest(role.id));
},
}));
+304
View File
@@ -0,0 +1,304 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" });
userCommands.add(new lib.Command({
definition: ["show <name>", "Show details for one user", (yargs) => {
yargs.positional("name", { decribe: "Name of user to show", type: "string" });
yargs.options({
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
let instanceStats = user.instanceStats;
// @ts-expect-error Terrible hack
delete user.instanceStats;
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await control.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
return instance.name;
}
return "<deleted>";
}
for (let [id, playerInstanceStats] of instanceStats || []) {
print();
print(`Instance ${instanceName(id)} (${id}):`);
print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value }))));
}
}
},
}));
userCommands.add(new lib.Command({
definition: [["list", "l"], "List user in the cluster", (yargs) => {
yargs.options({
"stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false },
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) {
let users = await control.send(new lib.UserListRequest());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
}
// @ts-expect-error Terrible hack
delete user.playerStats;
// @ts-expect-error Terrible hack
delete user.isDeleted;
// @ts-expect-error Terrible hack
delete user.banReason;
// @ts-expect-error Terrible hack
delete user.instanceStats;
if (!args.attributes) {
// @ts-expect-error Terrible hack
delete user.isAdmin;
// @ts-expect-error Terrible hack
delete user.isWhitelisted;
// @ts-expect-error Terrible hack
delete user.isBanned;
}
}
print(asTable(users));
},
}));
userCommands.add(new lib.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserCreateRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserRevokeTokenRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
yargs.options({
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, revoke: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
},
}));
userCommands.add(new lib.Command({
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
yargs.options({
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, remove: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
},
}));
userCommands.add(new lib.Command({
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
yargs.options({
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
user: string,
pardon: boolean,
reason: string,
create: boolean
},
control: Control
) {
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
userCommands.add(new lib.Command({
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args: { user: string, roles: string[] }, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
let resolvedRoles = [];
for (let roleName of args.roles) {
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
resolvedRoles.push(roleId);
} else {
let found = false;
for (let role of roles) {
if (role.name === roleName) {
resolvedRoles.push(role.id);
found = true;
break;
}
}
if (!found) {
throw new lib.CommandError(`No role named ${roleName}`);
}
}
}
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
userCommands.add(new lib.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args: { user: string }, control: Control) {
await control.send(new lib.UserDeleteRequest(args.user));
},
}));
async function handleImportOrRestore(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, restore: boolean) {
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
const filename = path.basename(args.filepath);
if (args.users || (optionCount === 0 && data.export_version)) {
importType = "users";
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
importType = "bans";
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
importType = "admins";
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
importType = "whitelist";
} else {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
userCommands.add(new lib.Command({
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
userCommands.add(new lib.Command({
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
},
}));
userCommands.add(new lib.Command({
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
yargs.options({
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
const optionCount = [args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
if (args.users) {
exportType = "users";
} else if (args.bans) {
exportType = "bans";
} else if (args.admins) {
exportType = "admins";
} else if (args.whitelist) {
exportType = "whitelist";
}
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
+58
View File
@@ -0,0 +1,58 @@
import * as lib from "@clusterio/lib";
export async function getEditor(argsEditor: string) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
export async function configToKeyVal(data: string) {
let final: Record<string, string> = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
let split = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = split[1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[finalIndex] = part;
}
}
return final;
}
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
if (name in disallowedList) {
continue;
}
let desc = "";
try {
desc += (configGroup.fieldDefinitions as any)[name]!.description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${name} = ${value}\n\n`;
}
return allConfigElements;
}
+15 -13
View File
@@ -51,13 +51,11 @@ void new lib.Gauge(
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
);
}
@@ -113,6 +111,7 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
@@ -167,8 +166,14 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let hostConfig;
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
@@ -186,10 +191,13 @@ async function startHost() {
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig);
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
@@ -220,17 +228,10 @@ async function startHost() {
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);
let hostConnector = new HostConnector(hostConfig, pluginInfos);
host = new Host(
hostConnector,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
Boolean(args.recovery),
@@ -281,6 +282,7 @@ async function startHost() {
});
await hostConnector.connect();
host.startedAtMs = Date.now();
logger.info("Started host");
}
@@ -297,7 +299,7 @@ I version of clusterio. Expect things to break. I
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
} else if (err instanceof lib.StartupError) {
} else if (err instanceof lib.StartupError || err.code === "InstallationError") {
logger.fatal(`
+----------------------------------+
| Unable to to start clusteriohost |
@@ -70,7 +70,6 @@ function serialize.serialize_equipment_grid(grid)
if equipment.to_be_removed then
entry.t = equipment.to_be_removed
end
log(entry)
table.insert(serialized, entry)
end
end
+13 -13
View File
@@ -3,10 +3,11 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -17,33 +18,32 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.3.0",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.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.17.19",
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+163 -124
View File
@@ -3,7 +3,6 @@ import path from "path";
import events from "events";
import pidusage from "pidusage";
import setBlocking from "set-blocking";
import phin from "phin";
import stream from "stream";
import util from "util";
@@ -32,7 +31,7 @@ function checkRequestSaveName(name: string) {
*
* Looks through all sub-dirs of the provided directory for valid
* instance definitions and return a mapping of instance id to
* instanceInfo objects.
* instance configs and the root instance paths.
*
* @param instancesDir - Directory containing instances
* @returns
@@ -40,7 +39,7 @@ function checkRequestSaveName(name: string) {
* @internal
*/
async function discoverInstances(instancesDir: string) {
let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
const instances = new Map<number, UnloadedInstance>();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
@@ -60,21 +59,25 @@ async function discoverInstances(instancesDir: string) {
continue;
}
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
if (instances.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instanceInfos.set(instanceConfig.get("instance.id"), {
instances.set(instanceConfig.get("instance.id"), {
path: instancePath,
config: instanceConfig,
});
}
}
return instanceInfos;
return instances;
}
// Requests that wake up instances are responsible for making sure the
// instance status is correctly updated even if they fail with an error. This
// means they should typically use try catch on anything that can throw
// and call notifyExit upon any errors.
const instanceStartingMessages = new Set([
lib.InstanceStartRequest.name,
lib.InstanceLoadScenarioRequest.name,
@@ -114,10 +117,10 @@ export class HostRouter {
dst.type === lib.Address.controller
|| dst.type === lib.Address.control
|| dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id")
|| dst.type === lib.Address.instance && !this.host.instanceInfos.has(dst.id)
|| dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id)
) {
nextHop = this.host;
} else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) {
} else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) {
nextHop = this.host.instanceConnections.get(dst.id)!;
}
@@ -244,6 +247,12 @@ export class HostRouter {
}
}
// Instance information required to location and load an instance
type UnloadedInstance = {
path: string,
config: lib.InstanceConfig,
};
/**
* Handles running the host
*
@@ -253,19 +262,18 @@ export default class Host extends lib.Link {
declare "connector": HostConnector;
router = new HostRouter(this);
/**
* Certificate authority used to validate TLS connections to the controller.
*/
tlsCa?: string;
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseHostPlugin> = new Map();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
/** A map of instances that exist in the instance directory of the host */
discoveredInstances = new Map<number, UnloadedInstance>();
/** A map of instances that are assigned to this host */
assignedInstances = new Map<number, UnloadedInstance>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
@@ -278,6 +286,8 @@ export default class Host extends lib.Link {
_disconnecting = false;
_shuttingDown = false;
startedAtMs = Date.now();
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
@@ -293,7 +303,6 @@ export default class Host extends lib.Link {
constructor(
connector: HostConnector,
hostConfig: lib.HostConfig,
tlsCa: string | undefined,
pluginInfos: lib.PluginNodeEnvInfo[],
/**
* If true indicates that there is a process monitor present that
@@ -308,7 +317,6 @@ export default class Host extends lib.Link {
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
) {
super(connector);
this.tlsCa = tlsCa;
this.pluginInfos = pluginInfos;
this.config = hostConfig;
@@ -410,7 +418,7 @@ export default class Host extends lib.Link {
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -436,6 +444,9 @@ export default class Host extends lib.Link {
this.plugins.set(pluginInfo.name, hostPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -580,18 +591,17 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
let response = await fetch(url);
if (!response.body) {
throw new Error(`Empty body downloading stream ${streamId} from controller`);
}
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
await events.once(writeStream, "open");
response.pipe(writeStream);
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
@@ -650,16 +660,16 @@ export default class Host extends lib.Link {
async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) {
let { instanceId, config } = request;
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
instanceInfo.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo));
let instance = this.assignedInstances.get(instanceId);
if (instance) {
instance.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance));
} else {
instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (instanceInfo) {
await Instance.populate_folders(instanceInfo.path);
instanceInfo.config.update(config, true, "controller");
instance = this.discoveredInstances.get(instanceId);
if (instance) {
await Instance.populate_folders(instance.path);
instance.config.update(config, true, "controller");
} else {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
@@ -670,16 +680,16 @@ export default class Host extends lib.Link {
logger.info(`Creating ${instanceDir}`);
await Instance.populate_folders(instanceDir);
instanceInfo = {
instance = {
path: instanceDir,
config: instanceConfig,
};
this.discoveredInstanceInfos.set(instanceId, instanceInfo);
this.discoveredInstances.set(instanceId, instance);
}
this.instanceInfos.set(instanceId, instanceInfo);
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
this.assignedInstances.set(instanceId, instance);
logger.verbose(`assigned instance ${instance.config.get("instance.name")}`);
}
// Somewhat hacky, but in the event of a lost session the status is
@@ -693,45 +703,46 @@ export default class Host extends lib.Link {
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
: instanceInfo.config.get("factorio.version"),
// TODO: factorio.version is not validated
: instance.config.get("factorio.version") as lib.TargetVersion,
)
);
// Send the new list of saves for this assigned instance to the controller.
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
// save a copy of the instance config
await instanceInfo.config.save();
await instance.config.save();
}
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
let instanceId = request.instanceId;
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
let instance = this.assignedInstances.get(instanceId);
if (instance) {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) {
await instanceConnection.send(new lib.InstanceStopRequest());
}
this.instanceInfos.delete(instanceId);
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
this.assignedInstances.delete(instanceId);
logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`);
}
}
instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
instanceInfo = instanceInfo || this.instanceInfos.get(instanceId);
if (!instanceInfo) {
instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) {
instance = instance || this.assignedInstances.get(instanceId);
if (!instance) {
return { instance_id: instanceId, instance_name: String(instanceId) };
}
return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") };
return { instance_id: instanceId, instance_name: instance.config.get("instance.name") };
}
getRequestInstanceInfo(instanceId: number) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
getRequestInstance(instanceId: number) {
let instance = this.assignedInstances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instanceInfo;
return instance;
}
/**
@@ -740,7 +751,7 @@ export default class Host extends lib.Link {
* @returns Assigned game port or undefined if it does not exist.
*/
gamePort(instanceId: number) {
const instance = this.discoveredInstanceInfos.get(instanceId);
const instance = this.discoveredInstances.get(instanceId);
if (!instance) {
return undefined;
}
@@ -753,7 +764,7 @@ export default class Host extends lib.Link {
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const [id, instance] of this.instanceInfos) {
for (const [id, instance] of this.assignedInstances) {
if (id === instanceId) {
continue;
}
@@ -765,7 +776,7 @@ export default class Host extends lib.Link {
availablePorts.delete(port);
}
}
const instanceToAssign = this.instanceInfos.get(instanceId)!;
const instanceToAssign = this.assignedInstances.get(instanceId)!;
const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port");
if (assignedPort !== null && availablePorts.has(assignedPort)) {
return assignedPort;
@@ -785,7 +796,7 @@ export default class Host extends lib.Link {
* @returns connection to instance.
*/
async _connectInstance(instanceId: number) {
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let unloadedInstance = this.getRequestInstance(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
@@ -794,7 +805,9 @@ export default class Host extends lib.Link {
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
let instance = new Instance(
this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config
this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
unloadedInstance.config
);
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
@@ -805,19 +818,48 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("host.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
let packageName = "@clusterio/host";
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async handleHostMetricsRequest() {
@@ -864,19 +906,19 @@ export default class Host extends lib.Link {
src: lib.Address,
dst: lib.Address
) {
let instanceInfo = this.getRequestInstanceInfo(dst.id);
return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null);
let instance = this.getRequestInstance(dst.id);
return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null);
}
async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) {
let { instanceId, oldName, newName } = request;
checkRequestSaveName(oldName);
checkRequestSaveName(newName);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let instance = this.getRequestInstance(instanceId);
try {
await fs.move(
path.join(instanceInfo.path, "saves", oldName),
path.join(instanceInfo.path, "saves", newName),
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
{ overwrite: false },
);
} catch (err: any) {
@@ -885,18 +927,18 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) {
let { instanceId, source, destination } = request;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let instance = this.getRequestInstance(instanceId);
try {
await fs.copy(
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err: any) {
@@ -905,33 +947,33 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
checkRequestSaveName(sourceName);
checkRequestSaveName(targetName);
let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId);
let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId);
let sourceInstance = this.getRequestInstance(sourceInstanceId);
let targetInstance = this.getRequestInstance(targetInstanceId);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
targetName = await lib.findUnusedName(
path.join(targetInstanceInfo.path, "saves"), targetName, ".zip"
path.join(targetInstance.path, "saves"), targetName, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
);
}
@@ -941,8 +983,8 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
return targetName;
}
@@ -962,38 +1004,34 @@ export default class Host extends lib.Link {
async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) {
let { instanceId, name } = request;
checkRequestSaveName(name);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let instance = this.getRequestInstance(instanceId);
try {
await fs.unlink(path.join(instanceInfo.path, "saves", name));
await fs.unlink(path.join(instance.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
}
async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let instance = this.getRequestInstance(instanceId);
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
let response = await fetch(url);
if (response.statusCode !== 200) {
let content = await lib.readStream(response);
throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
}
let savesDir = path.join(instanceInfo.path, "saves");
let savesDir = path.join(instance.path, "saves");
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
@@ -1009,7 +1047,7 @@ export default class Host extends lib.Link {
}
}
}
response.pipe(writeStream);
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
@@ -1022,12 +1060,13 @@ export default class Host extends lib.Link {
async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let instance = this.getRequestInstance(instanceId);
let content: Buffer;
let content = fs.createReadStream(path.join(instance.path, "saves", name));
try {
// phin doesn't support streaming requests :(
content = await fs.readFile(path.join(instanceInfo.path, "saves", name));
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
// "open" with "ready" when fs-extra is replaced with node's fs module.
await events.once(content, "open");
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1037,10 +1076,10 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
phin({
url, method: "PUT",
core: { ca: this.tlsCa } as object,
data: content,
fetch(url, {
method: "PUT",
body: content,
duplex: "half",
}).catch(err => {
logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId));
});
@@ -1052,21 +1091,21 @@ export default class Host extends lib.Link {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (!instanceInfo) {
let instance = this.discoveredInstances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
this.discoveredInstanceInfos.delete(instanceId);
this.instanceInfos.delete(instanceId);
await fs.remove(instanceInfo.path);
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.remove(instance.path);
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/host");
return await lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1087,7 +1126,7 @@ export default class Host extends lib.Link {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.plugins.has(pluginInfo.name),
this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean,
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1109,12 +1148,12 @@ export default class Host extends lib.Link {
* the host and controller with the new list of instances.
*/
async updateInstances() {
this.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory"));
this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory"));
let list = [];
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
for (let [instanceId, instance] of this.discoveredInstances) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push(new lib.HostInstanceUpdate(
instanceInfo.config.toRemote("controller"),
instance.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
@@ -1126,10 +1165,10 @@ export default class Host extends lib.Link {
if (this._startup) {
this._startup = false;
for (let [instanceId, instanceInfo] of this.instanceInfos) {
if (instanceInfo.config.get("instance.auto_start")) {
for (let [instanceId, instance] of this.assignedInstances) {
if (instance.config.get("instance.auto_start")) {
if (this.recoveryMode) {
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`);
continue;
}
try {
@@ -1137,8 +1176,8 @@ export default class Host extends lib.Link {
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
} catch (err: any) {
logger.error(
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instanceInfo)
`Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instance)
);
}
}
@@ -1186,7 +1225,7 @@ export default class Host extends lib.Link {
await this.config.save();
// Save host side in case host_assigned_game_port changed in the config.
await Promise.all(
[...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save())
[...this.assignedInstances.values()].map(instance => instance.config.save())
);
try {
+36 -15
View File
@@ -1,7 +1,6 @@
import fs from "fs-extra";
import path from "path";
import pidusage from "pidusage";
import phin from "phin";
import util from "util";
import type { Static } from "@sinclair/typebox";
import { exec } from "child_process";
@@ -210,7 +209,8 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
version: this.config.get("factorio.version"),
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
@@ -553,7 +553,8 @@ end`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
status === "running"? this.server.version : this.config.get("factorio.version"),
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
);
}
@@ -636,9 +637,12 @@ end`.replace(/\r?\n/g, " ");
}
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
// After changing the status this function is responsible for calling notifyExit if it throws.
this.notifyStatus("starting");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
this.notifyExit();
@@ -651,7 +655,7 @@ end`.replace(/\r?\n/g, " ");
if (
!pluginInfo.instanceEntrypoint
|| !this._host.serverPlugins.has(pluginInfo.name)
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -841,7 +845,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
this.logger.verbose("Writing server-adminlist.json");
lib.safeOutputFile(
await lib.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._host.adminlist], null, "\t")
);
@@ -849,7 +853,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_banlist") !== "disabled") {
this.logger.verbose("Writing server-banlist.json");
lib.safeOutputFile(
await lib.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._host.banlist].map(
([username, reason]) => ({ username, reason })
@@ -859,10 +863,21 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
this.logger.verbose("Writing server-whitelist.json");
lib.safeOutputFile(
await lib.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._host.whitelist], null, "\t")
);
} else {
// Always create the whitelist even if unused
// This allows it to be watched for changes without a server restart
try {
// wx will throw an error if the file already exists to prevent overwrite
await fs.writeFile(this.server.writePath("server-whitelist.json"), "\n[]\n", { flag: "wx" });
} catch (err: any) {
if (err.code !== "EEXIST") {
throw err;
}
}
}
await this.syncMods();
@@ -960,10 +975,15 @@ end`.replace(/\r?\n/g, " ");
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
if (this.config.get("factorio.enable_script_commands")) {
await this.server.disableAchievements();
await this.updateInstanceData();
}
if (this.config.get("factorio.enable_save_patching")) {
this._watchPlayerPromote();
if (this.config.get("factorio.enable_script_commands")) {
await this.updateInstanceData();
}
} else {
this._watchServerLogActions();
}
@@ -1283,17 +1303,18 @@ end`.replace(/\r?\n/g, " ");
let url = new URL(this._host.config.get("host.controller_url"));
url.pathname += "api/upload-export";
url.searchParams.set("mod_pack_id", String(this.activeModPack.id));
let response = await phin({
url, method: "PUT",
data: content,
core: { ca: this._host.tlsCa } as object,
let response = await fetch(url, {
method: "PUT",
body: content,
duplex: "half",
headers: {
"Content-Type": "application/zip",
"x-access-token": this._host.config.get("host.controller_token"),
},
});
if (response.statusCode !== 200) {
throw Error(`Upload failed: ${response.statusCode} ${response.statusMessage}: ${response.body}`);
if (response.status !== 200) {
const body = Buffer.from(await response.arrayBuffer());
throw Error(`Upload failed: ${response.status} ${response.statusText}: ${body.toString()}`);
}
} finally {
+155 -53
View File
@@ -98,7 +98,8 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
throw new Error(`Bad mod path ${match}`);
server._logger.warn(`Skipping icon with bad mod path: ${JSON.stringify(modPath)}`);
return null;
}
let [, mod, filePath] = match;
@@ -151,7 +152,6 @@ async function loadIcon(
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
@@ -258,6 +258,14 @@ function fixIcons(item: ItemPrototype) {
return item;
}
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
if (item.icons) {
return (item as LayeredIconSpecification).icons[0]?.icon;
}
return (item as SimpleIconSpecification).icon;
}
const itemTypes = new Set([
"item",
"ammo",
@@ -280,87 +288,153 @@ const itemTypes = new Set([
"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[] {
const recipeTypes = new Set(["recipe"]);
const signalTypes = new Set(["virtual-signal"]);
const technologyTypes = new Set(["technology"]);
const planetTypes = new Set(["planet", "space-location"]);
const qualityTypes = new Set(["quality"]);
// Entity types to export. Entities whose name matches an already-exported
const entityTypes = new Set([
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
"cargo-landing-pad", "asteroid",
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
"unit", "unit-spawner", "spider-unit", "segmented-unit",
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
"simple-entity-with-owner", "simple-entity-with-force",
"arithmetic-combinator", "decider-combinator", "constant-combinator",
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
"character",
"offshore-pump", "valve", "lightning-attractor",
"fusion-generator", "fusion-reactor", "burner-generator",
"agricultural-tower", "market", "lane-splitter",
]);
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type, _]) => itemTypes.has(type))
.filter(([type]) => allowedTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons)
;
.map(fixIcons);
}
/** Shared state for building a unified spritesheet across all categories. */
interface SheetState {
sheet: Jimp;
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
iconCache: IconCache;
simpleIcons: Map<string, number>;
pos: number;
}
const SHEET_ICON_SIZE = 32;
const SHEET_WIDTH = 1024;
async function createSheetState(): Promise<SheetState> {
return {
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
metadata: new Map(),
iconCache: new Map(),
simpleIcons: new Map(),
pos: 0,
};
}
/** Grow the spritesheet vertically if needed to fit another row. */
async function growSheet(state: SheetState) {
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
if (neededHeight > state.sheet.bitmap.height) {
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
extended.composite(state.sheet, 0, 0);
(state.sheet as any).bitmap = (extended as any).bitmap;
}
}
/**
* Export item icons and data
* Export item icons for a single category onto the shared spritesheet.
*
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param server -
* The server to generate the export mod for.
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param items - Array of item prototypes.
* @return Item spritesheet and metadata.
* @param server - The server to load icons from.
* @param modVersions - Mapping of mod name to version.
* @param items - Array of item prototypes to export.
* @param category - Category name for metadata.
* @param state - Shared spritesheet state (mutated in place).
* @returns Number of icons exported for this category.
* @internal
*/
async function exportItems(server: FactorioServer, modVersions: Map<string, string>, items: ItemPrototype[]) {
// Size to render icons at
let size = 32;
// Width of spritesheet
let width = 1024;
let rows = Math.ceil(items.length / (width / size));
let iconSheet = await Jimp.create(width, rows * size);
let itemData = new Map();
let pos = 0;
let iconCache: IconCache = new Map();
let simpleIcons = new Map();
async function exportItems(
server: FactorioServer,
modVersions: Map<string, string>,
items: ItemPrototype[],
category: string,
state: SheetState,
) {
let count = 0;
for (let item of items) {
// Skip prototypes with no icon data at all
if (!item.icons && !item.icon) {
continue;
}
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache);
iconPos = pos;
icon = await loadLayeredIcon(
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
} else {
iconPos = simpleIcons.get(item.icon);
iconPos = state.simpleIcons.get(item.icon as string);
if (iconPos === undefined) {
icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache);
icon = await loadSimpleIcon(
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
iconPos = state.pos;
state.simpleIcons.set(item.icon as string, state.pos);
}
}
}
if (iconPos !== undefined) {
itemData.set(item.name, {
x: iconPos * size % width,
y: Math.floor(iconPos / (width / size)) * size,
size,
localised_name: item.localised_name,
localised_description: item.localised_description,
const iconPath = getPrimaryIconPath(item);
state.metadata.set(`${category}.${item.name}`, {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
category,
...(iconPath ? { path: iconPath } : {}),
});
}
if (icon) {
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
pos += 1;
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
count += 1;
}
}
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
return { iconSheet, itemData };
return count;
}
/**
@@ -505,7 +579,16 @@ export async function exportData(server: FactorioServer) {
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
const categories: { name: string, types: Set<string> }[] = [
{ name: "item", types: itemTypes },
{ name: "recipe", types: recipeTypes },
{ name: "signal", types: signalTypes },
{ name: "technology", types: technologyTypes },
{ name: "planet", types: planetTypes },
{ name: "quality", types: qualityTypes },
{ name: "entity", types: entityTypes },
];
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
@@ -514,9 +597,28 @@ export async function exportData(server: FactorioServer) {
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()]));
// Build a single unified spritesheet across all prototype categories.
const state = await createSheetState();
for (const category of categories) {
const categoryItems = filterPrototypes(prototypes, category.types);
if (categoryItems.length === 0) {
continue;
}
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
server._logger.info(`Exported ${count} ${category.name} icons`);
}
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
return zip;
}
+181 -40
View File
@@ -2,6 +2,7 @@
import fs from "fs-extra";
import child_process from "child_process";
import path from "path";
import JSZip from "jszip";
import events from "events";
import util from "util";
import crypto from "crypto";
@@ -9,31 +10,33 @@ import { Rcon } from "rcon-client";
import * as lib from "@clusterio/lib";
const execFileAsync = util.promisify(child_process.execFile);
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
*
* @param changelogPath - Path to changelog.txt.
* @internal
* @param factorioDir - Path factorio dir containing data/changelog.txt.
*/
async function getVersion(changelogPath: string) {
async function getFactorioVersion(factorioDir: string) {
let changelog;
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
try {
changelog = await fs.readFile(changelogPath, "utf-8");
} catch (err: any) {
if (err.code === "ENOENT") {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return null;
}
throw err;
}
for (let line of changelog.split(/[\r\n]+/)) {
let index = line.indexOf(":");
for (const line of changelog.split(/[\r\n]+/)) {
const index = line.indexOf(":");
if (index !== -1) {
let name = line.slice(0, index).trim();
const name = line.slice(0, index).trim();
if (name.toLowerCase() === "version") {
return line.slice(index + 1).trim();
const version = line.slice(index + 1).trim();
return lib.isFullVersion(version) ? version : null;
}
}
}
@@ -66,61 +69,161 @@ function versionOrder(a: string, b: string) {
return 0;
}
/**
* Find all installed factorio versions and return them as a set
*
* @param factorioDir - Path to Factorio installation dirs.
* @returns Set of installed factorio versions
*/
async function listFactorioVersions(factorioDir: string) {
const versions = new Set<lib.FullVersion>();
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
versions.add(directVersion);
return { direct: true, versions };
}
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
continue;
}
versions.add(version);
}
return { direct: false, versions };
}
/**
* Downloads a ZIP file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractZip(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
// Load the ZIP with JSZip
const buffer = Buffer.from(await res.arrayBuffer());
const zip = await JSZip.loadAsync(buffer);
// Extract each file/folder
await fs.ensureDir(targetDir);
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedPath = parts.slice(1).join("/");
if (!strippedPath) {
return; // This copies the behaviour of tar --strip-components 1
}
const entryPath = path.join(targetDir, strippedPath);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
}
/**
* Downloads a tar.gz file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractTar(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
// Write the buffer to a temp file
const tmpFilePath = `${targetDir}.tmp`;
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(tmpFilePath, buffer);
await fs.ensureDir(targetDir);
// Execute tar CLI to extract
try {
await execFileAsync("tar", ["-xf", tmpFilePath, "-C", targetDir, "--strip-components", "1"]);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error("error executing tar command, do you have 'xz-utils' installed?");
}
throw err;
} finally {
// Clean up temp file
await fs.remove(tmpFilePath);
}
}
/**
* Find factorio data directory of the given version
*
* Searches the given factorio dir for an installation of Factorio of
* the target version.
*
* @param factorioDir - Path to Factorio installation dir(s).
* @param factorioDir - Path to Factorio installation dirs.
* @param targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
* @returns Array with path to data dir and version found.
* @internal
*/
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
// multiple install directories
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
if (simpleVersion === targetVersion || targetVersion === "latest") {
return [path.join(factorioDir, "data"), simpleVersion];
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
// Check if this is a direct installation dir
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
}
throw new Error(
`Factorio version ${targetVersion} was requested, but install directory contains ${simpleVersion}`
);
throw new Error(`Unable to find Factorio version ${targetVersion}`);
}
let versions = new Map();
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
// Otherwise assume it is a directory of multiple versions
const versions = new Map<lib.FullVersion, string>();
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
continue;
}
if (version === targetVersion) {
return [path.join(factorioDir, entry.name, "data"), version];
return [path.join(factorioDir, name, "data"), version];
}
versions.set(version, entry.name);
versions.set(version, name);
}
if (!versions.size) {
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
}
if (lib.isPartialVersion(targetVersion)) {
const sorted = [...versions.keys()].sort(versionOrder);
const latest = sorted.find(version => version.startsWith(targetVersion));
if (latest) {
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
}
if (targetVersion === "latest") {
let latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest), "data"), latest];
const latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
@@ -423,7 +526,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: string,
version?: lib.TargetVersion,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -523,7 +626,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_writeDir: string;
// Resolved in init
_version: string | null = null;
_version: lib.FullVersion | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
@@ -532,7 +635,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: string;
_targetVersion: lib.TargetVersion;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
@@ -784,7 +887,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
try {
this._whitelistWatcher = fs.watch(filePath);
} catch (err: any) {
this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
}
@@ -861,12 +964,47 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this._state = "init";
}
/**
* Check of updated versions to download
*
* Should be called before init for the new version to be detected during init
*/
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
const targetVersion = this._targetVersion;
const latestVersion = availableVersions
.filter(v => (
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
))
.sort((v1, v2) => versionOrder(v1.version, v2.version))
[0];
if (!latestVersion) {
return;
}
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct || process.platform !== "linux") {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
return;
}
// Download the newer version
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
const dst = path.join(this._factorioDir, latestVersion.version);
await downloadAndExtractTar(latestVersion.headlessUrl, dst);
this._logger.info(`Downloaded factorio version ${v}`);
}
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
get version(): string | undefined {
get version(): lib.FullVersion | undefined {
return this._version ?? undefined;
}
@@ -1411,9 +1549,12 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// For testing only
export const _getVersion = getVersion;
export const _getFactorioVersion = getFactorioVersion;
export const _versionOrder = versionOrder;
export const _findVersion = findVersion;
export const _listFactorioVersions = listFactorioVersions;
export const _downloadAndExtractZip = downloadAndExtractZip;
export const _downloadAndExtractTar = downloadAndExtractTar;
export const _randomDynamicPort = randomDynamicPort;
export const _generatePassword = generatePassword;
export const _parseOutput = parseOutput;
+4
View File
@@ -15,3 +15,7 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(import.meta.url);
+14 -2
View File
@@ -115,9 +115,21 @@ async function main() {
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
"source-dir": {
describe: "Path to mod source files",
type: "string",
nargs: 1,
normalize: true,
demandOption: true,
},
"output-dir": {
describe: "Path to output built mod(s)",
type: "string",
nargs: 1,
normalize: true,
default: "dist/factorio",
},
})
.strict()
.argv
+6
View File
@@ -9,6 +9,7 @@ export * from "./src/config";
export * from "./src/data";
export * from "./src/database";
export * from "./src/errors";
export * from "./src/external";
export * from "./src/factorio";
export * from "./src/file_ops";
export * from "./src/hash";
@@ -31,7 +32,12 @@ export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export * from "./src/LockFile";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(__filename);
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.23",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -18,30 +18,30 @@
"node": ">=12"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"ajv": "^8.17.1",
"chalk": "^4.1.2",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"chalk": "catalog:",
"ws": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.3.0",
"jszip": "^3.10.1",
"klaw": "^4.1.0",
"set-blocking": "^2.0.0",
"triple-beam": "^1.4.1",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"ajv": "^8.17.1",
"klaw": "^4.1.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/set-blocking": "catalog:",
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+163
View File
@@ -0,0 +1,163 @@
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
* Error thrown when the lock file already exists.
*/
export class LockFileExistsError extends Error {
constructor(
readonly filePath: string
) {
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
this.name = "LockFileExistsError";
}
}
/**
* Error thrown when the acquire or release called during an invalid state.
*/
export class LockFileBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockFileBusyError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
private handle: FileHandle | null = null;
private state: LockState = "idle";
constructor(
private readonly filePath: string
) {}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
}
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
this.state = "idle";
if (err?.code === "EEXIST") {
throw new LockFileExistsError(this.filePath);
}
throw err;
}
}
/**
* Release the lock file.
* Throws LockFileBusyError if not in acquired state.
*/
async release(): Promise<void> {
if (this.state !== "acquired") {
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
}
this.state = "releasing";
this.detachExitHandlers();
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
this.handle = null;
this.state = "idle";
}
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
return false;
}
}
/**
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
}
}
/**
* Attach process handlers, called when transitioning to "acquired"
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
process.off("SIGHUP", this.handleExit);
process.off("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
} catch (err) {
// Ignored: file may have already been removed by another process
}
};
}
+103 -65
View File
@@ -3,10 +3,10 @@ import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo, ModPack } from "./data";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
super();
}
getMod(name: string, version: string, sha1?: string) {
getMod(name: string, version: FullVersion, sha1?: string) {
const mod = this.files.get(ModInfo.filename(name, version));
if (!mod || sha1 && sha1 !== mod.sha1) {
return undefined;
@@ -87,11 +87,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: string) {
async loadMod(name: string, version: FullVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: string) {
async deleteMod(name: string, version: FullVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -155,10 +155,46 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
return new this(modsDirectory, files);
}
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
/**
* Stream a url's content into a file.
*
* @param url - Web path to stream from.
* @param filePath - File path to stream to.
*/
private static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
* Download a matching version from a selection of available versions.
*
* @param releases - Array of mod releases taken from the mod portal.
* @param version - The required version to install.
* @param username - Factorio username to download using.
* @param token - Factorio token to download using.
*/
private async downloadModFromReleases(
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
username: string, token: string
) {
const selectedRelease = releases.find(release => release.version === version);
const selectedRelease = releases.find(release => version.testVersion(release.version));
if (selectedRelease === undefined) {
logger.warn(`Mod release matching version ${version} not found.`);
@@ -190,96 +226,83 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
}
}
/**
* Downloads all provided mods, does not perform chunking or filtering of builtins
*
* @param mods - An array of mods to download
* @param username - Factorio username used for auth to download mods
* @param token - Factorio token used for auth to download mods
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
private async downloadModsChunk(
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
url.searchParams.set("version", factorioVersion);
const response = await fetch(url, {
method: "POST",
body: new URLSearchParams({ namelist: modNames.join(",") }),
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
});
if (response.status !== 200) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
const mods = (await response.json() as ModsInfoResponse).results;
await Promise.all(mods.map((mod) => {
const modReleases = (await response.json() as ModsInfoResponse).results;
await Promise.all(modReleases.map((mod) => {
const modVersion = mods.find(m => m.name === mod.name)!.version;
if (mod.releases) {
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
/**
* Downloads all provided mods to the mod dir.
*
* downloads mods to the mod dir, will not download mods for which are already in the mod directory
*
* @param modMap a map of what mods to download and what version to download
* @param username factorio username used for auth to download mods
* @param token factorio token used for auth to download mods
* @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions)
* Handles chunking to prevent errors due to URL length.
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
*
* @param mods - An array of mods to download.
* @param username - Factorio username used for auth to download mods.
* @param token - Factorio token used for auth to download mods.
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
) {
const chunkSize = 500;
let futures: Promise<void>[] = [];
modMap = new Map(modMap);
// Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks
const versionParts = factorioVersion.split(".");
if (versionParts.length > 2) {
factorioVersion = versionParts.slice(0, 2).join(".");
}
const futures: Promise<void>[] = [];
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
// get rid of mods already downloaded, built-in, or core
modMap.forEach((modVersion, modName, map) => {
const filename = `${modName}_${modVersion}.zip`;
// Filter existing, built-in from getBuiltinModNames, and explicitly filter "core"
if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") {
map.delete(modName);
}
});
const modNames = Array.from(modMap.keys());
const modVersions = Array.from(modMap.values());
for (let i = 0; i < modMap.size; i += chunkSize) {
for (let i = 0; i < mods.length; i += chunkSize) {
futures.push(
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize),
username, token, factorioVersion)
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
);
}
await Promise.all(futures);
}
static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
/**
* Filter an array of mods based on if they are installed.
*
* @param mods - Array of mods to filer.
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
* @returns Filtered array of mods based on install state.
*/
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
const installedMods = Array.from(this.mods());
return mods.filter(mod => (
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
));
}
/**
@@ -332,4 +355,19 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
/**
* Fetch the full release list for a mod including dependencies.
*
* @param modName - Name of the mod to fetch the releases of
* @returns Mod portal response
*/
static async fetchModReleases(modName: string) {
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
return await response.json() as ModsInfoResponse["results"][number];
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Represents an async function which can fetch a value for a ValueCache
*/
export interface ValueCacheFetch<T> {
(): Promise<T>;
}
/**
* In-memory single value cache
*
* Stores the last time a value was fetched and measures staleness
* Concurrent calls will share the same in-flight fetch
*/
export default class ValueCache<T> {
private ongoingFetch: Promise<T> | null = null;
private timestamp: number | null = null;
private value: T | null = null; // Only valid when timestamp is not null
/**
* Create a new in memory single value cache
*
* @param fetch A function that fetches and returns a fresh value
*/
constructor(
private readonly fetch: ValueCacheFetch<T>,
) {}
/**
* Get the cached value, will fetch a fresh value if stale or missing
*
* @param maxAgeMs Maximum allowed cache age in milliseconds.
* Defaults to 0, meaning it will always fetch a new value.
* @throws TypeError if maxAgeMs is invalid
* @throws Propagates errors from underlying fetch method
*/
get(maxAgeMs: number = 0): Promise<T> {
if (maxAgeMs < 0) {
throw new TypeError("maxAgeMs must be a non-negative number");
}
const now = Date.now();
if (this.timestamp && now - this.timestamp < maxAgeMs) {
return Promise.resolve(this.value as T);
}
if (this.ongoingFetch) {
return this.ongoingFetch;
}
this.ongoingFetch = this.fetch()
.then(value => {
this.timestamp = Date.now();
this.value = value;
return value;
})
.finally(() => {
this.ongoingFetch = null;
});
return this.ongoingFetch;
}
}
@@ -0,0 +1,21 @@
import { InstallationError } from "./errors";
declare global {
// eslint-disable-next-line vars-on-top
var _clusterioLibImportedFrom: string;
}
export function checkSingletonImport(location: string) {
if (!global._clusterioLibImportedFrom) {
global._clusterioLibImportedFrom = location;
} else {
throw new InstallationError(`Attempt to import duplicate copy of @clusterio/lib
Importing more than one copy of @clusterio/lib into the same runtime
will break Clusterio. This typically happens when the package
manager has installed two different versions of @clusterio/lib
and assigned different ones to different components of Clusterio.
Previously imported @clusterio/lib was located at ${_clusterioLibImportedFrom}
This copy of @clusterio/lib is located at ${location}`);
}
}
+4 -13
View File
@@ -35,6 +35,7 @@ export interface ControllerConfigFields {
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -258,7 +259,6 @@ export interface HostConfigFields {
"host.instances_directory": string;
"host.controller_url": string;
"host.controller_token": string;
"host.tls_ca": string | null;
"host.public_address": string;
"host.factorio_port_range": string;
"host.factorio_username": string | null,
@@ -267,6 +267,7 @@ export interface HostConfigFields {
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -325,12 +326,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
type: "string",
initialValue: "enter token here",
},
"host.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
restartRequired: true,
type: "string",
optional: true,
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
type: "string",
@@ -399,6 +394,7 @@ export interface InstanceConfigFields {
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
[key: `${string}.load_plugin`]: boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
@@ -476,6 +472,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "string",
initialValue: "latest",
inputComponent: "target_version",
},
"factorio.executable_path": {
description:
@@ -620,7 +617,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
export interface ControlConfigFields {
"control.controller_url": string | null;
"control.controller_token": string | null;
"control.tls_ca": string | null;
"control.max_reconnect_delay": number;
}
@@ -643,11 +639,6 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
type: "string",
optional: true,
},
"control.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
type: "string",
optional: true,
},
"control.max_reconnect_delay": {
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
+10 -5
View File
@@ -11,21 +11,26 @@ export default class ExportManifest {
/**
* Mapping between known assets and their file names.
*
* Currently exported assets are
* Currently exported assets are:
* - settings: JSON of mod settings prototypes.
* - prototypes: JSON of all game prototypes except settings.
* - item-spritesheet: PNG containing icons from the game.
* - item-metadata: JSON of Map entries describing icons in item-spritesheet
* - locale: JSON of flattened Map entries of en locale strings from the game.
* - spritesheet: Single PNG spritesheet containing all icon categories.
* - metadata: JSON array of [name, {x, y, size, category, path?}] entries
* describing sprite coordinates and category membership. Categories:
* item, recipe, signal, technology, planet, quality, entity, static.
*/
public assets: Record<string, string>
public assets: Record<string, string>,
/** ISO 8601 timestamp of when the export was uploaded. */
public exportedAt?: string,
) { }
static jsonSchema = Type.Object({
"assets": Type.Record(Type.String(), Type.String()),
"exportedAt": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof ExportManifest.jsonSchema>) {
return new this(json.assets);
return new this(json.assets, json.exportedAt);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { Type, Static } from "@sinclair/typebox";
export default class HostDetails {
constructor(
/** Version of the host software. The controller may warn if there is a mismatch. */
public version: string,
/** Human-readable display name of the host. */
public name: string,
/** Unique numeric identifier for this host. */
public id: number,
/** Whether the host is currently connected to the controller. */
public connected: boolean,
/** Address from which the host has connected to the controller. */
public remoteAddress = "",
/** Public-facing IP address of the host, if available. */
public publicAddress: string = "",
/** Unix timestamp (sec) after which issued tokens are considered valid. */
public tokenValidAfter: number = 0,
/** Unix timestamp (ms) indicating when this record was last updated. */
public updatedAtMs = 0,
/** Flag indicating the host record has been removed on the controller. */
public isDeleted = false
) {}
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted
);
}
toJSON() {
const json = {
version: this.version,
name: this.name,
id: this.id,
connected: this.connected,
} as Static<typeof HostDetails.jsonSchema>;
if (this.remoteAddress !== "") {
json.remoteAddress = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.publicAddress = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.tokenValidAfter = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.isDeleted = this.isDeleted;
}
return json;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Static, Type } from "@sinclair/typebox";
import { StringEnum } from "./composites";
import { TargetVersion, TargetVersionSchema } from "./version";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export default class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: TargetVersion | undefined,
public startedAtMs = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false
) {}
get isDeleted() {
return this.status === "deleted";
}
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(TargetVersionSchema),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
json.excludeFromStartAll
);
}
toJSON() {
const json = {
name: this.name,
id: this.id,
status: this.status,
} as Static<typeof InstanceDetails.jsonSchema>;
if (this.assignedHost !== undefined) {
json.assignedHost = this.assignedHost;
}
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.excludeFromStartAll !== false) {
json.excludeFromStartAll = this.excludeFromStartAll;
}
return json;
}
}
+133 -17
View File
@@ -5,9 +5,106 @@ import { Type, Static } from "@sinclair/typebox";
import * as libHash from "../hash";
import * as libSchema from "../schema";
import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
};
const depSpecRegex = /^(?:(\?|\(\?\)|!|~|\+) *)?(.+?)(?: *([<>]=?|=) *([0-9.]+))?$/;
export class ModDependency {
public spec: string;
public name: string;
public type: ModDependencyType;
public version: ModVersionEquality | undefined;
static getTypeFromPrefix(prefix: string): ModDependencyType {
switch (prefix) {
case "!":
return "incompatible";
case "?":
return "optional";
case "(?)":
return "hidden";
case "~":
return "unordered";
case "":
return "required";
default:
throw new Error(`Invalid dependency prefix "${prefix}"`);
}
}
constructor (
specification: string,
) {
const match = depSpecRegex.exec(specification);
if (match === null) {
throw new Error(`Invalid dependency specification "${specification}"`);
}
// Expand the match to the different parts
const [spec, type, name, equality, version] = match;
this.type = ModDependency.getTypeFromPrefix(type ?? "");
this.spec = spec;
this.name = name;
// Parse the equality and version
if ((equality || version) && !this.incompatible) {
this.version = ModVersionEquality.fromParts(equality, version);
}
}
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
const mod = mods.find(m => m.name === this.name);
if (mod === undefined) {
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
} else if (this.type === "incompatible") {
return "incompatible";
} else if (this.version && !this.version.testVersion(mod.version)) {
return "wrong_version";
}
return undefined;
}
isSatisfied(mods: (ModInfo | ModRecord)[]) {
return this.checkUnsatisfiedReason(mods) === undefined;
}
get incompatible() {
return this.type === "incompatible";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
get optional() {
return this.type === "hidden" || this.type === "optional";
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.spec;
}
}
/**
* Info about a mod available on the controller.
@@ -35,13 +132,13 @@ export default class ModInfo {
* Version of the mod.
* Sourced from info.json.
*/
version = "";
version = "0.0.0" as FullVersion;
/**
* Integer representation of the version
*/
get integerVersion() {
return integerModVersion(this.version);
return integerFullVersion(this.version);
}
/**
@@ -78,21 +175,38 @@ export default class ModInfo {
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12";
factorioVersion = "0.12" as "0.12" | ApiVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
* Dependiences for this mod.
* Dependencies for this mod.
* Sourced from info.json.
*/
dependencies = ["base"];
dependencies = [new ModDependency("base")];
/**
* Dependencies for this mod.
* As they would be represented in info.json.
*/
get dependencySpecifications() {
return this.dependencies.map(d => d.spec);
}
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
return this.dependencies
.map(d => d.checkUnsatisfiedReason(mods))
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
), undefined);
}
/**
* Expected name of zip file containing this mod.
@@ -107,7 +221,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: string) {
static filename(name: string, version: FullVersion) {
return `${name}_${version}.zip`;
}
@@ -137,13 +251,13 @@ export default class ModInfo {
// Content of info.json found in mod files
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"title": Type.String(),
"author": Type.String(),
"contact": Type.Optional(Type.String()),
"homepage": Type.Optional(Type.String()),
"description": Type.Optional(Type.String()),
"factorio_version": Type.Optional(Type.String()),
"factorio_version": Type.Optional(ApiVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -172,7 +286,13 @@ export default class ModInfo {
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
// Parse the dependencies
try {
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
} catch (err: any) {
throw new Error(`Failed for parse dependencies for ${json.name}: ${err}`);
}
// Additional data
if (json.size) { modInfo.size = json.size; }
@@ -195,8 +315,8 @@ export default class ModInfo {
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
json.dependencies = this.dependencies;
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
if (this.size) { json.size = this.size; }
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
@@ -227,10 +347,6 @@ export default class ModInfo {
throw new Error("Mod's info.json is not valid");
}
if (!modVersionRegExp.test(modInfo.version)) {
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
}
const stat = await fs.stat(modPath);
return this.fromJSON({
...modInfo,
+28 -17
View File
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import { integerFactorioVersion } from "./version";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
} from "./version";
export const ModSettingColor = Type.Object({
r: Type.Number(),
@@ -40,17 +46,21 @@ export interface ModRecord {
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod. */
version: string,
version: FullVersion,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
error?: "missing"|"bad_checksum",
error?: "missing" | "bad_checksum",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
info?: ModInfo,
}
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -82,13 +92,13 @@ export default class ModPack {
/**
* Version of Factorio this mod pack is for
*/
factorioVersion = "1.1.0";
factorioVersion = "1.1.0" as PartialVersion;
/**
* Integer representation of the factorioVersion
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
@@ -136,7 +146,7 @@ export default class ModPack {
"id": Type.Optional(Type.Integer()),
"name": Type.String(),
"description": Type.String(),
"factorio_version": Type.String(),
"factorio_version": PartialVersionSchema,
"mods": Type.Array(ModRecordJsonSchema),
"settings": Type.Object({
"startup": ModSettingsJsonSchema,
@@ -385,39 +395,40 @@ export default class ModPack {
/** Array of default mod packs which should exist on a newly installed cluster */
static defaultModPacks = [
...["0.17", "0.18", "1.0", "1.1", "2.0"]
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
factorio_version: version,
} as any)),
...["2.0"]
...(["2.0"] as const)
.map(version => this.fromJSON({
name: `Space Age ${version}`,
description: `Factorio ${version} with Space Age expansion.`,
factorio_version: version,
mods: [
{ name: "elevated-rails", enabled: true, version },
{ name: "quality", enabled: true, version },
{ name: "space-age", enabled: true, version },
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
];
/**
* Returns an array of ModInfo containing all mods included in factorio
* Returns an array of ModRecord containing all mods included in factorio
*
* @param factorioVersion - Factorio version to get the default mods for
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: string) {
static getBuiltinMods(factorioVersion: PartialVersion) {
factorioVersion = normaliseFullVersion(factorioVersion);
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: factorioVersion },
];
const integerVersion = integerFactorioVersion(factorioVersion);
if (integerVersion >= integerFactorioVersion("1.2")) {
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
@@ -428,7 +439,7 @@ export default class ModPack {
return defaultMods;
}
static getBuiltinModNames(factorioVersion: string) {
static getBuiltinModNames(factorioVersion: PartialVersion) {
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
}
}
+27 -1
View File
@@ -1,15 +1,23 @@
import { Static, Type } from "@sinclair/typebox";
import { permissions as globalPermissions } from "../permissions";
interface RoleCollection {
get(id: number): Role;
set(role: Role): void;
}
/**
* Represents a collection of granted permissions
*/
export default class Role {
static DefaultAdminRoleId = 0 as const;
static DefaultPlayerRoleId = 1 as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions: Set<string>,
public permissions = new Set<string>(),
public updatedAtMs = 0,
public isDeleted = false,
) { }
@@ -45,6 +53,13 @@ export default class Role {
};
}
static ensureDefaultPlayerRole(roles: RoleCollection) {
const playerRole = roles.get(this.DefaultPlayerRoleId)
?? new this(this.DefaultPlayerRoleId, "Player", "Default player role.");
playerRole.grantDefaultPermissions();
roles.set(playerRole);
}
grantDefaultPermissions() {
for (let permission of globalPermissions.values()) {
if (permission.grantByDefault) {
@@ -52,4 +67,15 @@ export default class Role {
}
}
}
static ensureDefaultAdminRole(roles: RoleCollection) {
const adminRole = roles.get(this.DefaultAdminRoleId)
?? new this(this.DefaultAdminRoleId, "Cluster Admin", "Cluster wide administrator.");
adminRole.grantAdminPermissions();
roles.set(adminRole);
}
grantAdminPermissions() {
this.permissions.add("core.admin");
}
}
@@ -2,12 +2,10 @@ import { Static, Type } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
*/
export default class User {
/** Represents a user in the cluster */
export default class UserDetails {
public playerStats: PlayerStats;
constructor(
/** Factorio user name. */
public name: string,
@@ -27,11 +25,15 @@ export default class User {
public updatedAtMs = 0,
/** True if this user object has been removed from the cluster. */
public isDeleted = false,
/** Combined statistics for the player this user account is tied to. */
public playerStats = new PlayerStats(),
/** Per instance statistics for the player this user account is tied to. */
public instanceStats = new Map<number, PlayerStats>(),
) {
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
static jsonSchema = Type.Object({
@@ -51,17 +53,13 @@ export default class User {
),
});
static fromJSON(json: Static<typeof this.jsonSchema>, _hack?: any) {
const roleIds = new Set<number>(json.roles ?? []);
static fromJSON(json: Static<typeof this.jsonSchema>) {
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
(json.instance_stats ?? []).map(([id, stats]) => [id, PlayerStats.fromJSON(stats)])
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
json.name,
roleIds,
new Set<number>(json.roles),
new Set(json.instances),
json.is_admin,
json.is_banned,
@@ -69,13 +67,12 @@ export default class User {
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON() {
let json: Static<typeof User.jsonSchema> = {
let json: Static<typeof UserDetails.jsonSchema> = {
name: this.name,
};
@@ -119,7 +116,7 @@ export default class User {
}
recalculatePlayerStats() {
this.playerStats = User._calculatePlayerStats(this.instanceStats);
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
}
static _calculatePlayerStats(instanceStatsMap: Map<number, PlayerStats>) {
@@ -149,44 +146,13 @@ export default class User {
}
return playerStats;
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: User) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isBanned = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
this.recalculatePlayerStats();
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
}
export interface IControllerUser extends User {
/**
* This interface is implemented by User on the controller.
* It is exposed here to allow message definitions to create permission methods.
*/
export interface IUser extends UserDetails {
/**
* Check if a given permission is granted
*
+5 -3
View File
@@ -4,7 +4,7 @@
* @author Hornwitser
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModInfo, ModDependency } from "./ModInfo";
export { ModSettingColor } from "./ModPack";
export type { ModSetting, ModRecord } from "./ModPack";
export { default as ModPack } from "./ModPack";
@@ -12,8 +12,10 @@ export { default as ModuleInfo } from "./ModuleInfo";
export { default as Permission } from "./Permission";
export { default as PlayerStats } from "./PlayerStats";
export { default as Role } from "./Role";
export { default as User } from "./User";
export type { IControllerUser } from "./User";
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails";
export { default as HostDetails } from "./HostDetails";
export { default as UserDetails } from "./UserDetails";
export type { IUser } from "./UserDetails";
export * from "./composites";
export * from "./messages_core";
export * from "./messages_controller";
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
import { ExternalFactorioVersionSchema } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -259,6 +260,8 @@ export class SystemInfo {
public diskAvailable: number,
public canRestart: boolean,
public restartRequired: boolean,
public systemStartedAtMs: number,
public processStartedAtMs: number,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number,
public isDeleted: boolean,
@@ -278,6 +281,8 @@ export class SystemInfo {
"diskAvailable": Type.Number(),
"canRestart": Type.Boolean(),
"restartRequired": Type.Boolean(),
"systemStartedAtMs": Type.Number(),
"processStartedAtMs": Type.Number(),
"updatedAtMs": Type.Number(),
"isDeleted": Type.Boolean(),
});
@@ -297,6 +302,8 @@ export class SystemInfo {
json.diskAvailable,
json.canRestart,
json.restartRequired,
json.systemStartedAtMs,
json.processStartedAtMs,
json.updatedAtMs,
json.isDeleted,
);
@@ -391,3 +398,25 @@ export class DebugWsMessageEvent {
return new this(json.direction, json.content);
}
}
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" as const;
constructor(
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
) {}
static jsonSchema = Type.Object({
"maxAgeMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
+1 -41
View File
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { jsonArray, plainJson, StringEnum } from "./composites";
import { CollectorResultSerialized } from "../prometheus";
import { HostConfig } from "../config/definitions";
import HostDetails from "./HostDetails";
export class HostStopRequest {
declare ["constructor"]: typeof HostStopRequest;
@@ -82,47 +83,6 @@ export class HostConfigSetPropRequest {
}
}
export class HostDetails {
constructor(
public version: string,
public name: string,
public id: number,
public connected: boolean,
public remoteAddress = "",
public publicAddress: string = "",
public tokenValidAfter: number = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public isDeleted = false,
) { }
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted,
);
}
}
export class HostListRequest {
declare ["constructor"]: typeof HostListRequest;
static type = "request" as const;
+9 -81
View File
@@ -2,83 +2,11 @@ import { Type, Static } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
import { JsonString, StringEnum, jsonArray, plainJson } from "./composites";
import { InstanceConfig } from "../config";
import type { IControllerUser } from "./User";
import type { IUser } from "./UserDetails";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: string | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(Type.String()),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.updatedAtMs,
json.excludeFromStartAll,
);
}
get isDeleted() {
return this.status === "deleted";
}
}
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
import InstanceDetails, { InstanceStatus } from "./InstanceDetails";
export class InstanceDetailsGetRequest {
declare ["constructor"]: typeof InstanceDetailsGetRequest;
@@ -535,7 +463,7 @@ export class InstanceTransferSaveRequest {
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = ["controller", "host"] as const;
static permission(user: IControllerUser, message: MessageRequest) {
static permission(user: IUser, message: MessageRequest) {
user.checkPermission("core.instance.save.transfer");
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as { copy: boolean, sourceName: string, targetName: string };
@@ -744,8 +672,8 @@ export class HostInstanceUpdate {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public status: InstanceStatus,
public gamePort: undefined | number,
public factorioVersion: undefined | string,
public gamePort: number | undefined,
public factorioVersion: PartialVersion | undefined,
) { }
static jsonSchema = Type.Object({
@@ -754,7 +682,7 @@ export class HostInstanceUpdate {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(PartialVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -852,7 +780,7 @@ export class InstanceStatusChangedEvent {
public instanceId: number,
public status: InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: TargetVersion,
) { }
static jsonSchema = Type.Object({
@@ -861,7 +789,7 @@ export class InstanceStatusChangedEvent {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Integer()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
+122 -30
View File
@@ -1,8 +1,14 @@
import { Type, Static } from "@sinclair/typebox";
import ModInfo from "./ModInfo";
import ModInfo, { ModDependency } from "./ModInfo";
import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
ModVersionEquality,
} from "./version";
export class ModPackGetRequest {
declare ["constructor"]: typeof ModPackGetRequest;
@@ -113,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -148,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: string,
public factorioVersion: ApiVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -157,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": Type.String(),
"factorioVersion": ApiVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -199,9 +205,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: Type.String(),
version: FullVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: Type.String() }),
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -229,20 +235,13 @@ export class ModPortalGetAllRequest {
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
// Define allowed Factorio versions
static allowedVersions = [
Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"),
Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"),
Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"),
] as const;
constructor(
public factorioVersion: Static<typeof ModPortalGetAllRequest.allowedVersions[number]>,
public factorioVersion: ApiVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
"factorioVersion": ApiVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -277,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -303,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -356,6 +355,16 @@ export class ModUpdatesEvent {
}
}
export interface ModNameVersionPair {
name: string,
version: ModVersionEquality,
}
export const ModNameVersionPairSchema = Type.Object({
"name": Type.String(),
"version": ModVersionEquality.jsonSchema,
});
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
@@ -363,8 +372,7 @@ export class ModUpdatesEvent {
* Requires Factorio credentials to be configured on the controller if the portal
* requires authentication for downloads.
*
* @param name - Name of the mod to download.
* @param version - Version of the mod to download.
* @param mods - Array of mods to be downloaded.
* @param factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
@@ -373,21 +381,105 @@ export class ModPortalDownloadRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = ModInfo;
static Response = jsonArray(ModInfo);
constructor(
public name: string,
public version: string,
public factorioVersion: string,
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
return new this(
json.mods.map(mod => ({
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
})),
json.factorioVersion,
);
}
}
const ModDependencyResolveErrors = ["incompatible", "unsatisfiable", "invalidDependency", "notFound"] as const;
export const ModDependencyResolveErrorsSchema = Type.Union(ModDependencyResolveErrors.map(v => Type.Literal(v)));
export type ModDependencyResolveErrors = Static<typeof ModDependencyResolveErrorsSchema>;
/**
* Request mods dependencies to be resolved using the Factorio mod portal.
*
* @param mods - Array of dependencies to resolve.
* @param factorioVersion - Factorio version context for resolution.
*/
export class ModDependencyResolveRequest {
declare ["constructor"]: typeof ModDependencyResolveRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"checkForUpdates": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
}
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static Response = class ModDependencyResolveResponse {
constructor(
public dependencies: ModInfo[],
public errors: Map<string, ModDependencyResolveErrors>,
) {}
static jsonSchema = Type.Object({
"dependencies": Type.Array(ModInfo.jsonSchema),
"errors": Type.Record(Type.String(), ModDependencyResolveErrorsSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.dependencies.map(d => ModInfo.fromJSON(d)),
new Map(Object.entries(json.errors))
);
}
toJSON(): Static<typeof ModDependencyResolveResponse.jsonSchema> {
return {
dependencies: this.dependencies.map(d => d.toJSON()),
errors: Object.fromEntries(this.errors),
};
}
};
}
+7 -7
View File
@@ -1,5 +1,5 @@
import { Type, Static } from "@sinclair/typebox";
import User, { IControllerUser } from "./User";
import UserDetails, { IUser } from "./UserDetails";
import { StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
@@ -22,7 +22,7 @@ export class UserGetRequest {
return new this(json.name);
}
static Response = User;
static Response = UserDetails;
}
export class UserListRequest {
@@ -31,7 +31,7 @@ export class UserListRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.list" as const;
static Response = jsonArray(User);
static Response = jsonArray(UserDetails);
}
export class UserCreateRequest {
@@ -198,15 +198,15 @@ export class UserUpdatesEvent {
static permission = "core.user.subscribe" as const;
constructor(
public updates: User[],
public updates: UserDetails[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(User.jsonSchema),
"updates": Type.Array(UserDetails.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => User.fromJSON(update)));
return new this(json.updates.map(update => UserDetails.fromJSON(update)));
}
}
@@ -250,7 +250,7 @@ export class UserBulkImportRequest {
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission(user: IControllerUser, message: MessageRequest) {
static permission(user: IUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof UserBulkImportRequest.jsonSchema>;
// Check if this is a restore or import request
+224 -16
View File
@@ -1,33 +1,241 @@
// Helper functions for dealing with factorio version numbers
import { Static, Type } from "@sinclair/typebox";
/**
* Integer representation of a mod version
* Integer representation of a version string.
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
*/
export function integerModVersion(version: string) {
export type IntegerVersion = number & { [integerVersionSymbol]: void };
declare const integerVersionSymbol: unique symbol;
/**
* Allowed version equalities as defined within mod info json
* https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
*/
const versionEqualities = ["<", "<=", "=", ">=", ">"] as const;
export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v)));
export type VersionEquality = Static<typeof VersionEqualitySchema>;
export function isVersionEquality(input: string): input is VersionEquality {
return versionEqualities.includes(input as any);
}
/**
* Matches valid factorio versions accepts by the web API
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
*/
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
export type ApiVersion = Static<typeof ApiVersionSchema>;
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
}
export function normaliseApiVersion(version: PartialVersion) {
const parts = version.split(".");
const apiVersion = `${parts[0]}.${parts[1]}`;
if (!isApiVersion(apiVersion)) {
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
}
return apiVersion;
}
/**
* Matches valid mod versions, where all parts are specified.
*/
const fullVersionRegExp = /^\d+\.\d+\.\d+$/;
export type FullVersion = `${number}.${number}.${number}`;
export const FullVersionSchema = Type.Unsafe<FullVersion>(
Type.String({ pattern: fullVersionRegExp.source })
);
export function isFullVersion(input: string): input is FullVersion {
return fullVersionRegExp.test(input);
}
export function integerFullVersion(version: FullVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
// Can't use bitwise here because this is 48-bits.
return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion;
}
export function normaliseFullVersion(version: PartialVersion) {
const parts = version.split(".");
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Integer representation of a factorio version
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
*/
export function integerFactorioVersion(version: string) {
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
export type PartialVersion = FullVersion | `${number}.${number}`;
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
export function isPartialVersion(input: string): input is PartialVersion {
return partialVersionRegExp.test(input);
}
export function integerPartialVersion(version: PartialVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
// Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0.
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
}
/**
* Matches valid mod versions.
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
*/
export const modVersionRegExp = /^\d+\.\d+\.\d+$/;
export type TargetVersion = PartialVersion | "latest";
export const TargetVersionSchema = Type.Union([
PartialVersionSchema,
Type.Literal("latest"),
]);
export function isTargetVersion(input: string): input is TargetVersion {
return input === "latest" || partialVersionRegExp.test(input);
}
/**
* Represents a mod version paired with an equality which can be tested against
*/
export class ModVersionEquality {
public integerVersion: IntegerVersion;
constructor(
public equality: VersionEquality,
public version: PartialVersion,
) {
this.integerVersion = integerPartialVersion(version);
if (!isVersionEquality(this.equality)) {
throw new Error("Invalid equality");
}
if (!isPartialVersion(this.version)) {
throw new Error("Invalid version");
}
}
testIntegerVersion(other: IntegerVersion) {
switch (this.equality) {
case "<":
return other < this.integerVersion;
case "<=":
return other <= this.integerVersion;
case "=":
return other === this.integerVersion;
case ">=":
return other >= this.integerVersion;
case ">":
return other > this.integerVersion;
default:
throw new Error("unreachable");
}
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
toString() {
return `${this.equality} ${this.version}`;
}
static fromString(version: string) {
const parts = version.split(" ");
return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]);
}
static fromParts(equality: string, version: string) {
if (!isVersionEquality(equality)) {
throw new Error(`Invalid version equality "${equality}"`);
}
if (!isPartialVersion(version)) {
throw new Error(`Invalid version string "${version}"`);
}
return new this(equality, version);
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return this.fromString(json);
}
toJSON() {
return this.toString();
}
}
/**
* Represents a range of mod versions which can be tested against
*/
export class ModVersionRange {
constructor(
public minVersion = new ModVersionEquality(">=", "0.0.0"),
public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"),
) {
if (minVersion.equality === "<" || minVersion.equality === "<=") {
throw new Error("Minimum version can not use < or <=");
}
if (maxVersion.equality === ">" || maxVersion.equality === ">=") {
throw new Error("Maximum version can not use > or >=");
}
}
get valid() {
return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion)
&& this.maxVersion.testIntegerVersion(this.minVersion.integerVersion);
}
invalidate() {
this.maxVersion = new ModVersionEquality(">", "0.0.0");
}
testIntegerVersion(other: IntegerVersion) {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
combineVersion(other: ModVersionEquality) {
switch (other.equality) {
case "<":
if (other.integerVersion <= this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "<=":
if (other.integerVersion < this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "=":
if (this.testIntegerVersion(other.integerVersion)) {
this.minVersion = other;
this.maxVersion = other;
} else {
// Make invalid as it could not be satisfied
this.invalidate();
}
break;
case ">=":
if (other.integerVersion > this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
case ">":
if (other.integerVersion >= this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
default:
throw new Error("unreachable");
}
}
}
+77 -13
View File
@@ -53,7 +53,7 @@ export class JsonDatastoreProvider<
private filePath: string,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
private finalise: (obj: V) => V = v => v,
private finalise: (obj: V, map: Map<string, V>) => V | undefined = v => v,
) {
super();
}
@@ -82,7 +82,24 @@ export class JsonDatastoreProvider<
const serialized = this.migrations(rawJson);
// Convert to data class objects
return new Map(Object.entries(serialized).map(([k, v]) => [k, this.finalise(this.fromJson(v))]));
let serializedCount = 0;
const result = new Map<string, V>();
for (const [key, raw] of Object.entries(serialized)) {
const final = this.finalise(this.fromJson(raw), result);
if (final !== undefined) { result.set(key, final); }
serializedCount += 1;
}
// Save a backup if the map does not contain all values (happens where there are duplicates)
if (result.size < serializedCount) {
const backupPath = `${this.filePath}.${Date.now()}.bak`;
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
}
}
@@ -95,7 +112,7 @@ export class JsonIdDatastoreProvider<
private filePath: string,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
private finalise: (obj: V) => V = v => v,
private finalise: (obj: V, map: Map<V["id"], V>) => V | undefined = v => v,
) {
super();
}
@@ -124,10 +141,24 @@ export class JsonIdDatastoreProvider<
const serialized = this.migrations(rawJson);
// Convert to data class objects
return new Map(serialized.map((e) => {
const v = this.finalise(this.fromJson(e));
return [v.id, v];
}));
let serializedCount = 0;
const result = new Map<V["id"], V>();
for (const raw of serialized) {
const final = this.finalise(this.fromJson(raw), result);
if (final !== undefined) { result.set(final.id, final); }
serializedCount += 1;
}
// Save a backup if the map does not contain all values (happens where there are duplicates)
if (result.size < serializedCount) {
const backupPath = `${this.filePath}.${Date.now()}.bak`;
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
}
}
@@ -259,18 +290,50 @@ export class KeyValueDatastore<
export class SubscribableDatastore<
V extends SubscribableValue,
> extends Datastore<V["id"], V> {
/** Last time an update occurred */
lastUpdatedAtMs: number = 0;
private static maxUpdatedAtMs(values: Iterable<SubscribableValue>) {
let max = 0;
for (const value of values) {
// Prevent one NaN, undefined, +Inf or similar from breaking the datastore.
if (Number.isFinite(value.updatedAtMs)) {
max = Math.max(max, value.updatedAtMs);
}
}
return max;
}
private incrementLastUpdatedMs() {
this.lastUpdatedAtMs = Math.max(Date.now(), this.lastUpdatedAtMs + 1);
}
constructor(
provider: DatastoreProvider<V["id"], V> = new MemoryDatastoreProvider(),
data = new Map<V["id"], V>(),
) {
super(provider, data);
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
}
async load() {
await super.load();
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
}
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
set(value: V) {
this.incrementLastUpdatedMs();
this.data.set(value.id, value);
value.updatedAtMs = Date.now();
value.updatedAtMs = this.lastUpdatedAtMs;
this.touch([value]);
}
// Set many values at once from an array of key value pairs
setMany(values: V[]) {
const nowMs = Date.now();
this.incrementLastUpdatedMs();
for (const value of values) {
value.updatedAtMs = nowMs;
value.updatedAtMs = this.lastUpdatedAtMs;
this.data.set(value.id, value);
}
this.touch(values);
@@ -278,18 +341,19 @@ export class SubscribableDatastore<
// Delete a value in the datastore
delete(value: V) {
this.incrementLastUpdatedMs();
this.data.delete(value.id);
value.updatedAtMs = Date.now();
value.updatedAtMs = this.lastUpdatedAtMs;
value.isDeleted = true;
this.touch([value]);
}
// Delete many values at once from an array of values
deleteMany(values: V[]) {
const nowMs = Date.now();
this.incrementLastUpdatedMs();
for (const value of values) {
value.isDeleted = true;
value.updatedAtMs = nowMs;
value.updatedAtMs = this.lastUpdatedAtMs;
this.data.delete(value.id);
}
this.touch(values);
+7
View File
@@ -10,6 +10,13 @@ export class CommandError extends Error {
code = "CommandError";
}
/**
* Thrown if the installation is broken, such as when multiple copies of lib is imported
*/
export class InstallationError extends Error {
code = "InstallationError";
}
/**
* Thrown from requests sent when an error occured handling it
*/
+69
View File
@@ -0,0 +1,69 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive/";
/**
* Represents the parsed result of a factorio version
*/
export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
/**
* Fetch all factorio versions that support headless
*
* @returns A list of all factorio versions and their download URL
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res;
try {
res = await fetch(ARCHIVE_URL);
} catch (err) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`);
}
const html = await res.text();
return parseFactorioVersions(html);
}
/**
* Parse all versions from the factorio archive webpage
*
* @param html The HTML content of the archive page
* @returns A list of all factorio versions and their download URL
*/
function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
const versions = new Map<string, ExternalFactorioVersion>();
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
let match: RegExpExecArray | null;
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while ((match = anchorRegex.exec(html)) !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) {
versions.set(version, {
version: version,
stable: kind === "stable",
headlessUrl: `https://www.factorio.com/get-download/${version}/headless/linux64`,
});
}
}
return [...versions.values()];
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Library for interfacing with external apis
* @module lib/external
*/
export * from "./FactorioVersions";
+1 -12
View File
@@ -431,7 +431,6 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector<We
constructor(
protected _url: string,
maxReconnectDelay: number,
protected _tlsCa: string | undefined,
) {
super(undefined as any, new libData.Address(libData.Address.controller, 0));
this._backoff = new ExponentialBackoff({ max: maxReconnectDelay });
@@ -543,17 +542,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector<We
// Open WebSocket to controller
logger.verbose(`Connector | connecting to ${url}`);
if (process.env.APP_ENV === "browser") {
this._socket = new WebSocket(url) as WebSocketClusterio;
} else {
let options: { ca?: string } = {};
if (this._tlsCa) { options.ca = this._tlsCa; }
this._socket = new WebSocket(url, options) as WebSocketClusterio;
}
this._socket = new WebSocket(url) as WebSocketClusterio;
this._attachSocketHandlers();
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { BaseConnector, WebSocketBaseConnector } from "./connectors";
import { strict as assert } from "assert";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import type {
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser,
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
} from "../data";
export interface Request<Req, Res> {
@@ -18,7 +18,7 @@ export interface Request<Req, Res> {
type: "request";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void);
permission?: null | string | ((user: IUser, message: MessageRequest) => void);
plugin?: string;
Response?: JSONDeserialisable<Res>,
}
@@ -32,7 +32,7 @@ export interface Event<T> {
type: "event";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void);
permission?: null | string | ((user: IUser, message: MessageEvent) => void);
plugin?: string;
}
}
+2
View File
@@ -35,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.SystemInfoUpdateEvent,
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
subscriptions.SubscriptionRequest,
@@ -108,6 +109,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
mod.ModDependencyResolveRequest,
role.PermissionListRequest,
role.RoleListRequest,
+7 -51
View File
@@ -6,9 +6,7 @@
* @author Hornwitser
* @module lib/users
*/
import { Permission, Role } from "./data";
import { SubscribableDatastore } from "./datastore";
import { Permission } from "./data";
export const permissions = new Map<string, Permission>();
@@ -58,54 +56,6 @@ export function definePermission({
permissions.set(name, new Permission(name, title, description, grantByDefault));
}
/**
* Ensure the default admin role has full access
*
* Ensures the role with id 0 exisits and has the core.admin permission.
*
* @param roles - role storage to modify.
* @returns the default admin role
*/
export function ensureDefaultAdminRole(roles: SubscribableDatastore<Role>) {
let admin = roles.get(0);
if (!admin) {
admin = Role.fromJSON({
id: 0,
name: "Cluster Admin",
description: "Cluster wide administrator.",
permissions: [],
});
}
admin.permissions.add("core.admin");
roles.set(admin);
return admin;
}
/**
* Ensure the default player role has default accesses
*
* Ensures the role with id 1 exists and has all the permissions that are
* assigned by default. Note that this may not be the same role as the one
* that is assigned to new users by default.
*
* @param roles - role storage to modify.
*/
export function ensureDefaultPlayerRole(roles: SubscribableDatastore<Role>) {
let player = roles.get(1);
if (!player) {
player = Role.fromJSON({
id: 1,
name: "Player",
description: "Default player role.",
permissions: [],
});
}
player.grantDefaultPermissions();
roles.set(player);
}
// Definitions for the built in permissions used in Clusterio.
// description should answer "this permission allows you to ___"
definePermission({
@@ -568,3 +518,9 @@ definePermission({
title: "Dump WebSocket",
description: "Dump all WebSocket communicatation from the controller.",
});
definePermission({
name: "core.external.get_factorio_versions",
title: "Get factorio versions",
description: "Get list of all factorio versions from Wube",
});
+11 -2
View File
@@ -4,6 +4,7 @@
* @module lib/plugin_loader
*/
import path from "path";
import fs from "fs/promises";
import * as libErrors from "./errors";
import * as libPlugin from "./plugin";
import { logger } from "./logging";
@@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
// Check if plugin path exists, otherwise remove it
try {
require.resolve(pluginPath);
} catch (err) {
logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`);
} catch {
let errMsg = `Plugin path ${pluginPath} does not exist`;
try {
await fs.access(pluginPath, fs.constants.F_OK);
errMsg = `Plugin path ${pluginPath} missing index or main file`;
} catch {}
logger.error(`${errMsg}, not loading ${pluginName}`);
pluginList.delete(pluginName);
continue;
}
@@ -39,6 +45,9 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
pluginInfo = require(pluginPath).plugin;
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new libErrors.PluginError(pluginName, err);
}
+20 -4
View File
@@ -272,15 +272,31 @@ function labelsToKey(labels: LabelValues, metricLabels: string[]) {
function keyToLabels(key: string) {
let labels = new Map<string, string>();
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
let pos = 0;
let matchLabel = /([^=]+)="(([^\\"]|\\["n\\])+)"/y;
while (pos < key.length) {
if (pos !== 0) {
/* istanbul ignore if */
if (key[pos] !== ",") {
// Should be unreachable
throw Error(`Expected , at pos ${pos} in "${key}"`);
}
pos += 1;
}
matchLabel.lastIndex = pos;
const match = matchLabel.exec(key);
/* istanbul ignore else */
if (match) {
let [, name, value] = match;
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
);
pos += match[0].length;
} else {
// Should be unreachable
throw new Error(`Failed to parse label at pos ${pos} in "${key}"`);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { RequestError } from "./errors";
import { PluginNodeEnvInfo } from "./plugin";
const execAsync = util.promisify(exec);
async function isDev() {
function isDev() {
// dev: <devRoot>/packages/lib/dist/src/rce_ops.js
// prod: <prodRoot>/node_modules/@clusterio/lib/dist/src/rce_ops.js
return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
+7 -1
View File
@@ -8,6 +8,7 @@ import * as libConfig from "./config";
import * as libFileOps from "./file_ops";
import { logger } from "./logging";
import * as libHelpers from "./helpers";
import { LockFile } from "./LockFile";
function print(...content: any[]) {
@@ -130,11 +131,12 @@ export function configCommand(yargs: any) {
*
* @param args - yargs args object.
* @param instance - Config instance.
* @param configPath - Path to configuration file.
* @param lockFile - Lockfile required if attempting to write
*/
export async function handleConfigCommand(
args: Record<string, unknown>,
instance: libConfig.Config<any>,
lockFile: LockFile,
) {
let command = (args._ as string[])[1];
@@ -155,6 +157,8 @@ export async function handleConfigCommand(
}
} else if (command === "set") {
await lockFile.acquire();
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
@@ -172,5 +176,7 @@ export async function handleConfigCommand(
throw err;
}
}
await lockFile.release();
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IControllerUser, plainJson, JsonBoolean } from "./data";
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError, SessionLost } from "./errors";
import { logger } from "./logging";
@@ -18,7 +18,7 @@ export class SubscriptionRequest {
static type = "request" as const;
static src = ["control", "host", "instance"] as const;
static dst = "controller" as const;
static permission(user: IControllerUser, message: MessageRequest) {
static permission(user: IUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
+10 -2
View File
@@ -134,7 +134,12 @@ function minZip<T>(a: T[], b: T[]): [T, T][] {
let previousTotalCpuMs: number[] = [];
let previousIdleCpuMs: number[] = [];
export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean, restartRequired: boolean) {
export async function gatherSystemInfo(
id: SystemInfo["id"],
canRestart: boolean,
restartRequired: boolean,
startedAtMs: number
) {
const cpus = os.cpus();
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
const currentIdleCpuMs = cpus.map(({ times }) => times.idle);
@@ -152,6 +157,7 @@ export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean
diskAvailable = stats.bavail * stats.bsize;
}
const now = Date.now();
return new SystemInfo(
id,
os.hostname(),
@@ -166,7 +172,9 @@ export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean
diskAvailable,
canRestart,
restartRequired,
Date.now(),
now - (os.uptime() * 1000),
startedAtMs,
now,
false,
);
}
+3
View File
@@ -6,6 +6,8 @@
"./src/api.ts",
"./src/config/*.ts",
"./src/data/*.ts",
"./src/external/*.ts",
"./src/check_singleton_import.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/factorio/exchange_string.ts",
@@ -25,6 +27,7 @@
"./src/ExponentialBackoff.ts",
"./src/RateLimiter.ts",
"./src/TypedEventEmitter.ts",
"./src/ValueCache.ts",
],
"compilerOptions": {
"rootDir": ".",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "../../tsconfig.node.json",
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"include": ["index.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"allowJs": true,
},
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.23",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
+5 -1
View File
@@ -9,6 +9,8 @@ import App from "./components/App";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputRole from "./components/InputRole";
import InputModPack from "./components/InputModPack";
import InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -109,6 +111,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
"role": InputRole,
};
@@ -133,7 +137,7 @@ export default async function bootstrap() {
lib.addPluginConfigFields(pluginInfos);
let wsUrl = new URL(webRoot, document.location.href);
let controlConnector = new ControlConnector(wsUrl.href, 120, undefined);
let controlConnector = new ControlConnector(wsUrl.href, 120);
let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])));
control.plugins = await loadPlugins(pluginInfos, control);
control.inputComponents = inputComponentsFromPlugins(control.plugins);
@@ -8,7 +8,7 @@ import PluginExtra from "./PluginExtra";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
} from "./system_metrics";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
@@ -34,9 +34,10 @@ function ControllerControlButton({ canRestart, restartRequired }: ControllerCont
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.send(
@@ -100,6 +101,12 @@ export default function ControllerPage() {
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
@@ -0,0 +1,17 @@
import { Tag } from "antd";
import React from "react";
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
const statusOptions = {
connected: <Tag color="#389e0d" icon={<CheckCircleOutlined/>} >Connected</Tag>,
disconnected: <Tag color="#cf1322" icon={<CloseCircleOutlined/>} >Disconnected</Tag>,
};
type HostStatusTagProps = {
connected: boolean;
};
export default function HostStatusTag(props: HostStatusTagProps) {
return props.connected ? statusOptions.connected : statusOptions.disconnected;
}
+17 -14
View File
@@ -1,5 +1,5 @@
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm, Flex, Tooltip } from "antd";
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { useParams } from "react-router-dom";
@@ -7,6 +7,7 @@ import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import HostStatusTag from "./HostStatusTag";
import InstanceList from "./InstanceList";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import { useAccount } from "../model/account";
@@ -17,7 +18,7 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
} from "./system_metrics";
import { formatTimestamp } from "../util/time_format";
import { useSystems } from "../model/system";
@@ -37,9 +38,10 @@ function HostControlButton({ hostId, canRestart, restartRequired }: HostControlB
actions.push({
key: "restart",
label: (
<Tooltip title={restartRequired ? "Restart Required" : null}>
Restart {restartRequired ? <ExclamationCircleOutlined style={{ color: "yellow" }}/> : undefined}
</Tooltip>
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.sendTo(
@@ -105,7 +107,7 @@ export default function HostViewPage() {
</PageLayout>;
}
let hostButtons = <Space>
let hostButtons = <Space wrap>
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
{
account.hasPermission("core.host.revoke_token")
@@ -133,21 +135,22 @@ export default function HostViewPage() {
return <PageLayout nav={nav}>
<PageHeader
title={host.name || String(hostId)}
status={<HostStatusTag connected={host["connected"]}/>}
extra={hostButtons}
/>
<Descriptions bordered size="small" column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="Name">{host["name"]}</Descriptions.Item>
<Descriptions.Item label="Connected">
<Tag color={host["connected"] ? "#389e0d" : "#cf1322"}>
{host["connected"] ? "Connected" : "Disconnected"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Version">{host.version}</Descriptions.Item>
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From" span={2}>{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname">{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From">{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
<Descriptions.Item label="Cores"><MetricCpuUsed system={system} /></Descriptions.Item>
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more