Compare commits

..
Author SHA1 Message Date
Cooldude2606 5b56aa3fea v2.0.0-alpha.21 2025-05-26 00:29:28 +01:00
Daniel 56479246ed Merge pull request #767 from Cooldude2606/tests/factorio-2.0
Bump tests: Remove Node 18 & Add Factorio 2.0
2025-05-25 23:31:27 +02:00
Danielv123 773c98b31a Prepare factorio 2.0 in devcontainer 2025-05-25 22:46:25 +02:00
Cooldude2606 4f6abca9d7 Bump ts module as per node 20 recommended 2025-05-21 17:13:54 +01:00
Cooldude2606 0bbaef4751 Remove node 18 add factorio 2.0 2025-05-21 17:13:54 +01:00
Cooldude2606 3ff80119e6 Fix flaking tests 2025-05-21 17:13:43 +01:00
Cooldude2606 63e30dd0ee Add missing migrations for system info and tests (#766)
* Add missing migration for SystemInfo

* Add missing migration tests
2025-05-21 17:07:24 +01:00
Daniel 6e01ba18c5 Merge pull request #765 from Cooldude2606/fix/exchange-strings-v2
Impliment decode for map exchange strings v2
2025-05-19 21:17:03 +02:00
Daniel a39911d667 Merge pull request #764 from Cooldude2606/refactor/config-file-path
Refactor config file paths into the config class
2025-05-19 20:52:14 +02:00
Cooldude2606 4c5ab21c64 Fix version comparison 2025-05-18 21:37:46 +01:00
Daniel cca1d4efc0 Merge pull request #763 from Cooldude2606/feature/whitelist-watching
Add bidirectional whitelist sync
2025-05-18 15:50:08 +02:00
Daniel 78d4217a2a Merge pull request #762 from Cooldude2606/feature/remote-update
Add core package updates + plugin listing, updating, and remote install
2025-05-17 23:26:19 +02:00
Cooldude2606 25a5a2257c Fix test that depends on map strings 2025-05-16 19:40:17 +01:00
Cooldude2606 0f8075ad41 Implement v2 map strings 2025-05-16 19:39:43 +01:00
Cooldude2606 e089e2df4c Improve exchange string tests 2025-05-15 16:46:21 +01:00
Cooldude2606 cd20464952 Fix grammar error 2025-05-13 23:14:20 +01:00
Cooldude2606 cb23428507 Respond to comments and add docs 2025-05-12 23:19:14 +01:00
Cooldude2606 f285d7d803 Add tests for save and load 2025-05-12 22:00:40 +01:00
Cooldude2606 117713eda3 Fix silent test for alt host 2025-05-12 21:32:45 +01:00
Cooldude2606 515093abd2 Improve error message logged for config load error 2025-05-12 21:32:44 +01:00
Cooldude2606 726a3538c2 Move save and load into config class 2025-05-12 21:32:44 +01:00
Cooldude2606 17db53403a Move filepath into config class 2025-05-12 21:30:48 +01:00
Cooldude2606 c650f42276 Enable source maps for test setup 2025-05-12 21:30:47 +01:00
Cooldude2606 6989fbdd44 Move defaultAccess to static property 2025-05-12 21:30:47 +01:00
Cooldude2606andCopilot 9f0633abbb Update packages/lib/src/permissions.ts
Copilot Suggestion - Fxies spelling error

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-11 17:33:54 +01:00
Cooldude2606andCopilot 4e6f7c5402 Update packages/lib/src/rce_ops.ts
Copilot Suggestion - A-Z was the intend range.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-11 17:33:26 +01:00
Daniel 0adb45bba4 Merge pull request #758 from Cooldude2606/feature/pending-restart
Add indicators for host version mismatch and required restart
2025-05-11 14:14:39 +02:00
Cooldude2606 be071be8f2 Add recovery startup mode (#757)
* Add recovery startup mode

* Change error code to RecoveryMode
2025-05-10 21:33:45 +01:00
Cooldude2606 7cf13a8b3b Add user search and pagination 2025-05-10 15:52:16 +02:00
Cooldude2606 90de1ecbf0 Fix bidirectional sync test 2025-05-06 18:05:05 +01:00
Cooldude2606 405af0e173 Add bidirectional whitelist sync 2025-05-06 17:14:27 +01:00
Cooldude2606 5e9beb0cf6 Add tests 2025-05-04 03:35:50 +01:00
Cooldude2606 0eb56b1711 Disable options if a system doesn't support it 2025-05-03 01:53:56 +01:00
Cooldude2606 42ad7f8704 Add plugin list to update modal 2025-04-30 00:05:24 +01:00
Cooldude2606 ff35b5c378 Basic functionality of remote npm 2025-04-29 01:40:22 +01:00
Cooldude2606 c120443657 Add checks for updated version on disk 2025-04-27 23:57:58 +01:00
Cooldude2606 96c5f33dbe Add tests for config changes 2025-04-27 23:29:14 +01:00
Cooldude2606 cf9f087cba Add icons for mis-matched version and restart required 2025-04-27 22:35:00 +01:00
Cooldude2606 47c5e0fabd Fix incorrect ip detection in create 2025-04-22 20:52:30 +01:00
Cooldude2606 a9def876e8 Remove phin without introducing new dependencies (#755) 2025-04-15 19:09:25 +01:00
Cooldude2606 eafbc32d3d Fix open file stream error on some systems 2025-04-15 00:02:54 +01:00
Daniel bbf65dea57 Support quality and ghosts for power armor equipment (#753) 2025-04-13 21:32:23 +01:00
Daniel fcd3dbb65b Merge pull request #748 from Cooldude2606/feature/console-log
Add config option for console logging
2025-04-13 21:56:40 +02:00
Daniel f21a3f45bf Merge pull request #712 from O2theC/auto-mod-update - basic mod portal integration
Add mod portal integration and mod downloads to the web interface
2025-04-13 21:52:07 +02:00
Danielv123 1711271ca8 Fix broken test 2025-04-13 21:44:25 +02:00
Danielv123 25e1398229 Revert "fixed mod export command"
This reverts commit 7f4159b6c6.
2025-04-13 18:13:27 +02:00
Danielv123 2d62ff6689 Reduce type duplication 2025-04-13 18:10:17 +02:00
Danielv123 25099c03ba Use config instead of constants, other review commetns 2025-04-13 18:00:52 +02:00
Danielv123 c6f10e2195 Refactor to avoid duplicated code 2025-04-13 14:22:57 +02:00
Danielv123 1f5f28c820 Same styling of mod portal link and mod download button 2025-04-12 00:06:33 +02:00
Danielv123 a79b129b0c Fix and refactor download buttons 2025-04-12 00:03:37 +02:00
Danielv123 e841aa5685 Add tests 2025-04-11 23:54:42 +02:00
Danielv123 35fef5e021 Cleanup redundant code, remove unused code, add checks 2025-04-11 23:22:37 +02:00
Danielv123 de8a7ef884 Support mod portal downloads 2025-04-11 22:19:11 +02:00
Cooldude2606 f3a62e585f Add log filters to web ui (#746)
* Add max level select to logs

Closes: #745

* Add log filter for server actions

Closes: #744

* Respond to comments

Change name of component, clean up onChange anon function
2025-04-10 16:04:05 +01:00
Danielv123 2936973a75 Link to mod portal 2025-04-08 22:54:16 +02:00
Danielv123 d50230becc Cache mods on controller 2025-04-08 22:42:03 +02:00
Danielv123 600a5d5ce1 Search mods clientside 2025-04-08 22:34:32 +02:00
Daniel 81fb223d5e Fix webpack export not found warnings (#750) 2025-04-08 13:13:50 -07:00
Cooldude2606andDanielv123 0e6d33aa82 Add button to download ctl config (#743)
* Move saveJson to util file

* Add button to download ctl config

* Add icon and tooltip to describe what the button does

---------

Co-authored-by: Danielv123 <danielv@danielv.no>
2025-04-08 20:34:50 +01:00
Danielv123 3bf0d48641 Fix linter warnings 2025-04-08 20:53:06 +02:00
Danielv123 3fae97ac8b Fix mod description rendering 2025-04-08 20:52:56 +02:00
Daniel 42cd099eea Merge pull request #749 from Cooldude2606/fix/736
Fix incorrect use of Array.splice
2025-04-06 13:46:47 -07:00
Cooldude2606 916c48844a Fix incorrect use of Array.splice
This should resolve the sync loops for bans and admins
2025-04-06 18:06:05 +01:00
Cooldude2606 ce85149b4c Add config cloning on instance creation (#747)
* Add instance config cloning on creation

* Add test for instance cloning

* Add tooltip to copy config button
2025-04-06 02:00:08 +01:00
Cooldude2606 0055f28f45 Add bulk user actions (Import/Export/Restore) (#741)
* Add "Bulk Actions" modal to users page

* Add control logic without validation

* Add restore and better permission check

* Add controller logic and automatic backup

* Add CTL commands for bulk actions

* Add tests for bulk actions

* Improve description on permissions
2025-04-06 01:42:51 +01:00
Cooldude2606 1d5040dc0c Improve error page with social links 2025-04-05 02:33:24 -07:00
Cooldude2606 60ee4b2c86 Add config option for console logging 2025-03-29 01:41:19 +00:00
Cooldude2606 7cb191d332 v2.0.0-alpha.20.b
Republish controller to use correct web_ui version.
2025-03-23 17:24:17 +00:00
Cooldude2606 c50298d408 Bump version of web_ui to alpha 20
There has been no functional changes or bug fixes made to web ui between this commit and the previous alpha 20 release.
2025-03-23 15:58:22 +00:00
Hornwitser aeac7eedec zip_ops: Deduce root folder from first zip entry (#739)
Some mods, for example cubium, includes bogus folders like __MACOSX in
addition to the root folder where everything is located.  This only
works by chance as Factorio will check for the folder of the first
entry in the zip file and use that.

Match Factorio's logic in findRoot so that these mods work with
Clusterio.
2025-03-21 23:00:30 +00:00
Danielv123 2ae3f9a600 Add mod portal search 2025-03-15 23:13:33 +01:00
O2theC 05790966db removed unused imports 2025-03-15 15:30:00 +01:00
O2theC a60a3bcbd7 fixed type, split download modpack function into downloadmods and updatemods , returns a map of mods and their latest versions 2025-03-15 15:29:59 +01:00
O2theC 61a241b02d added function to update modpacks 2025-03-15 15:29:59 +01:00
O2theC e1c29bcde1 added functions to download mods from a Map of names and versions 2025-03-15 15:29:59 +01:00
O2theC 7f4159b6c6 fixed mod export command 2025-03-15 15:29:59 +01:00
O2theC 4080c1025d added download file function 2025-03-15 15:29:59 +01:00
O2theC 64e429c5f6 got rid of pesky type definitions for function returns and better map merge 2025-03-15 15:29:59 +01:00
O2theC 24c07c0ed1 it use maps now, much safer 2025-03-15 15:29:59 +01:00
O2theC 61249e5e7e no more undefines in that return dict 2025-03-15 15:29:59 +01:00
O2theC a2a3bcd4f8 adjusted function input for getting latest mod version and adding chunking to not hit request limits 2025-03-15 15:29:59 +01:00
O2theC 71c1cffcac moved interfaces to ModStore and made fetchLatestVersionMod functions 2025-03-15 15:29:59 +01:00
O2theC a769c3179a made modRelease and modDetails interface and made a rough function to get the latest version of a mod from it's name 2025-03-15 15:29:59 +01:00
Cooldude2606 6180a0b27b Fix lint error in UsersPage.tsx 2025-03-14 23:43:42 +00:00
Cooldude2606 4e9df0bc21 v2.0.0-alpha.20 2025-02-28 23:55:11 +00:00
Cooldude2606 2c44890100 Move package release to workflow docs 2025-02-28 23:34:24 +00:00
Daniel febdbd4fd6 Document release workflow (#738) 2025-02-28 23:11:59 +00:00
Danielv123 d622ab5127 Fix missing key on users table roles column
Results in inconsistent visual behaviour when creating and deleting roles
2025-02-27 16:13:33 +01:00
Danielv123 a893109a06 Fix NaN in subspace storage database 2025-02-27 00:05:48 +01:00
Cooldude2606 9f888867fe Dep version bumps
Temp Fix #737
2025-02-25 21:09:11 +00:00
Danielv123 12f833fcc3 Add clusterio_lib release changelog 2025-02-25 19:14:32 +01:00
Daniel a16dcd315f Merge pull request #733 from clusterio/feature/subspace-storage-space-age
Feature/subspace storage space age
2025-02-25 18:53:36 +01:00
Danielv123 9cd68fcb36 Bump version number 2025-02-25 18:39:05 +01:00
Danielv123 ebafda5407 Update with comments from review 2025-02-25 16:33:52 +01:00
Danielv123 a9e1bdcc65 Test itemDatabase with quality and migrations from pre quality format 2025-02-16 14:44:49 +01:00
Danielv123 f907455620 Validate quality parameter 2025-02-16 14:44:15 +01:00
Danielv123 19336cdd3b Remove test dependency on old version of subspace mod 2025-02-16 14:30:47 +01:00
Danielv123 5d37712db4 Fix online time export in 2.0 w backwards compat 2025-02-16 13:34:35 +01:00
Cooldude2606 8996671bea Add default mod packs (#735) 2025-02-14 14:47:26 +00:00
Daniel 7b8945ff15 Don't write input stats to both input and output (#734) 2025-02-14 14:47:13 +00:00
Danielv123 fbdf206e31 Test: Don't depend on a specific version of clusterio_lib 2025-01-25 17:55:43 +01:00
Danielv123andCooldude2606 edfe32c59e Simplify checks based on review comments
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2025-01-25 17:39:47 +01:00
Danielv123 f78966860e Build clusterio lib with CI 2025-01-24 19:23:15 +01:00
Danielv123 2162351710 Bump clusterio_lib versions along with subspace 2025-01-24 17:49:04 +01:00
Danielv123 a33aa3b7e5 Show quality in web UI 2025-01-23 18:54:06 +01:00
Danielv123 e8570029b0 Send and return requests 2025-01-23 00:04:00 +01:00
Danielv123 4fd123abc1 Add quality support to subspace storage 2025-01-22 19:46:23 +01:00
Cooldude2606 26269df7d4 Add 2.0.29 freeplay hash
Hot patch to add the updated 2.0.29 control.lua freeplay hash to knoww scenarios.
2025-01-21 20:43:00 +00:00
phidias 52d8822048 Fix human readable time format (#732) 2025-01-20 11:12:34 +00:00
Daniel 97e25eb885 Handle missing logistic requester point (#731) 2025-01-20 11:11:47 +00:00
Cooldude2606 fa1338ddd4 Add decisions log (#714)
* Add decisions log

* Response to Daniel's comments

* Add PR Links
2025-01-13 21:30:06 +00:00
Cooldude2606 4d66dc5de3 v2.0.0-alpha19.b
Add missing prepare step for web_ui package.
2024-12-24 20:26:31 +00:00
117 changed files with 7271 additions and 967 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
{
"image":"mcr.microsoft.com/devcontainers/universal:2",
"postCreateCommand": "wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && pnpm install",
"postCreateCommand": "mkdir factorio && cd factorio && wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && mv factorio factorio_1.1.107 && rm linux64 && wget https://factorio.com/get-download/2.0.47/headless/linux64 && tar -xf linux64 && mv factorio factorio_2.0.47 && rm linux64 && pnpm install",
}
+1 -1
View File
@@ -191,7 +191,7 @@ module.exports = {
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum"],
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
+27 -7
View File
@@ -12,10 +12,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
factorio-version: [1.1.110]
# Supported versions are latest stable/experimental and one minor version before stable (even if from a previous major)
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [20.x, 22.x]
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.47]
steps:
- uses: actions/checkout@v4
@@ -31,13 +31,13 @@ jobs:
- name: Use Factorio ${{ matrix.factorio-version }}
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ matrix.node-version != '18.x' || matrix.factorio-version != '1.1.110' }}
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '18.x' && matrix.factorio-version == '1.1.110' }}
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '18.x' && matrix.factorio-version == '1.1.110' }}
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
@@ -55,3 +55,23 @@ jobs:
node-version: 18.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
build:
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
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
- name: Upload build to artifacts
uses: actions/upload-artifact@v4
with:
name: clusterio_lib
path: ./dist
if-no-files-found: error
+2
View File
@@ -32,6 +32,8 @@ node_modules
Factorio*
/factorio
!/test/file/factorio
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
/mods
/database
+78 -1
View File
@@ -13,16 +13,93 @@
### Changes
- Bugfixes and small changes to existing behaviour.
- Small changes to existing behaviour.
### Fixes
- Bugfixes
### Breaking Changes
- Changes which break backwards compatibility with older hosts or plugins code.
### Meta
- Changes to the project activities / structure worth highlighting but have no impact on the published package.
Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.21
### Major Features
- Added import, export, and restore of user lists (bans / admins / whitelist / combined). [#717](https://github.com/clusterio/clusterio/issues/717)
- Added remote update for core packages and third party plugins. [#761](https://github.com/clusterio/clusterio/issues/761)
- Added config cloning on instance creation. [#720](https://github.com/clusterio/clusterio/issues/720)
### Features
- Added social links and instructions to error page. [#742](https://github.com/clusterio/clusterio/issues/742)
- Added button to download control config. [#740](https://github.com/clusterio/clusterio/issues/740)
- Added server action filter (aka "chat filter") to instance log in web ui. [#744](https://github.com/clusterio/clusterio/issues/744)
- Added `factorio.console_logging` to enable dedicated console log file for servers. [#748](https://github.com/clusterio/clusterio/issues/748)
- Added a recovery mode to host and controller accessible as an option on the run command. [#752](https://github.com/clusterio/clusterio/issues/752)
- Added username search to the users list. [#751](https://github.com/clusterio/clusterio/issues/751)
- Added log level filtering to the web ui. [#745](https://github.com/clusterio/clusterio/issues/745)
- Added pending update detection for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added restart required indicator for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added version mismatch indicator in host list. [#758](https://github.com/clusterio/clusterio/issues/758)
- Added host plugin list command to crl where previously only controller plugin list existed. [#760](https://github.com/clusterio/clusterio/issues/760)
- Added optional feature which allows new plugins to be installed remotely.
- Added bidirectional whitelist syncing. [#716](https://github.com/clusterio/clusterio/issues/716)
- Added support for v2 map exchange strings. [#700](https://github.com/clusterio/clusterio/issues/700)
### Fixes
- Fixed handling of zip files with multiple top-level folders. [#739](https://github.com/clusterio/clusterio/issues/739)
- Fixed looping between instances of ban and admin sync events. [#736](https://github.com/clusterio/clusterio/issues/736)
- Fixed quality being stripped from power armour equipment when moving between servers [#753](https://github.com/clusterio/clusterio/pull/753)
- Resolved webpack type export warnings [#750](https://github.com/clusterio/clusterio/pull/750)
- Reduce page load time on users list by enabling pagination. [#718](https://github.com/clusterio/clusterio/issues/718)
- Corrected stack trace and enabled source mapping during bootstrap. [#764](https://github.com/clusterio/clusterio/pull/764)
- Fixed map gen settings using the incorrect name for cliff settings following exchange string decode.
### Meta
- Silent testing is now truly silent. [#764](https://github.com/clusterio/clusterio/pull/764)
- Node 18 has reached end of life and is no longer maintained, we no longer guarantee support for it.
- CI testing now runs on node 20 & 22 as well as factorio 1.1.110 & 2.0.47.
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.20
### Features
- Update subspace storage to 2.0 and space age. [#733](https://github.com/clusterio/clusterio/pull/733)
- A fresh install of Clusterio will include default mod packs. [#719](https://github.com/clusterio/clusterio/issues/719)
### Fixes
- Fix crash when leaving a server without a logistic requester point [#731](https://github.com/clusterio/clusterio/pull/731)
- Use floor instead of round to prevent "1 minute and 60 seconds" being shown [#723](https://github.com/clusterio/clusterio/issues/723)
- Fixed production statistics overwriting consumption statistics and vise versa [#734](https://github.com/clusterio/clusterio/pulls/734)
- Hot patch to add the updated 2.0.29 control.lua freeplay hash to know scenarios. [commit](https://github.com/clusterio/clusterio/commit/26269df7d4d8f39042c59653008bc8f034a4f3b4)
### Meta
- We have now introduced a new [decisions log](https://github.com/clusterio/clusterio/blob/master/docs/decisions.md) to aid tracking all the technical and design decisions we make and their reasons. [#714](https://github.com/clusterio/clusterio/pull/714)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@psihius](https://github.com/psihius)
## Version 2.0.0-alpha.19
### Major Features
+3 -3
View File
@@ -90,13 +90,13 @@ Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for w
## Installation
Clusterio runs on Node.js v18 and up, it's distributed via npm and comes with a guided install script.
Clusterio runs on all LTS version of NodeJS, it is distributed via npm and comes with a guided install script.
If you already have a recent Node.js installed, you can set it up in a new directory with:
npm init "@clusterio"
Otherwise see below for OS specific instructions.
Otherwise see below for OS specific instructions.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
### Ubuntu setup
+11 -43
View File
@@ -3,6 +3,8 @@
You are probably reading this because you want to contribute code to Clusterio.
Great! We need people like you.
Though we have some conventions and workflows that we kindly ask you to follow.
We have included all the pratical considerations in this document.
Although, we recomend checking out out our [past decisions](/docs/decisions.md) if you have the time.
## Contents
@@ -15,6 +17,7 @@ Though we have some conventions and workflows that we kindly ask you to follow.
- [Editing your Feature Branch](#editing-your-feature-branch)
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
- [Updating Your GitHub Fork](#updating-your-github-fork)
- [Publishing releases to npm](#publishing-releases-to-npm)
- [Changelog](#changelog)
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
@@ -23,7 +26,6 @@ Though we have some conventions and workflows that we kindly ask you to follow.
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Markdown](#markdown)
- [Pushing releases to npm](#pushing-releases-to-npm)
## Workflow
@@ -178,11 +180,15 @@ Fetch the main repository directly as described above and base your feature bran
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
### Publishing releases to npm
For publishing new releases see [package release workflow](./release-workflow.md#packages).
## Changelog
There's a [Changelog](../CHANGELOG.md) in the root of the project.
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
The format of this log should be self explanatory, please follow it carefully.
The format of this log should be self explanatory, please follow it carefully. Please make sure to document any [breaking changes](/docs/decisions.md#breaking-changes).
## Supported Node.js and Factorio Versions
@@ -194,12 +200,14 @@ For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The GitHub Actions tests are run against the latest experimental release.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
## Code Style
The style of the code is a bit of a mixed bag at the moment.
But there are at least a few things that have been agreed upon.
See our [decisions document](/docs/decisions.md) for justifcations.
### Naming style
@@ -284,43 +292,3 @@ And there's two spaces before the next heading.
Headings use the `# heading` format.
```
## Pushing releases to npm
The releases are done by updating packages versions and then publishing them with pnpm.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
```sh
git switch master
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`.
2. Check all PRs merged since the last version and copy their change logs into the main changelog.
3. Update the version in package files.
Make sure to update the command for the right version jump.
```sh
sed -i 's/2\.0\.0-alpha\.12/2\.0\.0-alpha\.13/' {packages,plugins}/*/package.json
```
4. Inspect, commit and tag the changes.
Again, make sure to be tagging the right version.
```sh
git add -p
git commit -m v2.0.0-alpha.13
git tag v2.0.0-alpha.13
```
5. Publish the packages.
```sh
pnpm clean
pnpm -r --filter='./{plugins,packages}/*' publish
```
+479
View File
@@ -0,0 +1,479 @@
# Decisions
This document outlines key decisions taken by the project team that should be followed by all maintainers and contributors.
If there is a particular issue or concern you would like the team to comment on then please create a github issue for it.
<!-- Try to use categories that already exist if possible -->
<!-- Sublists should be alphabetical, avoiding starting with "the" -->
<!-- For code style, add (Lang) to section names if present in both -->
1. Documentation
- [Decision Log](#decision-log)
- [TSDoc](#tsdoc)
2. Code Style (General)
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Naming Booleans](#naming-booleans)
- [Naming Files](#naming-files)
- [Naming Times and Durations](#naming-times-and-durations)
- [Strings](#strings)
3. Code Style (TS / JS / Web)
- [ESLint](#eslint)
- [Imports Within `lib` Package](#imports-within-lib-package)
- [Name Style](#name-style-ts)
4. Code Style (Lua)
- [FMTK](#fmtk)
- [Name Style](#name-style-lua)
5. Code Style (Other)
- [Markdown](#markdown)
6. Repository Structure
- [External Plugins](#external-plugins)
- [First Party Plugins](#first-party-plugins)
- [Core Packages](#core-packages)
- [TypeScript Configs](#typescript-configs)
- [Workspace](#workspace)
7. Project Architecture
- [Deprecation of Phin in favour of `fetch`](#deprecation-of-phin-in-favour-of-fetch)
- [Minimal Dependencies](#minimal-dependencies)
- [React](#react)
- [Separation of Host and Controller](#separation-of-host-and-controller)
- [Typescript](#typescript)
- [Webpack](#webpack)
8. Supportability
- [Breaking Changes](#breaking-changes)
- [Distribution](#distribution)
- [Factorio](#factorio)
- [General Support](#general-support)
- [NodeJS](#nodejs)
- [Operating System](#operating-systems)
9. Testing
- [Continuous Integration](#continuous-integration)
- [Continuous Deployment](#continuous-deployment)
- [Mocha](#mocha)
- [Version Matrix](#version-matrix)
> [!note]
> The use of `Time Immemorial` indicates an obsverion of a previously undocumented decision with no known start date, which has remained unchallenged.
<!--
### Template
🧾 0th Month 2000 | Source
> A short sentence describing the action to take in the context of our project.
Write a breif description which includes: the reason a decision was needed, the outcome of the discussion, and the justification of the outcome.
Write using the active voice in third person "we".
Remember to have one sentence per line.
-->
## Documentation
### Decision Log
🧾 22nd December 2024 | Discord Voice Call
> Maintain a document that includes a brief description of the outcomes and the reasoning behind our decisions, along with links to the relevant sources for further reading.
We discovered that we were beginning to ask similar questions to other maintainers that we had previously raised, which led to a waste of time for all involved.
Consequently, we decided that a log of important decisions should be kept for the benefit of ourselves and future maintainers.
### TSDoc
🧾 12th August 2023 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1320573126731370506) | [Github PR #493](https://github.com/clusterio/clusterio/pull/493)
> When a function or it's arguments do not have a clear purpose, a TSDoc comment should be used to describe it.
While TypeScript provides useful type infomation in function hover text, it does not allow for comments about the purpose of functions and their arguments.
Therefore, we decided to use TSDoc comments for this purpose as it is well intergrated in many text editors and IDEs (such as VSCode).
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
API Extractor was also considered but not selected.
## Code Style (General)
### Indenting
🧾 Time Immemorial
> Use tabs for indention. Except for Markdown where spaces carry meaning.
We needed to maintain a consistent whitespace style across all our files.
Therefore, we selected tabs as our standard.
### Line Length
🧾 Time Immemorial
> Lines are limited to 120 characters where tabs count as 4 characters.
We needed to maintain a consistent max line legnth across all our files.
Therefore, we selected 120 characters as our standard.
### Naming Booleans
🧾 25th November 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1310611960508977223)
> Variable and member properties which contain booleans should be named starting with a verb unless it ends with "ed", e.g. `canRestart` `expectEmpty` `hasFallback` `connected` `enabled` `loaded` `banned`
We had inconsistencies in our naming of booleans and aimed to standardise them.
There was little preference for any particular approach, so we chose the one that would require the fewest changes to existing code.
As a result, all boolean values should begin with a verb, unless they end with "ed." For clarity, both "is" and "has" are considered verbs.
### Naming Files
🧾 Time Immemorial
> Files use lowercase_underscore if multiple values are exported. If a single class is exported then it uses the class name in PascalCase.
We needed to maintain a consistent name style across all our files.
Therefore, we selected lowercase undercore for most files, with class files using PascalCase.
### Naming Times and Durations
🧾 18th January 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1197566542548828170)
> Variable and member properties which contain times or durations should end with their SI unit of messure, e.g. `updatedAtMs`
We found verifying the units of measurement used in different locations to be a simple yet repetitive task that could lead to errors.
Our main concern was the mixing of variables containing seconds and milliseconds.
Therefore, we decided that times and durations should explicitly include their units, whereas other value types do not require any units.
### Strings
🧾 Time Immemorial
> Strings should use double quotes `"` unless the string contains them, in which case single quotes `'` can be used. For JS string templates use backticks ``` ` ``` as normal.
We needed to maintain a consistent string style across all our files.
Therefore, we selected double quotes as our standard.
## Code Style (TS / JS / Web)
### ESLint
🧾 Time Immemorial
> Use ESLint for style checking.
We needed to maintain a consistent style across all our files.
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
### Imports Within `lib` Package
🧾 Time Immemorial
> When importing files within the lib package, all names should be prefixed with "lib".
The exports of the various library files are often very generic and may conflict with variable names.
Therefore, when importing these files the "lib" prefix should be used to avoid confussing.
This only applies to files within `lib` importing other files in `lib` because all other packages import the whole of `lib` as a single package.
### Name Style (TS)
🧾 Time Immemorial
> Variables and members of classes use camelCase. Classes use PascalCase. Config values use lowercase_underscore. Exceptions allowed for over the write objects.
We needed to maintain a consistent name style across all our files.
Therefore, we follow common practive for JavaScript of using camelCase and PascalCase.
Although, we have also allowed the use of lowercase_underscore for config values to distinguish them from other variables.
## Code Style (Lua)
### FMTK
🧾 14th June 2024 | [External PR](https://github.com/justarandomgeek/vscode-factoriomod-debug/pull/126)
> Use of FMTK is not required but is advised.
> To enable Clusterio support, use the CLI flag `--clusterio-modules` under `Lua.runtime.pluginArgs`.
We needed to maintain a consistent style across all our files.
There are multiple competing standards for Lua code, and given the limited amount of Lua code in this project, we chose not to adopt any automatic linting or shared luarc file.
Nevertheless, we recomend the use of [FMTK](https://github.com/justarandomgeek/vscode-factoriomod-debug) and have taken steps towords supporting it in Clusterio.
### Name Style (Lua)
🧾 Time Immemorial
> Everything uses lowercase_underscore
We needed to maintain a consistent name style across all our files.
Therefore, we follow common practive for Lua of using lowercase_underscore.
## Code Style (Other)
### Markdown
🧾 Time Immemorial
> Use one sentence per line.
> Place two lines before each heading, except for the first one or when two headings are adjacent.
We needed to maintain a consistent code style across all our markdown files.
Therefore, we selected an approach which makes diff reports more useful and which makes headings use additional space to make them stand out.
## Repository Structure
### External Plugins
🧾 Time Immemorial
> No external plugins should be included in the main repository.
> A folder will exist which external plugin repositories can be cloned into and will be given the same treatment as first party plugins.
We require a designated space for developers to create their plugins without the concern of conflicts with first-party plugins.
To address this, we have introduced a dedicated folder for external plugins.
This solution is straightforward and has since proven to be highly effective.
### First Party Plugins
🧾 Time Immemorial
> First party plugins are included in the main repository.
> Those selected to be first party are held to the same stanards which apply to the core packages.
> Each will recieve a new release in step with the core packages.
We require a designated space for first-party plugins, separate from the core packages, to clearly represent the dependency between them.
Creating a folder provided a simple solution to this.
### Core Packages
🧾 Time Immemorial
> All core packages will be contained in the packages folder of the repository.
> They are allowed to be interdependent on each other.
We require a set of core packages to represent the different applications used in a cluster.
See [Separation of Host and Controller](#separation-of-host-and-controller).
To avoid putting more files into the repository root we created a folder for the core packages.
### TypeScript Configs
🧾 26th August 2023 | [Github PR #504](https://github.com/clusterio/clusterio/pull/504)
> Each package / plugin may contain any of: `tsconfig.json` `tsconfig.node.json` and `tsconfig.web.json`.
> The main respository root will contain all three files in addition to `tsconfig.base.json` which node and web extend from.
> The configs inside packages / plugins should extend from those in the respository root.
We found that maintaining independent tsconfig files became burdensome and required a way to consolidate them.
Initially, we introduced a base configuration file; however, this proved insufficient, so we extended it to include separate configurations for Node.js and the Web.
This approach led to each package having up to three tsconfig files.
Nevertheless, we believe this to be justified, given the need to differentiate between Node.js and Web builds while maintaining a single, shared configuration.
### Workspace
🧾 Time Immemorial
> The workspace root should contain only: configuration files, scripts used to build / clean the workspace, the readme, and the changelog.
We need to maintain a clean workspace root by only including files which effect all pacakges and plugins such as configuration files.
We have allowed for build and clean scripts to exist here as long as they are not published as part of any package or plugin.
## Project Architecture
### Deprecation of Phin in favour of `fetch`
🧾 12th December 2024 | [Discord Discussion Dec 24](https://discord.com/channels/450361298220351489/450361298220351491/1316781609206419567) | [Discord Discussion May 25](https://discord.com/channels/450361298220351489/450361298220351491/1240246997496102942)
> The builtin `fetch` method should be used as a replacement of the deprecated Phin module.
We required a replacement for the deprecated Phin HTTP client, which was used for making server-side requests and as part of testing.
With `fetch` being marked as stable in Node.js v21.0.0 and having first been made available in v16.15.0, it serves as a suitable replacement without the need to add new dependencies.
It had previously not been used because it was not marked as stable in any LTS version of NodeJS.
### Minimal Dependencies
🧾 Time Immemorial
> Any new dependencies need to be well justified.
We aim to minimise our exposure to upstream changes by using a minimal number of dependencies.
This also serves to reduce the bloat of a cluster installation and improve installation times.
Consequently, the addition of any new dependencies must be thoroughly justified, with evidence of research into alternative options and comparisons between them.
### React
🧾 Time Immemorial
> Our web stack uses ReactJS
We require a reactive website for our control panel, and ReactJS was selected due to its widespread use and familiarity.
Switching away from React for any reason would represent a significant undertaking.
### Separation of Host and Controller
🧾 Time Immemorial
> The primary application locations are Control (web and cli), Controller, and Host.
> Therefore the core packages are: `ctl` `controller` `host`. There are two libaray packages `lib` and `web_ui`.
> Additionally `create` exists to provide easy creation of a cluster.
A single application to perform all the duties of a cluster would complicate the reasoning and maintenance of the project, so we decided to split it into several packages.
The primary components of a cluster include servers connected to an instance, which runs on a host that connects to the controller, whose configuration is managed via a control connection from a CLI or web portal.
Based on this structure, we found it logical to divide the packages according to their installation location, resulting in `ctl`, `controller`, and `host`, with shared code separated into `lib` and `web_ui` depending on where it is utilised.
The additional `create` package is used by `npm create` to provide a user-friendly installation process.
### TypeScript
🧾 6th August 2023 | [Github PR #491](https://github.com/clusterio/clusterio/issues/491)
> We use TypeScript as our language of choice.
We previously used JavaScript and switched to TypeScript to provide a robust type system.
Transitioning to any other langauge would be too significant an undertaking.
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
### Webpack
🧾 Time Immemorial
> Our web stack uses Webpack and associated plugins.
We required a bundler for our web build, and at the time, Webpack was the standard.
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
## Supportability
### Breaking Changes
🧾 Time Immemorial
> We follow symver versioning for breaking changes.
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
> Once we enter beta, breaking changes will be more restricted.
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
### Distribution
🧾 Time Immemorial
> We use `git` and `pnpm` for development distribution.
> We use `npm` and the factorio mod portal for production distribution.
We need to distribute both production builds and development source code for the project.
By necessity, we use `npm` and the Factorio Mod Portal to distribute our production builds.
For development, we rely on `git` and GitHub, which are industry standards, while using `pnpm` as a more efficient variant of `npm` for development installations.
Although bun was considered upon its release, we were not convinced it was the right choice.
### Factorio
🧾 26th October 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1299637042350653461)
> We actively provide full support for the last two major versions of Factorio.
> This approach does not follow semantic versioning; instead, it follows the "middle" versions: 2.1 > 2.0 > 1.1 > 1.0 > 0.18 > 0.17.
> We offer passive support for all versions from 0.17 onwards, where implementation is trivial and does not impose restrictions on our actively supported versions.
Following the 2.0 update of Factorio, which introduced a large number of breaking changes, we needed to reconsider which versions would be actively supported, as it was no longer trivial to support all versions.
Anticipating that future breaking changes would also be significant, and assuming the majority of players would update to the latest stable version upon release, we decided to support the latest stable version and the previous major version that introduced breaking changes.
Support for other versions will not be removed if they have already been implemented and do not impose restrictions on our ability to support newer versions.
### General Support
🧾 Time Immemorial
> We provide full support via GitHub and our Discord server for the latest published version of Clusterio, as well as assistance with migrating from the previous published version.
> However, we do not offer any guarantees with our support; we assist when time permits and may not be able to resolve every issue.
> Additionally, while we offer support in setting up development workspaces and guidance for plugin development, we do not provide support for those running development builds as production instances.
We aim to support as many users of Clusterio as possible in setting up and using the platform for a variety of purposes, including setting up production instances, development workspaces, and providing guidance on plugin development.
However, our time to work on Clusterio is limited, and even more so our ability to offer support.
As such, we cannot assist with every issue and must set reasonable restrictions to ensure expectations are aligned.
Consequently, we do not offer any guarantees for our support, but we strive to provide as much assistance as possible for those setting up or running the latest published version.
Additionally, we support users in setting up development workspaces, as the more people willing to contribute, the better.
### NodeJS
🧾 Time Immemorial
> We support all LTS versions of NodeJS in their active and maintenance phases.
Our options were: supporting all versions, supporting all active versions, supporting all LTS versions, or supporting only the latest version.
As we aim to provide a stable, production-ready tool while still limiting the number of versions we need to manage, we chose to support all LTS versions because NodeJS can be installed by the majority of linux distro package managers which will always offer at least one LTS version.
### Operating Systems
🧾 Time Immemorial
> We support all major versions of Windows, Linux, and MacOS.
This is largely inpart to our use of NodeJS and is not a guarantee from us.
Our choice of Node.js has enabled us to support all major operating systems with minimal effort on our part.
Nevertheless, there have been instances where code paths have diverged between systems, and we have attempted to maintain support for as many systems as possible.
However, this is not a priority, and such support has often been reactive or provided by external contributors.
## Testing
### Continuous Integration
🧾 Time Immemorial
> We run unit and integration tests through GitHub CI actions as well as test coverage through CodeCov.
Continuous integration actions in GitHub are a valuable tool that we use to assist with the review of pull requests from both maintainers and contributors.
While we aim to increase the number of useful checks performed by CI in the future, this is not a priority over the development of new features.
### Continuous Deployment
🧾 Time Immemorial
> We do not use any CD actions, and have no plans of implementing them.
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
This is due to the frequent breaking changes merged into the main branch, which we prefer to bundle together as a single version.
In the future, we may automate the deployment process, but it will always require manual activation.
### Mocha
🧾 Time Immemorial
> We use Mocha for testing and CodeCov for coverage.
We chose Mocha because it was a good fit when we selected it.
However, we are always looking for a better framework which is worth the effort of switching.
### Version Matrix
🧾 Time Immemorial
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
We perform tests on all versions of NodeJS and Factorio that we support.
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
+62
View File
@@ -28,6 +28,21 @@ Generate a token for a host. `--id` is the host id.
Create a host config. `--id` id the host id. `--name` is the host name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-host.json", "-" for stdout.
### Update Clusterio
ctl> host update [--id <host id>] [--name <host name>] [--restart]
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete.
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the following command locally while the host is offline:
host> config set host.allow_remote_updates true/false
The controller also supports remote updates, following the same logic. The corresponding commands are:
ctl> controller update [--restart]
controller> config set controller.allow_remote_updates true/false
## Instances
### List Instances
@@ -199,3 +214,50 @@ Note: If the player joins the cluster again a new account will be made for them
## Roles
To be written.
## Plugins (Local)
To be written.
## Plugins (Remote)
By default, remote updates are enabled unless you selected "no" during installation.
Remote updates allow ctl and the web UI to update plugins on machines within the cluster.
However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature.
### List
ctl> controller plugin list
ctl> host plugin list <host>
This command lists all plugins currently known to the remote. Newly installed plugins will not be displayed until the remote is restarted. Plugins that are distributed as an npm package can be updated remotely.
### Update
ctl> controller plugin update <name> [--restart]
ctl> host plugin update <host> <name> [--restart]
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Install
ctl> controller plugin install <name> [--restart]
ctl> host plugin install <host> <name> [--restart]
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Enable/Disable Updates
host> config set host.allow_plugin_updates true/false
controller> config set controller.allow_plugin_updates true/false
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
### Enable/Disable Installs
host> config set host.allow_plugin_install true/false
controller> config set controller.allow_plugin_install true/false
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
+4
View File
@@ -10,6 +10,10 @@ You can do this by running the follow RCON command on an instance that is curren
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
If you for some reason want to run this function in 2.0+, use the following command:
/sc local a={name="pack",description="",factorio_version=script.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}}for b,c in pairs(script.active_mods)do table.insert(a.mods,{name=b,enabled=true,version=c})end;local function d(e,f)for b,c in pairs(e)do a.settings[f][b]=c end end;d(settings.startup,"startup")d(settings.global,"runtime-global")if game.player then d(game.player.mod_settings,"runtime-per-user")else d(settings.player,"runtime-per-user")end;local g=helpers.encode_string(helpers.table_to_json(a))log(g)rcon.print(g)
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
Mod Pack strings looks something like this:
+67
View File
@@ -0,0 +1,67 @@
# Release workflow
## Update changelog
- CHANGELOG.md in the repository root
- The changelog.txt file in mod directories
Pull requests will usually have suggestions for changelog entries.
## Packages
> [!NOTE]
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. Ensure you are on the latest version of master branch with no untracked files
```sh
git switch master
git pull --ff-only upstream master
git status
```
2. Bump the version in the `package.json` files
```sh
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
````
3. Tag and push the release to github
```sh
git add -p
git commit -m v2.0.0-alpha.XX
git tag v2.0.0-alpha.XX
```
4. Perform a clean install and publish the packages.
```sh
pnpm clean
pnpm -r --filter='./{plugins,packages}/*' publish
```
## Plugins
1. Bump the version in the `package.json` file
2. Run `pnpm install` in the repository root
3. Run `pnpm adduser` to authenticate
4. Run `pnpm publish` in the plugin/package directory
Before confirming the publish, ensure the packed size is approximately the same size as the last version (or within the expected size)
After publishing, test creating and installing the plugin on a fresh install.
## Lua mods
To publish lua mods like clusterio_lib, start by building from source:
```sh
pnpm run build-mod
```
or download the latest artifacts from the CI. When uploading the mods to the mod portal, make sure the latest version is the last version uploaded, as the game/mod portal/ingame updater isn't consistent in how it figures out the "latest version"
## Release notes
Release notes are expected to be published alongside new releases. We use the github releases system as well as posting in the #announcements channel on the discord. Major releases may warrant a ping.
+9 -8
View File
@@ -5,6 +5,7 @@
"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/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
@@ -40,21 +41,21 @@
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.4.0",
"@swc/core": "^1.11.1",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.45.0",
"eslint": "^8.57.1",
"eslint-plugin-node": "^11.1.0",
"express": "^4.18.2",
"form-data": "^4.0.0",
"fs-extra": "^11.1.1",
"express": "^4.21.2",
"form-data": "^4.0.2",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.0",
"mocha": "^10.2.0",
"mocha": "^10.8.2",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.26.3",
"typescript": "^5.5.3",
"typedoc": "^0.26.11",
"typescript": "^5.7.3",
"yargs": "^17.7.2"
}
}
+17 -17
View File
@@ -210,7 +210,6 @@ interface InitializeParameters {
shouldRun: boolean;
clusterLogger: winston.Logger;
pluginInfos: lib.PluginNodeEnvInfo[] | null;
controllerConfigPath: string | null;
controllerConfig: lib.ControllerConfig | null;
}
@@ -270,6 +269,10 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart the controller on failure",
});
yargs.option("recovery", {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -313,6 +316,9 @@ async function initialize(): Promise<InitializeParameters> {
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
shouldRun = true;
}
@@ -327,7 +333,6 @@ async function initialize(): Promise<InitializeParameters> {
shouldRun,
clusterLogger,
pluginInfos: null,
controllerConfigPath: null,
controllerConfig: null,
};
}
@@ -337,37 +342,34 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let controllerConfig;
const controllerConfigPath = args.config;
logger.info(`Loading config from ${controllerConfigPath}`);
let controllerConfig = new lib.ControllerConfig("controller");
try {
let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" }));
controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller");
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
controllerConfig = new lib.ControllerConfig("controller");
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
}
}
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
controllerConfig.set("controller.auth_secret", bytes.toString("base64"));
await lib.safeOutputFile(
controllerConfigPath, JSON.stringify(controllerConfig, null, "\t")
);
await controllerConfig.save();
}
if (command === "config") {
await lib.handleConfigCommand(
args, controllerConfig, controllerConfigPath
);
await lib.handleConfigCommand(args, controllerConfig);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
@@ -377,7 +379,6 @@ async function initialize(): Promise<InitializeParameters> {
args,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
shouldRun,
};
@@ -393,19 +394,18 @@ async function startup() {
shouldRun,
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
} = await initialize();
if (!shouldRun || !pluginInfos || !controllerConfigPath || !controllerConfig) {
if (!shouldRun || !pluginInfos || !controllerConfig) {
return;
}
controller = new Controller(
clusterLogger,
pluginInfos,
controllerConfigPath,
controllerConfig,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Controller.bootstrap(controllerConfig)
);
+1
View File
@@ -5,6 +5,7 @@ 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 BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as WsServer } from "./src/WsServer";
+14 -14
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
@@ -23,36 +23,36 @@
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"busboy": "^1.6.0",
"compression": "^1.7.4",
"express": "^4.18.2",
"finalhandler": "^1.2.0",
"fs-extra": "^11.1.1",
"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.13.0",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.4.0",
"@swc/core": "^1.11.1",
"@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.2",
"@types/node": "^20.4.5",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.14",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"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"
@@ -70,6 +70,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ControllerStopRequest, this.handleControllerStopRequest.bind(this));
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
@@ -98,6 +99,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModGetRequest, this.handleModGetRequest.bind(this));
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.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));
@@ -116,7 +118,13 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this));
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
this.handle(lib.UserBulkImportRequest, this.handleUserBulkImportRequest.bind(this));
this.handle(lib.UserBulkExportRequest, this.handleUserBulkExportRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
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) {
@@ -249,6 +257,14 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
const instanceConfig = new lib.InstanceConfig("controller");
if (request.cloneFromId) {
const baseInstance = this._controller.instances.get(request.cloneFromId);
if (!baseInstance) {
throw new lib.RequestError(`Instance with ID ${request.cloneFromId} does not exist`);
}
instanceConfig.update(baseInstance.config.toJSON(), false);
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instanceCreate(instanceConfig);
}
@@ -514,6 +530,88 @@ export default class ControlConnection extends BaseConnection {
};
}
/**
* Handle request to fetch all mods from the Factorio Mod Portal for a given version.
* @param request - The request object containing factorioVersion.
*/
async handleModPortalGetAllRequest(request: lib.ModPortalGetAllRequest) {
const cacheKey = `${request.factorioVersion}-${request.hide_deprecated}`;
const cachedData = this._controller.modPortalCache.get(cacheKey);
const cacheDuration = this._controller.config.get("controller.mod_portal_cache_duration_minutes") * 60 * 1000;
if (cachedData && Date.now() - cachedData.timestamp < cacheDuration) {
return new lib.ModPortalGetAllRequest.Response(cachedData.data);
}
try {
logger.info(`Fetching mod portal data for ${cacheKey}`);
const mods = await lib.ModStore.fetchAllModsFromPortal(
request.factorioVersion,
this._controller.config.get("controller.mod_portal_page_size"),
request.hide_deprecated
);
this._controller.modPortalCache.set(cacheKey, { timestamp: Date.now(), data: mods });
// The Response class is defined inline within the Request class
return new lib.ModPortalGetAllRequest.Response(mods);
} catch (error: any) {
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
// Propagate a user-friendly error back to the client
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
}
}
/**
* 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);
// 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)!;
}
// 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.`);
}
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.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
await this._controller.modStore.deleteMod(request.name, request.version);
}
@@ -752,6 +850,240 @@ export default class ControlConnection extends BaseConnection {
}
}
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
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()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
}
if (user.isBanned && !banned.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
if (user.isWhitelisted && !whitelist.has(user.name)) {
user.isWhitelisted = false;
updated.set(user.id, user);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
updated.set(user.id, user);
}
}
} else {
// Whitelist and admin have the same logic
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()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
}
}
}
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
// eslint-disable-next-line complexity
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<ControllerUser["id"], ControllerUser>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
await this.handleUserBulkRestoreRequest(request, updated);
}
// Will get a user or attempt to create one
const getUserOrCreate = (username: string) => {
const user = this._controller.userManager.getByName(username);
if (user) { return user; }
this.user.checkPermission("core.user.create");
return this._controller.userManager.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
if (request.importType === "users") {
// Update all fields
const users = request.users as Extract<typeof request.users, { username: string }[]>;
for (const user of users) {
const cUser = getUserOrCreate(user.username);
if (user.is_admin && !cUser.isAdmin) {
cUser.isAdmin = true;
updated.set(cUser.id, cUser);
}
if (user.is_banned && !cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.ban_reason ?? "";
updated.set(cUser.id, cUser);
}
if (user.is_whitelisted && !cUser.isWhitelisted) {
cUser.isWhitelisted = true;
updated.set(cUser.id, cUser);
}
}
} else if (request.importType === "bans") {
// Bans have extra logic to handle ban reason
const users = request.users as (string | { username: string, reason: string })[];
for (const user of users) {
if (typeof user === "string") {
const cUser = getUserOrCreate(user);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = "";
updated.set(cUser.id, cUser);
}
} else {
const cUser = getUserOrCreate(user.username);
if (!cUser.isBanned) {
cUser.isBanned = true;
cUser.banReason = user.reason;
updated.set(cUser.id, cUser);
}
}
}
} else {
// Whitelist and admin have the same logic
const users = request.users as string[];
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of users) {
const cUser = getUserOrCreate(user);
if (!cUser[prop]) {
cUser[prop] = true;
updated.set(cUser.id, cUser);
}
}
}
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.usersUpdated(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()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.id);
}
}
this._controller.sendEvent(
new lib.SyncUserListsEvent(adminlist, banlist, whitelist),
lib.Address.fromShorthand("allHosts")
);
return backup ?? [];
}
async handleUserBulkExportRequest(request: lib.UserBulkExportRequest) {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.userManager.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
send = true;
userToSend.is_banned = true;
if (user.banReason !== "") {
userToSend.ban_reason = user.banReason;
}
}
if (user.isAdmin) {
send = true;
userToSend.is_admin = true;
}
if (user.isWhitelisted) {
send = true;
userToSend.is_whitelisted = true;
}
if (send) {
usersToSend.push(userToSend);
}
}
return new lib.ClusterioUserExport(usersToSend);
} 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()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
} else {
usersToSend.push(user.name);
}
}
}
return usersToSend;
}
// 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()) {
if (user[prop]) {
usersToSend.push(user.name);
}
}
return usersToSend;
}
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
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");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
if (!this._controller.config.get("controller.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos);
}
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
if (!this._controller.config.get("controller.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
return await lib.handlePluginInstall(request.pluginPackage);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
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,
));
}
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
this.ws_dumper = data => {
if (this.connector.connected) {
+53 -13
View File
@@ -50,7 +50,6 @@ export default class Controller {
clusterLogger: winston.Logger;
/** Array of plugin info objects for known plugins */
pluginInfos: lib.PluginNodeEnvInfo[];
configPath: string;
/** Controller config. */
config: lib.ControllerConfig;
app: Application;
@@ -96,13 +95,17 @@ export default class Controller {
clusterLogIndex: lib.LogIndex | null = null;
clusterLogBuildInterval: ReturnType<typeof setInterval> | null = null;
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo)
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
this.migrateSystems,
).bootstrap());
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
@@ -127,6 +130,10 @@ export default class Controller {
lib.ModPack.fromJSON.bind(lib.ModPack)
).bootstrap());
if (modPacks.size === 0) {
modPacks.setMany(lib.ModPack.defaultModPacks);
}
const userManager = new UserManager(config);
await userManager.load(path.join(databaseDirectory, "users.json"));
@@ -148,7 +155,6 @@ export default class Controller {
constructor(
clusterLogger: winston.Logger,
pluginInfos: lib.PluginNodeEnvInfo[],
configPath: string,
config: lib.ControllerConfig,
/**
@@ -156,6 +162,11 @@ export default class Controller {
* will restart the controller on non-zero exit codes.
*/
public canRestart: boolean = false,
/**
* If true indicates the controller is in recovery mode and should
* disable certain actions such as loading plugins or connecting to hosts
*/
public recoveryMode: boolean = false,
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** Mapping of host id to host info */
public hosts = new lib.SubscribableDatastore<HostInfo>(),
@@ -172,7 +183,6 @@ export default class Controller {
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
this.configPath = configPath;
this.config = config;
this.app = express();
@@ -284,7 +294,7 @@ export default class Controller {
let tls_cert = this.config.get("controller.tls_certificate");
let tls_key = this.config.get("controller.tls_private_key");
if (httpsPort && (!tls_cert || !tls_key)) {
if (httpsPort && (!tls_cert || !tls_key)) {
throw new lib.StartupError(
"tls_certificate and tls_private_key must be configure in order to use https_port"
);
@@ -516,7 +526,19 @@ export default class Controller {
}
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
requests.push(lib.gatherSystemInfo("controller", this.canRestart));
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 newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -531,6 +553,10 @@ export default class Controller {
clearInterval(this.autosaveInterval);
this.autosaveInterval = undefined;
}
if (this.recoveryMode) {
logger.warn("Recovery | autosaving disabled");
return;
}
const autosaveIntervalSeconds = this.config.get("controller.autosave_interval");
if (autosaveIntervalSeconds > 0) {
this.autosaveInterval = setInterval(this.saveData.bind(this), autosaveIntervalSeconds * 1000);
@@ -556,12 +582,8 @@ export default class Controller {
});
private async _saveDataInternal() {
if (this.config.dirty) {
this.config.dirty = false;
await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t"));
}
await Promise.all([
this.config.save(),
this.systems.save(),
this.hosts.save(),
this.instances.save(),
@@ -576,10 +598,23 @@ export default class Controller {
await lib.invokeHook(this.plugins, "onSaveData");
}
static migrateSystems(rawJson: unknown[]): Static<typeof lib.SystemInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.SystemInfo.jsonSchema>[];
return serialized.map(json => {
if (!json.canRestart) { // Added in 2.0.0.alpha.17
json.canRestart = false;
}
if (!json.restartRequired) { // Added in 2.0.0.alpha.21
json.restartRequired = false;
}
return json;
});
}
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
let serialized = rawJson as any;
// migrate from pre alpha.19 format
// New format 2.0.0.alpha.19
if (serialized.length && serialized[0] instanceof Array) {
serialized = serialized.map((e: any) => e[1]);
}
@@ -598,7 +633,7 @@ export default class Controller {
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
return serialized.map(json => {
if (!json.config) { // migrate: from pre Alpha 14 format.
if (!json.config) { // New format 2.0.0.alpha.14
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
}
return json;
@@ -1051,6 +1086,11 @@ export default class Controller {
continue;
}
if (this.recoveryMode) {
logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
continue;
}
let ControllerPluginClass = BaseControllerPlugin;
try {
if (pluginInfo.controllerEntrypoint) {
+7
View File
@@ -239,6 +239,13 @@ ${err.stack}`
}
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
if (this.controller.recoveryMode) {
logger.warn(`Recovery | rejected host connection attempt for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(lib.ConnectionClosed.RecoveryMode, "Connection Refused: Controller in recovery mode");
return;
}
try {
let tokenPayload = jwt.verify(
data.token,
+1
View File
@@ -690,4 +690,5 @@ export const webRoutes = [
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/error",
];
+25 -9
View File
@@ -6,7 +6,6 @@ const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
@@ -624,8 +623,8 @@ async function inquirerMissingArgs(args) {
answers.publicAddress = args.publicAddress;
} else {
try {
let result = await phin("https://api.ipify.org/");
myIp = result.body.toString();
let result = await fetch("https://api.ipify.org/");
myIp = await result.text();
} catch (err) { /* ignore */ }
}
answers = await inquirer.prompt([
@@ -689,6 +688,17 @@ async function inquirerMissingArgs(args) {
}
}
if (["standalone", "controller", "host"].includes(answers.mode)) {
answers = inquirer.prompt([
{
type: "confirm",
name: "remoteNpm",
message: "Allow remote updates via npm?",
default: true,
},
], answers);
}
if (answers.mode === "plugin-template") {
if (args["plugin-template"] && args["plugin-template"].length > 0) {
answers.pluginTemplate = args["plugin-template"];
@@ -741,7 +751,7 @@ async function inquirerMissingArgs(args) {
}
async function downloadLinuxServer() {
let res = await phin("https://factorio.com/get-download/stable/headless/linux64");
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
@@ -764,14 +774,11 @@ async function downloadLinuxServer() {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await phin({
url: url.href,
stream: true,
});
res = await fetch(url.href);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
res.pipe(writeStream);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
@@ -828,6 +835,9 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
})
@@ -893,6 +903,8 @@ async function main() {
logger.info("Setting up controller");
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
@@ -912,6 +924,8 @@ async function main() {
await execHost(["config", "set", "host.controller_token", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
}
if (answers.mode === "host") {
@@ -923,6 +937,8 @@ async function main() {
await execHost(["config", "set", "host.controller_token", answers.controllerToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
}
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
+2 -3
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -17,9 +17,8 @@
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.1.1",
"fs-extra": "^11.3.0",
"inquirer": "^8.2.4",
"phin": "^3.7.0",
"yargs": "^17.7.2"
},
"publishConfig": {
+6 -6
View File
@@ -209,19 +209,19 @@ async function startControl() {
.parse() as CtlArguments
;
logger.verbose(`Loading config from ${args.config}`);
let controlConfig;
const controlConfigPath = args.config;
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control");
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
controlConfig = new lib.ControlConfig("control");
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
@@ -232,7 +232,7 @@ async function startControl() {
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, args.config);
await lib.handleConfigCommand(args, controlConfig);
return;
}
+5 -5
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -22,7 +22,7 @@
"dependencies": {
"@clusterio/lib": "workspace:*",
"as-table": "^1.0.55",
"fs-extra": "^11.1.1",
"fs-extra": "^11.3.0",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
@@ -33,9 +33,9 @@
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.5.3"
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
}
}
+233 -3
View File
@@ -95,6 +95,19 @@ controllerCommands.add(new lib.Command({
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",
@@ -234,6 +247,35 @@ controllerPluginCommands.add(new lib.Command({
print(asTable(response.body));
},
}));
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);
@@ -322,6 +364,22 @@ hostCommands.add(new lib.Command({
}
},
}));
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",
@@ -464,9 +522,60 @@ hostConfigCommands.add(new lib.Command({
}
},
}));
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);
const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
@@ -484,16 +593,21 @@ instanceCommands.add(new lib.Command({
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 }, control: Control) {
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);
const serializedConfig = instanceConfig.toRemote("controller");
await control.send(new lib.InstanceCreateRequest(serializedConfig));
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
@@ -1836,6 +1950,122 @@ userCommands.add(new lib.Command({
},
}));
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";
}
fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
+16 -8
View File
@@ -120,6 +120,10 @@ async function startHost() {
type: "boolean", nargs: 0, default: false,
describe: "Indicate that a process monitor will restart this host on failure",
});
yargs.option("recovery", {
type: "boolean", nargs: 0, default: false,
describe: "Start the host in recovery mode with all plugins disabled and controller disconnected",
});
})
.demandCommand(1, "You need to specify a command to run")
.strict()
@@ -144,6 +148,9 @@ async function startHost() {
let command = args._[0];
if (command === "run") {
logger.info(`Starting Clusterio host ${version}`);
if (args.recovery) {
logger.warn("Host recovery mode enabled. Some features will be disabled.");
}
}
logger.info(`Loading available plugins from ${args.pluginList}`);
@@ -160,29 +167,30 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
logger.info(`Loading config from ${args.config}`);
let hostConfig;
const hostConfigPath = args.config;
logger.info(`Loading config from ${hostConfigPath}`);
try {
const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8"));
hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host");
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
hostConfig = new lib.HostConfig("host");
hostConfig = new lib.HostConfig("host", undefined, hostConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`);
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig, args.config);
await lib.handleConfigCommand(args, hostConfig);
return;
}
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
await fs.ensureDir("modules");
@@ -221,11 +229,11 @@ async function startHost() {
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
host = new Host(
hostConnector,
args.config,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Host.bootstrap(hostConfig)
);
@@ -1,4 +1,10 @@
---------------------------------------------------------------------------------------------------
Version: 2.0.20
Date: 25. 02. 2025
Features:
- Added support for Factorio 2.0 and space-age
- Add compatibility library with support for factorio 0.17 - 2.0
---------------------------------------------------------------------------------------------------
Version: 0.1.2
Features:
- Added support for Factorio 1.1.
+4 -4
View File
@@ -2,19 +2,19 @@
"name": "clusterio_lib",
"variants": [
{
"version": "0.1.0",
"version": "2.0.17",
"factorio_version": "0.17"
},
{
"version": "0.1.1",
"version": "2.0.18",
"factorio_version": "0.18"
},
{
"version": "0.1.2",
"version": "2.0.11",
"factorio_version": "1.1"
},
{
"version": "0.1.3",
"version": "2.0.20",
"factorio_version": "2.0"
}
],
+19 -1
View File
@@ -21,6 +21,8 @@ local has_quality = compat.version_ge("2.0.0")
-- Equipment Grids are serialized into an array of equipment entries
-- where ench entry is a table with the following fields:
-- n: name
-- g: ghost name (optional)
-- t: to_be_removed (optional)
-- p: position (array of 2 numbers corresponding to x and y)
-- s: shield (optional)
-- e: energy (optional)
@@ -29,6 +31,8 @@ local has_quality = compat.version_ge("2.0.0")
-- r: result inventory
-- b: currently burning (optional)
-- f: remaining_burning_fuel (optional)
-- If the equipment has a quality the following is also present:
-- q: quality (optional)
function serialize.serialize_equipment_grid(grid)
local serialized = {}
local processed = {}
@@ -44,6 +48,11 @@ function serialize.serialize_equipment_grid(grid)
n = equipment.name,
p = {pos.x, pos.y},
}
if equipment.type == "equipment-ghost" then
if equipment.ghost_name then
entry.g = equipment.ghost_name
end
end
if equipment.shield > 0 then entry.s = equipment.shield end
if equipment.energy > 0 then entry.e = equipment.energy end
if equipment.burner then
@@ -55,6 +64,13 @@ function serialize.serialize_equipment_grid(grid)
entry.f = burner.remaining_burning_fuel
end
end
if equipment.quality then
entry.q = equipment.quality.name
end
if equipment.to_be_removed then
entry.t = equipment.to_be_removed
end
log(entry)
table.insert(serialized, entry)
end
end
@@ -67,8 +83,10 @@ function serialize.deserialize_equipment_grid(grid, serialized)
grid.clear()
for _, entry in ipairs(serialized) do
local equipment = grid.put({
name = entry.n,
name = entry.g or entry.n,
quality = has_quality and entry.q or nil,
position = entry.p,
ghost = entry.g and true or nil,
})
if equipment then
if entry.s then equipment.shield = entry.s end
+7 -7
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -22,13 +22,13 @@
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.1.1",
"fs-extra": "^11.3.0",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.3",
"semver": "^7.5.4",
"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",
@@ -39,11 +39,11 @@
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/node": "^20.17.19",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.5.3"
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
}
}
+82 -30
View File
@@ -43,19 +43,20 @@ async function discoverInstances(instancesDir: string) {
let instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new lib.InstanceConfig("host");
let configPath = path.join(instancesDir, entry.name, "instance.json");
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
const instancePath = path.join(instancesDir, entry.name);
const configPath = path.join(instancePath, "instance.json");
try {
const jsonConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
instanceConfig = lib.InstanceConfig.fromJSON(jsonConfig, "host");
instanceConfig = await lib.InstanceConfig.fromFile("host", configPath);
instanceConfig.update(Host.instanceConfigWarning, false); // File might be missing the warning
} catch (err: any) {
if (err.code === "ENOENT") {
continue; // Ignore folders without config.json
}
logger.error(`Error occured while parsing ${configPath}: ${err.message}`);
logger.error(`Error occurred while parsing ${configPath}: ${err.message}`);
continue;
}
@@ -64,7 +65,6 @@ async function discoverInstances(instancesDir: string) {
continue;
}
let instancePath = path.join(instancesDir, entry.name);
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instanceInfos.set(instanceConfig.get("instance.id"), {
path: instancePath,
@@ -258,7 +258,6 @@ export default class Host extends lib.Link {
*/
tlsCa?: string;
pluginInfos: lib.PluginNodeEnvInfo[];
configPath: string;
config: lib.HostConfig;
/** Mapping of plugin name to loaded plugin */
@@ -279,6 +278,10 @@ export default class Host extends lib.Link {
_disconnecting = false;
_shuttingDown = false;
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
static async bootstrap(hostConfig: lib.HostConfig) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.ensureDir(modsDirectory);
@@ -289,7 +292,6 @@ export default class Host extends lib.Link {
constructor(
connector: HostConnector,
hostConfigPath: string,
hostConfig: lib.HostConfig,
tlsCa: string | undefined,
pluginInfos: lib.PluginNodeEnvInfo[],
@@ -298,13 +300,17 @@ export default class Host extends lib.Link {
* will restart this host on a non-zero exit codes.
*/
public canRestart = false,
/**
* If true indicates the host is in recovery mode and should
* disable certain actions such as loading plugins or instance autostart
*/
public recoveryMode = false,
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
) {
super(connector);
this.tlsCa = tlsCa;
this.pluginInfos = pluginInfos;
this.configPath = hostConfigPath;
this.config = hostConfig;
this.config.on("fieldChanged", (name, curr, prev) => {
@@ -371,17 +377,23 @@ export default class Host extends lib.Link {
this.handle(lib.HostStopRequest, this.handleHostStopRequest.bind(this));
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this));
this.handle(lib.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this));
this.handle(lib.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this));
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
this.fallbackRequest(
lib.InstanceSaveDetailsListRequest, this.fallbackInstanceSaveDetailsListRequest.bind(this),
);
@@ -403,6 +415,11 @@ export default class Host extends lib.Link {
continue;
}
if (this.recoveryMode) {
logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
continue;
}
let HostPluginClass = BaseHostPlugin;
try {
if (pluginInfo.hostEntrypoint) {
@@ -645,10 +662,11 @@ export default class Host extends lib.Link {
instanceInfo.config.update(config, true, "controller");
} else {
let instanceConfig = new lib.InstanceConfig("host");
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
instanceConfig.update(config, false, "controller");
let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name"));
instanceConfig.filepath = path.join(instanceDir, "instance.json");
logger.info(`Creating ${instanceDir}`);
await Instance.populate_folders(instanceDir);
@@ -683,18 +701,7 @@ export default class Host extends lib.Link {
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
// save a copy of the instance config
await this.saveInstanceConfig(instanceInfo);
}
async saveInstanceConfig(instanceInfo: { path: string, config: lib.InstanceConfig }) {
let warnedOutput = {
_warning: "Changes to this file will be overwritten by the controller's copy.",
...instanceInfo.config.toJSON(),
};
await lib.safeOutputFile(
path.join(instanceInfo.path, "instance.json"),
JSON.stringify(warnedOutput, null, "\t")
);
await instanceInfo.config.save();
}
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
@@ -798,7 +805,19 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart);
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}`);
}
}
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
}
async handleHostMetricsRequest() {
@@ -1043,6 +1062,35 @@ export default class Host extends lib.Link {
await fs.remove(instanceInfo.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");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
if (!this.config.get("host.allow_plugin_updates")) {
throw new lib.RequestError("Plugin updates are disabled on this machine");
}
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
}
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
if (!this.config.get("host.allow_plugin_install")) {
throw new lib.RequestError("Plugin installs are disabled on this machine");
}
return await lib.handlePluginInstall(request.pluginPackage);
}
async handlePluginListRequest(request: lib.PluginListRequest) {
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,
));
}
sendHostUpdate() {
this.send(
new lib.HostInfoUpdateEvent(
@@ -1080,6 +1128,10 @@ export default class Host extends lib.Link {
for (let [instanceId, instanceInfo] of this.instanceInfos) {
if (instanceInfo.config.get("instance.auto_start")) {
if (this.recoveryMode) {
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
continue;
}
try {
let instanceConnection = await this._connectInstance(instanceId);
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
@@ -1131,11 +1183,11 @@ export default class Host extends lib.Link {
}
logger.info("Saving config");
await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t"));
for (const instanceInfo of this.instanceInfos.values()) {
// Save in case host_assigned_game_port changed in the config.
await this.saveInstanceConfig(instanceInfo);
}
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())
);
try {
// Clear silly interval in pidfile library.
+21 -3
View File
@@ -218,6 +218,7 @@ export default class Instance extends lib.Link {
enableWhitelist: this.config.get("factorio.enable_whitelist"),
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
verboseLogging: this.config.get("factorio.verbose_logging"),
consoleLogging: this.config.get("factorio.console_logging"),
stripPaths: this.config.get("factorio.strip_paths"),
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000,
@@ -265,6 +266,15 @@ export default class Instance extends lib.Link {
}
});
this.server.on("whitelist-change", (added: string[], removed: string[]) => {
for (const player of added) {
this._recordUserUpdate("WHITELISTED", player);
}
for (const player of removed) {
this._recordUserUpdate("UNWHITELISTED", player);
}
});
this.handle(lib.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this));
this.handle(lib.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this));
this.handle(lib.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this));
@@ -416,7 +426,11 @@ local players = {}
for _, p in pairs(game.players) do
players[p.name] = p.online_time
end
rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
if helpers ~= nil then
rcon.print(helpers.table_to_json(players))
else
rcon.print(game.table_to_json(players))
end`.replace(/\r?\n/g, " ");
let playerTimes: Record<string, number> = JSON.parse(await this.sendRcon(exportPlayerTimes));
let count = 0;
@@ -641,6 +655,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
continue;
}
if (this._host.recoveryMode) {
this.logger.warn(`Recovery | force disabled plugin ${pluginInfo.name}`);
continue;
}
try {
await this._loadPlugin(pluginInfo, this._host);
} catch (err) {
@@ -1026,13 +1045,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
name: string,
reason: string = ""
) {
// TODO: Implement bidirectional whitelist sync, likely by watching the json file
const addr = lib.Address.fromShorthand("allInstances");
const expectedIndex = this._expectedUserUpdates.findIndex(
expected => expected.name === name && expected.action === action && expected.reason === reason
);
if (expectedIndex >= 0) {
this._expectedUserUpdates = this._expectedUserUpdates.splice(expectedIndex, 1);
this._expectedUserUpdates.splice(expectedIndex, 1);
} else if (action === "BAN") {
if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, true, reason ?? ""));
+2
View File
@@ -348,6 +348,8 @@ const knownScenarios: Record<string, lib.ModuleInfo> = {
"4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", [], ["scenario"]),
// First seen in 2.0
"bcbdde18ce4ec16ebfd93bd694cd9b12ef969c9a": new lib.ModuleInfo("freeplay", "2.0.0", [], ["scenario"]),
// First seen in 2.0.29
"3af547d2f4db3728b75ac5d1b3a4f47830dc48e7": new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]),
};
/**
+81
View File
@@ -438,6 +438,8 @@ export interface FactorioServerOptions {
enableAuthserverBans?: boolean,
/** Enable verbose logging. */
verboseLogging?: boolean,
/** Enable console logging. */
consoleLogging?: boolean,
/** Strip paths in the console. */
stripPaths?: boolean,
/**
@@ -486,6 +488,8 @@ export class FactorioServer extends events.EventEmitter {
enableAuthserverBans: boolean;
/** Enable verbose logging */
verboseLogging: boolean;
/** Enable console logging */
consoleLogging: boolean;
_shutdownTimeoutMs = 0;
_stopTimeoutId?: ReturnType<typeof setTimeout>;
@@ -496,6 +500,10 @@ export class FactorioServer extends events.EventEmitter {
_version: string | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
// https://forums.factorio.com/viewtopic.php?t=123673
_whitelistWatcher: fs.FSWatcher | null = null;
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: string;
@@ -541,6 +549,8 @@ export class FactorioServer extends events.EventEmitter {
this.enableAuthserverBans = options.enableAuthserverBans || false;
/** Enable verbose logging */
this.verboseLogging = options.verboseLogging || false;
/** Enable console logging */
this.consoleLogging = options.consoleLogging || false;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
if (options.shutdownTimeoutMs !== undefined) {
@@ -735,6 +745,62 @@ export class FactorioServer extends events.EventEmitter {
}
}
_watchWhitelist() {
if (this._whitelistWatcher !== null) {
throw new Error("Whitelist watcher already started");
}
if (!this.enableWhitelist) {
return; // The file is not updated if the whitelist is disabled
}
const filePath = this.writePath("server-whitelist.json");
try {
this._whitelistWatcher = fs.watch(filePath);
} catch (err: any) {
this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
}
this._whitelistWatcher.unref();
this._whitelistWatcher.on("error", (err) => {
this._whitelistWatcher = null;
this._logger.error(`Whitelist Watcher has errored:\n${err?.stack ?? err?.message ?? err}`);
});
this._whitelistWatcher.on("close", () => {
this._whitelistWatcher = null;
});
this._whitelistWatcher.on("change", async (eventType) => {
if (eventType !== "change") {
this._logger.warn(`Unexpected file watcher event: ${eventType}`);
return;
}
let newWhitelistJson;
try {
newWhitelistJson = await fs.readJSON(filePath);
} catch (err: any) {
this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`);
return;
}
if (!(newWhitelistJson instanceof Array) || !newWhitelistJson.every(e => typeof e === "string")) {
// The whitelist must be an array of strings
this._logger.error("Unable to read whitelist, bidirectional sync will not be available:" +
"Unexpected whitelist format");
return;
}
const newWhitelist = new Set(newWhitelistJson);
// Set.difference added in node v22 and so we can not use this due to v20 support
const added = newWhitelistJson.filter(e => !this._whitelist.has(e));
const removed = [...this._whitelist.values()].filter(e => !newWhitelist.has(e));
this._whitelist = newWhitelist;
this.emit("whitelist-change", added, removed);
});
}
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
get maxConcurrentCommands() {
return this._maxConcurrentCommands;
@@ -848,6 +914,10 @@ export class FactorioServer extends events.EventEmitter {
this._rconClient.end().catch(() => {});
}
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
}
this._resetState();
this.emit("exit");
});
@@ -862,6 +932,10 @@ export class FactorioServer extends events.EventEmitter {
this._rconClient.end().catch(() => {});
}
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
}
this._resetState();
this.emit("exit");
});
@@ -973,12 +1047,14 @@ export class FactorioServer extends events.EventEmitter {
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._watchWhitelist();
this._attachStdio();
this._watchExit();
/* eslint-enable node/no-sync */
@@ -1029,12 +1105,14 @@ export class FactorioServer extends events.EventEmitter {
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
...(this.consoleLogging ? ["--console-log", this.writePath("console.log")] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._watchWhitelist();
this._attachStdio();
this._watchExit();
/* eslint-enable node/no-sync */
@@ -1110,6 +1188,9 @@ export class FactorioServer extends events.EventEmitter {
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
}
this._server!.kill("SIGKILL");
}
+1
View File
@@ -30,6 +30,7 @@ export * from "./src/zip_ops";
export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -19,17 +19,17 @@
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"ajv": "^8.12.0",
"ajv": "^8.17.1",
"chalk": "^4.1.2",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.1.1",
"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.13.0",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"publishConfig": {
@@ -37,11 +37,11 @@
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"typescript": "^5.5.3"
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
}
}
+206 -8
View File
@@ -2,15 +2,41 @@ import fs from "fs-extra";
import path from "path";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo } from "./data";
import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data";
import TypedEventEmitter from "./TypedEventEmitter";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
change: (mod: ModInfo) => void;
}
interface ModsInfoResponse {
pagination: any,
results: Array<Static<typeof ModPortalDetailsSchema>>,
}
/**
* Interface describing the structure of the pagination info from the portal API
*/
interface ModPortalPagination {
count: number;
links: { next?: string; prev?: string; };
page: number;
page_count: number;
page_size: number;
}
/**
* Interface describing the structure of the portal API response for a single page
*/
interface ModPortalResponse {
pagination: ModPortalPagination;
results: Static<typeof ModPortalDetailsSchema>[];
}
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
constructor(
public modsDirectory: string,
@@ -27,11 +53,6 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
return mod;
}
hasMod(name: string, version: string, sha1?: string): boolean {
const mod = this.files.get(ModInfo.filename(name, version));
return Boolean(mod && (!sha1 || sha1 !== mod.sha1));
}
mods() { return this.files.values(); }
static async loadFile(modsDirectory: string, file: string) {
@@ -44,8 +65,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
async loadFile(file: string) {
let modInfo = await ModStore.loadFile(this.modsDirectory, file);
this.files.set(modInfo.filename, modInfo);
this.emit("change", modInfo);
this.addMod(modInfo);
return modInfo;
}
@@ -134,4 +154,182 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
return new this(modsDirectory, files);
}
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
username: string, token: string
) {
const selectedRelease = releases.find(release => release.version === version);
if (selectedRelease === undefined) {
logger.warn(`Mod release matching version ${version} not found.`);
return;
}
try {
const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`);
url.searchParams.set("username", username);
url.searchParams.set("token", token);
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`;
await ModStore.downloadFile(url, tempFileName);
await fs.rename(tempFileName, finalFileName);
await this.loadFile(selectedRelease.file_name);
} catch (err: any) {
logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`);
// Attempt to clean up temp file if it exists
try {
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
await fs.unlink(tempFileName);
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist
logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`);
}
}
}
}
private async downloadModsChunk(
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
) {
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(",") }),
});
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) => {
if (mod.releases) {
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
/**
*
* 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)
*
*/
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "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 builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
// 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) {
futures.push(
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.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;
}
}
/**
* Fetch all mods from the Factorio Mod Portal for a specific Factorio version.
*
* Handles pagination automatically to retrieve the complete list.
*
* @param factorioVersion - The Factorio version to fetch mods for (e.g., "1.1").
* @param pageSize - The page size for fetching mods.
* @param hide_deprecated - Whether to exclude deprecated mods.
* @returns An array of mod details.
*/
static async fetchAllModsFromPortal(
factorioVersion: string,
pageSize: number,
hide_deprecated: boolean = false
): Promise<Static<typeof ModPortalDetailsSchema>[]> {
logger.info(`Fetching all mods for Factorio version ${factorioVersion}...`);
let currentPage = 1;
let allMods: Static<typeof ModPortalDetailsSchema>[] = [];
let hasMorePages = true;
while (hasMorePages) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", String(pageSize));
url.searchParams.set("version", factorioVersion);
url.searchParams.set("page", String(currentPage));
url.searchParams.set("hide_deprecated", String(hide_deprecated));
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
const response = await fetch(url.toString());
if (!response.ok) {
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
errorMessage += `${response.status} ${response.statusText}`;
try {
errorMessage += ` - ${await response.text()}`;
} catch (bodyError) {
logger.warn("Failed to read response body for failed portal request");
}
throw new Error(errorMessage);
}
const data = await response.json() as ModPortalResponse;
allMods = allMods.concat(data.results);
hasMorePages = currentPage < data.pagination.page_count;
currentPage += 1;
}
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
}
+75 -27
View File
@@ -1,11 +1,13 @@
// Configuration classes
import { Type, Static } from "@sinclair/typebox";
import TypedEventEmitter from "../TypedEventEmitter";
import * as fs from "fs-extra";
import TypedEventEmitter from "../TypedEventEmitter";
import isDeepStrictEqual from "../is_deep_strict_equal";
import { basicType } from "../helpers";
import * as libSchema from "../schema";
import { StringEnum } from "../data/composites";
import { safeOutputFile } from "../file_ops";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
@@ -198,24 +200,32 @@ export class Config<
return config;
}
fields: Fields;
_unknownFields: Record<string, FieldValue> = {};
/** Defines the default access locations for the config class */
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
/** Contains the fields and their values */
private fields: Fields;
private _unknownFields: Record<string, FieldValue> = {};
/** Set to true when a field in the config is changed. */
dirty = false;
private dirty = false;
/** Set to true when a 'restart required' field in the config is changed. */
restartRequired = false;
/**
* Create a new instance of the given config
*
* @param location -
* Location to evaluate access for this instance from
* Location to evaluate access for this instance from.
* @param fields -
* Serialized representation of fields to load.
* @param filepath -
* Filepath for the config to save to.
*/
constructor(
public location: ConfigLocation,
fields?: Static<typeof Config.jsonSchema>,
public defaultAccess: ConfigLocation[] = ["controller", "host", "control"],
public filepath?: string,
) {
if (typeof location !== "string") {
throw new Error("location must be a string");
@@ -260,7 +270,7 @@ export class Config<
|| (
(!def.credential || mode & ConfigAccess.write)
&& (!def.readonly || mode & ConfigAccess.read)
&& !(def.access ?? this.defaultAccess).includes(location)
&& !(def.access ?? this.constructor.defaultAccess).includes(location)
)
) {
if (error) {
@@ -272,8 +282,52 @@ export class Config<
return true;
}
static jsonSchema = ConfigSchema;
/**
* Set the value of a field, while also settings the required flags and events.
* This method does not perform any validation on the value being set
*
* @param name - Name of the field to set
* @param value - The new value of the field
* @param notify - False to skip setting dirty flags and emitting events
*/
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
const prev = this.fields[name];
this.fields[name] = value as any;
if (notify && !isDeepStrictEqual(value, prev)) {
const def = this.constructor.fieldDefinitions[name];
if (def.restartRequired) {
this.restartRequired = true;
}
this.dirty = true;
this.emit("fieldChanged", name, value, prev);
}
}
/**
* Create config from file
*
* @param location - Location used for access control.
* @param filepath - Filepath for the config to save to.
* @returns Instance of this config
*/
static async fromFile(location: ConfigLocation, filepath: string) {
const fields = await fs.readJson(filepath, { encoding: "utf8" });
return this.fromJSON(fields, location, filepath);
}
/** Saves this config to file if there are unsaved changes */
async save() {
if (!this.dirty) {
return;
}
if (!this.filepath) {
throw new Error("Cannot save config which has no filepath");
}
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"));
this.dirty = false;
}
static jsonSchema = ConfigSchema;
static validate = libSchema.compile(this.jsonSchema as any);
/**
@@ -281,11 +335,13 @@ export class Config<
*
* @param json - Serialized config to load.
* @param location - Location used for access control.
* @param filepath - Filepath for the config to save to.
* @returns Instance of this config
*/
static fromJSON(
json: Static<typeof this.jsonSchema>,
location: ConfigLocation,
filepath?: string,
) {
// migrate: Pre alpha 14 config format
if (typeof json === "object" && json !== null && json.groups instanceof Array) {
@@ -301,7 +357,7 @@ export class Config<
if (!valid) {
throw new Error("Invalid config");
}
return new this(location, json);
return new this(location, json, filepath);
}
toJSON() {
@@ -312,11 +368,16 @@ export class Config<
* Serialize the config to a plain JavaScript object
*
* @param remote - Location this serialised representation is for
* @param filter - When provided, only the given fields are serialized
* @returns JSON serializable representation of the config.
*/
toRemote(remote: ConfigLocation): Static<typeof Config.jsonSchema> {
toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static<typeof Config.jsonSchema> {
let fields: Record<string, FieldValue> = {};
for (let [name, value] of Object.entries(this.fields)) {
if (filter && !filter.includes(name as keyof Fields)) {
continue;
}
let def = this.constructor.fieldDefinitions[name];
if (
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
@@ -386,12 +447,7 @@ export class Config<
continue;
}
let prev = this.fields[name];
this.fields[name] = value as any;
if (notify && !isDeepStrictEqual(value, prev)) {
this.dirty = true;
this.emit("fieldChanged", name, value, prev);
}
this._set(name, value, notify);
}
}
@@ -506,12 +562,7 @@ export class Config<
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
let prev = this.fields[name];
this.fields[name] = value as Fields[Field];
if (!isDeepStrictEqual(value, prev)) {
this.dirty = true;
this.emit("fieldChanged", name, value, prev);
}
this._set(name, value);
}
/**
@@ -554,10 +605,7 @@ export class Config<
} else {
delete updated[prop];
}
this.fields[name] = updated as Fields[Field];
if (!isDeepStrictEqual(updated, prev)) {
this.dirty = true;
this.emit("fieldChanged", name, updated, prev);
}
this._set(name, updated);
}
}
+100 -9
View File
@@ -3,7 +3,11 @@ import * as classes from "./classes";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import { Static } from "@sinclair/typebox";
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
export interface ControllerConfigFields {
"controller.version": string;
"controller.name": string;
"controller.mods_directory": string;
"controller.database_directory": string;
@@ -26,6 +30,11 @@ export interface ControllerConfigFields {
"controller.default_mod_pack_id": number | null;
"controller.default_role_id": number | null;
"controller.autosave_interval": number;
"controller.mod_portal_cache_duration_minutes": number;
"controller.mod_portal_page_size": number;
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
}
/**
@@ -33,7 +42,8 @@ export interface ControllerConfigFields {
* @extends classes.Config
*/
export class ControllerConfig extends classes.Config<ControllerConfigFields> {
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControllerConfig;
declare static fromJSON: configFromJSON<ControllerConfig>;
declare static fromFile: configFromFile<ControllerConfig>;
static migrations(config: Static<typeof this.jsonSchema>) {
if (config.hasOwnProperty("controller.external_address")) {
config["controller.public_url"] = config["controller.external_address"];
@@ -44,6 +54,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
}
static fieldDefinitions: classes.ConfigDefs<ControllerConfigFields> = {
"controller.version": {
type: "string",
initialValue: "", // Set on start
readonly: ["controller"],
hidden: true,
},
"controller.name": {
title: "Name",
description: "Name of the cluster.",
@@ -197,10 +213,44 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
optional: true,
initialValue: 1,
},
"controller.mod_portal_cache_duration_minutes": {
title: "Mod Portal Cache Duration",
description: "Duration in minutes to cache mod portal API responses.",
type: "number",
initialValue: 30,
},
"controller.mod_portal_page_size": {
title: "Mod Portal Page Size",
description: "Maximum number of results per page when querying the Factorio mod portal API.",
type: "number",
initialValue: 1000,
},
"controller.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
type: "boolean",
initialValue: true,
readonly: ["controller"],
hidden: true,
},
"controller.allow_plugin_updates": {
description: "When true, allows a remote event to trigger a plugin update via npm",
type: "boolean",
initialValue: true,
readonly: ["controller"],
hidden: true,
},
"controller.allow_plugin_install": {
description: "When true, allows a remote event to trigger a plugin install via npm",
type: "boolean",
initialValue: false,
readonly: ["controller"],
hidden: true,
},
};
}
export interface HostConfigFields {
"host.version": string;
"host.name": string;
"host.id": number;
"host.factorio_directory": string;
@@ -214,6 +264,9 @@ export interface HostConfigFields {
"host.factorio_username": string | null,
"host.factorio_token": string | null,
"host.max_reconnect_delay": number;
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
}
/**
@@ -221,8 +274,15 @@ export interface HostConfigFields {
* @extends classes.Config
*/
export class HostConfig extends classes.Config<HostConfigFields> {
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => HostConfig;
declare static fromJSON: configFromJSON<HostConfig>;
declare static fromFile: configFromFile<HostConfig>;
static fieldDefinitions: classes.ConfigDefs<HostConfigFields> = {
"host.version": {
type: "string",
initialValue: "", // Set on start
readonly: ["host"],
hidden: true,
},
"host.name": {
description: "Name of the host",
type: "string",
@@ -309,6 +369,27 @@ export class HostConfig extends classes.Config<HostConfigFields> {
type: "number",
initialValue: 60,
},
"host.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
type: "boolean",
initialValue: true,
readonly: ["host"],
hidden: true,
},
"host.allow_plugin_updates": {
description: "When true, allows a remote event to trigger a plugin update via npm",
type: "boolean",
initialValue: true,
readonly: ["host"],
hidden: true,
},
"host.allow_plugin_install": {
description: "When true, allows a remote event to trigger a plugin install via npm",
type: "boolean",
initialValue: false,
readonly: ["host"],
hidden: true,
},
};
}
@@ -333,6 +414,7 @@ export interface InstanceConfigFields {
"factorio.enable_authserver_bans": boolean;
"factorio.settings": Record<string, unknown>;
"factorio.verbose_logging": boolean;
"factorio.console_logging": boolean;
"factorio.strip_paths": boolean;
"factorio.sync_adminlist": "enabled" | "disabled" | "bidirectional";
"factorio.sync_whitelist": "enabled" | "disabled" | "bidirectional";
@@ -346,7 +428,8 @@ export interface InstanceConfigFields {
* @extends classes.Config
*/
export class InstanceConfig extends classes.Config<InstanceConfigFields> {
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => InstanceConfig;
declare static fromJSON: configFromJSON<InstanceConfig>;
declare static fromFile: configFromFile<InstanceConfig>;
static migrations(config: Static<typeof this.jsonSchema>) {
function boolToEnableDisable(name: string) {
if (config.hasOwnProperty(name) && typeof config[name] === "boolean") {
@@ -461,12 +544,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
},
"factorio.enable_whitelist": {
description: "Turn on whitelist for joining the server.",
restartRequired: true, // Because of the cli option "--use-server-whitelist"
type: "boolean",
initialValue: false,
},
"factorio.enable_authserver_bans": {
description: "Turn on Factorio.com based multiplayer bans.",
restartRequired: true,
restartRequired: true, // Because of the cli option "--use-authserver-bans"
type: "boolean",
initialValue: false,
},
@@ -484,7 +568,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
},
"factorio.verbose_logging": {
description: "Enable verbose logging on the Factorio server",
restartRequired: true,
restartRequired: true, // Because of the cli option "--verbose"
type: "boolean",
initialValue: false,
},
"factorio.console_logging": {
description: "Enable console logging to a separate file, useful for 3rd party integrations",
restartRequired: true, // Because of the cli option "--console-log"
type: "boolean",
initialValue: false,
},
@@ -501,10 +591,10 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
initialValue: "bidirectional",
},
"factorio.sync_whitelist": {
description: "Synchronize whitelist with the controller",
description: "Synchronize whitelist with the controller (whitelist must be enabled)",
type: "string",
enum: ["disabled", "enabled"], // TODO: Implement bidirectional
initialValue: "enabled",
enum: ["disabled", "enabled", "bidirectional"],
initialValue: "bidirectional",
},
"factorio.sync_banlist": {
description: "Synchronize banlist with the controller",
@@ -533,7 +623,8 @@ export interface ControlConfigFields {
* @extends classes.Config
*/
export class ControlConfig extends classes.Config<ControlConfigFields> {
declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControlConfig;
declare static fromJSON: configFromJSON<ControlConfig>;
declare static fromFile: configFromFile<ControlConfig>;
static fieldDefinitions: classes.ConfigDefs<ControlConfigFields> = {
"control.controller_url": {
description: "URL to connect to the controller at",
+21
View File
@@ -383,6 +383,27 @@ 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"]
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
factorio_version: version,
} as any)),
...["2.0"]
.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 },
],
} as any)),
];
/**
* Returns an array of ModInfo containing all mods included in factorio
*
+4
View File
@@ -4,6 +4,10 @@ export function StringEnum<T extends string[]>(values: [...T]) {
return Type.Unsafe<T[number]>({ type: "string", enum: values });
}
export function StringKey<T extends string>(object: Record<T, any>) {
return Type.Unsafe<keyof typeof object>({ type: "string", enum: Object.keys(object) });
}
export interface JSONDeserialisable<T> {
fromJSON(json: unknown): T;
jsonSchema: object;
+6 -2
View File
@@ -5,12 +5,15 @@
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModPack, ModSetting, ModSettingColor, ModRecord } from "./ModPack";
export { ModSettingColor } from "./ModPack";
export type { ModSetting, ModRecord } from "./ModPack";
export { default as ModPack } from "./ModPack";
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, IControllerUser } from "./User";
export { default as User } from "./User";
export type { IControllerUser } from "./User";
export * from "./composites";
export * from "./messages_core";
export * from "./messages_controller";
@@ -18,4 +21,5 @@ export * from "./messages_host";
export * from "./messages_instance";
export * from "./messages_mod";
export * from "./messages_user";
export * from "./messages_plugin";
export * from "./version";
+14 -3
View File
@@ -1,5 +1,5 @@
import { Type, Static } from "@sinclair/typebox";
import { JsonString, StringEnum, plainJson } from "./composites";
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
import { levels } from "../logging";
import { ControllerConfig, HostConfig } from "../config";
@@ -19,6 +19,14 @@ export class ControllerRestartRequest {
static permission = "core.controller.restart" as const;
}
export class ControllerUpdateRequest {
declare ["constructor"]: typeof ControllerUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.controller.update" as const;
}
export class ControllerConfigGetRequest {
declare ["constructor"]: typeof ControllerConfigGetRequest;
static type = "request" as const;
@@ -217,12 +225,12 @@ export class LogMessageEvent {
static dst = ["controller", "control"] as const;
constructor(
public info: { level: string, message: string },
public info: { level: keyof typeof levels, message: string },
) { }
static jsonSchema = Type.Object({
"info": Type.Object({
"level": Type.String(),
"level": StringKey(levels),
"message": Type.String(),
}),
});
@@ -250,6 +258,7 @@ export class SystemInfo {
public diskCapacity: number,
public diskAvailable: number,
public canRestart: boolean,
public restartRequired: boolean,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number,
public isDeleted: boolean,
@@ -268,6 +277,7 @@ export class SystemInfo {
"diskCapacity": Type.Number(),
"diskAvailable": Type.Number(),
"canRestart": Type.Boolean(),
"restartRequired": Type.Boolean(),
"updatedAtMs": Type.Number(),
"isDeleted": Type.Boolean(),
});
@@ -286,6 +296,7 @@ export class SystemInfo {
json.diskCapacity,
json.diskAvailable,
json.canRestart,
json.restartRequired,
json.updatedAtMs,
json.isDeleted,
);
+8
View File
@@ -19,6 +19,14 @@ export class HostRestartRequest {
static permission = "core.host.restart" as const;
}
export class HostUpdateRequest {
declare ["constructor"]: typeof HostUpdateRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update" as const;
}
export class HostConfigGetRequest {
declare ["constructor"]: typeof HostConfigGetRequest;
static type = "request" as const;
+3 -1
View File
@@ -137,14 +137,16 @@ export class InstanceCreateRequest {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public cloneFromId?: number,
) { }
static jsonSchema = Type.Object({
"config": InstanceConfig.jsonSchema,
"cloneFromId": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.config);
return new this(json.config, json.cloneFromId);
}
}
+107
View File
@@ -197,6 +197,77 @@ export class ModSearchRequest {
};
}
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: Type.String(),
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: Type.String() }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
sha1: Type.String(),
});
// Define the structure for mod details returned by the portal API
// This should align with the ModDetails interface in ModStore.ts
export const ModPortalDetailsSchema = Type.Object({
name: Type.String(),
title: Type.String(),
summary: Type.String(),
owner: Type.String(),
downloads_count: Type.Integer(),
category: Type.Optional(Type.String()),
score: Type.Optional(Type.Number()),
latest_release: Type.Optional(ModPortalReleaseSchema),
releases: Type.Optional(Type.Array(ModPortalReleaseSchema)),
});
export class ModPortalGetAllRequest {
declare ["constructor"]: typeof ModPortalGetAllRequest;
static type = "request" as const;
static src = "control" as const;
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 hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
"hide_deprecated": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.factorioVersion, json.hide_deprecated);
}
// Define the Response class inline
static Response = class ModPortalGetAllResponse {
declare ["constructor"]: typeof ModPortalGetAllResponse;
constructor(
public mods: Static<typeof ModPortalDetailsSchema>[],
) { }
static jsonSchema = Type.Object({
mods: Type.Array(ModPortalDetailsSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
// No transformation needed if schema matches API structure
return new this(json.mods);
}
};
}
export class ModDownloadRequest {
declare ["constructor"]: typeof ModDownloadRequest;
static type = "request" as const;
@@ -284,3 +355,39 @@ export class ModUpdatesEvent {
return new this(json.updates.map(update => ModInfo.fromJSON(update)));
}
}
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
* The controller handles the actual download process asynchronously.
* 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 factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
declare ["constructor"]: typeof ModPortalDownloadRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = ModInfo;
constructor(
public name: string,
public version: string,
public factorioVersion: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { Type, Static } from "@sinclair/typebox";
import { jsonArray } from "./composites";
import { PluginNodeEnvInfo } from "../plugin";
/* This is similar to other plugin definitions but with lots removed */
export class PluginDetails {
constructor(
public name: string,
public title: string,
public version: string,
public loaded: boolean,
public enabled: boolean,
public description?: string,
public npmPackage?: string,
) {}
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
version: Type.String(),
loaded: Type.Boolean(),
enabled: Type.Boolean(),
description: Type.Optional(Type.String()),
npmPackage: Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name, json.title, json.version, json.loaded, json.enabled,
json.description, json.npmPackage
);
}
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
return new this(
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
pluginInfo.description, pluginInfo.npmPackage,
);
}
}
export class PluginListRequest {
declare ["constructor"]: typeof PluginListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = ["controller", "host"] as const;
static permission = "core.plugin.list";
static Response = jsonArray(PluginDetails);
}
export class PluginUpdateRequest {
declare ["constructor"]: typeof PluginUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = ["controller", "host"] as const;
static permission = "core.plugin.update";
constructor(
public pluginPackage: string,
) {}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.pluginPackage;
}
}
export class PluginInstallRequest {
declare ["constructor"]: typeof PluginInstallRequest;
static type = "request" as const;
static src = "control" as const;
static dst = ["controller", "host"] as const;
static permission = "core.plugin.install";
constructor(
public pluginPackage: string,
) {}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.pluginPackage;
}
}
+129 -2
View File
@@ -1,8 +1,9 @@
import { Type, Static } from "@sinclair/typebox";
import Permission from "./Permission";
import Role from "./Role";
import User from "./User";
import { JsonNumber, jsonArray } from "./composites";
import User, { IControllerUser } from "./User";
import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
@@ -320,3 +321,129 @@ export class UserUpdatesEvent {
return new this(json.updates.map(update => User.fromJSON(update)));
}
}
export class ClusterioUserExport {
public export_version = "2.0.0-alpha.20"; // Only increment when jsonSchema changes
static clusterioUserSchema = Type.Object({
"username": Type.String(),
"is_admin": Type.Optional(Type.Boolean()),
"is_whitelisted": Type.Optional(Type.Boolean()),
"is_banned": Type.Optional(Type.Boolean()),
"ban_reason": Type.Optional(Type.String()),
});
static factorioUserSchema = Type.Union([
Type.String(),
Type.Object({
"username": Type.String(),
"reason": Type.String(),
}),
]);
static jsonSchema = Type.Object({
"export_version": Type.String(),
"users": Type.Array(this.clusterioUserSchema),
});
constructor(
public users: Static<typeof ClusterioUserExport.jsonSchema.properties.users>,
) { }
static fromJSON(json: Static<typeof this.jsonSchema>) {
const obj = new this(json.users);
obj.export_version = json.export_version;
return obj;
}
}
export class UserBulkImportRequest {
declare ["constructor"]: typeof UserBulkImportRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission(user: IControllerUser, 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
if (data.restore) {
user.checkPermission("core.user.bulk_restore");
} else {
user.checkPermission("core.user.bulk_import");
}
// Check if they have the permission for that type
switch (data.importType) {
case "users":
user.checkPermission("core.user.set_admin");
user.checkPermission("core.user.set_banned");
user.checkPermission("core.user.set_whitelisted");
break;
case "admins":
user.checkPermission("core.user.set_admin");
break;
case "bans":
user.checkPermission("core.user.set_banned");
break;
case "whitelist":
user.checkPermission("core.user.set_whitelisted");
break;
default:
// @ts-expect-error Unreachable
throw new Error(`Unknown import / restore type: ${data.importType}`);
}
}
}
static Response = plainJson(Type.Union([
Type.Array(ClusterioUserExport.factorioUserSchema),
ClusterioUserExport.jsonSchema,
]));
constructor(
public importType: "users" | "bans" | "admins" | "whitelist",
public users: Static<typeof ClusterioUserExport.clusterioUserSchema>[]
| Static<typeof ClusterioUserExport.factorioUserSchema>[],
public restore?: boolean
) { }
static jsonSchema = Type.Union([
Type.Object({
"importType": StringEnum(["bans", "admins", "whitelist"]),
"users": Type.Array(ClusterioUserExport.factorioUserSchema),
"restore": Type.Optional(Type.Boolean()),
}),
Type.Object({
"importType": Type.Literal("users"),
"users": Type.Array(ClusterioUserExport.clusterioUserSchema),
"restore": Type.Optional(Type.Boolean()),
}),
]);
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.importType, json.users, json.restore);
}
}
export class UserBulkExportRequest {
declare ["constructor"]: typeof UserBulkExportRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.bulk_export" as const;
static Response = plainJson(Type.Union([
Type.Array(ClusterioUserExport.factorioUserSchema),
ClusterioUserExport.jsonSchema,
]));
constructor(
public exportType: "users" | "bans" | "admins" | "whitelist",
) { }
static jsonSchema = Type.Object({
"exportType": StringEnum(["users", "bans", "admins", "whitelist"]),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.exportType);
}
}
+43 -25
View File
@@ -9,6 +9,7 @@ import fs from "fs-extra";
import * as libFileOps from "./file_ops";
import { basicType } from "./helpers";
export type ItemCountWithQuality = { [quality: string]: number };
/**
* Converts a Map with only string keys to a JavaScript object.
@@ -154,6 +155,11 @@ function checkCount(count: unknown): asserts count is number {
}
}
function checkQuality(quality: unknown): asserts quality is string {
if (typeof quality !== "string") {
throw new Error("quality must be a string");
}
}
/**
* Item database
@@ -164,7 +170,7 @@ function checkCount(count: unknown): asserts count is number {
* deserialized the content is verified.
*/
export class ItemDatabase {
private _items: Map<string, number> = new Map();
private _items: Map<string, ItemCountWithQuality> = new Map();
/**
* Create a new item database
@@ -177,11 +183,17 @@ export class ItemDatabase {
constructor(serialized?: object) {
// Verify the content of the serialized database
if (serialized !== undefined) {
for (let [name, count] of Object.entries(serialized)) {
for (let [name, qualities] of Object.entries(serialized)) {
checkName(name);
checkCount(count);
// Migration from pre-quality database format
if (typeof qualities === "number") {
qualities = { normal: qualities };
}
for (const count of Object.values(qualities)) {
checkCount(count);
}
this._items.set(name, count);
this._items.set(name, qualities);
}
}
}
@@ -195,10 +207,15 @@ export class ItemDatabase {
* @returns Serialized representation of the database
*/
serialize() {
let obj: Record<string, number> = {};
for (let [name, count] of this._items) {
if (count !== 0) {
obj[name] = count;
let obj: Record<string, ItemCountWithQuality> = {};
for (let [name, qualities] of this._items) {
for (const quality of Object.getOwnPropertyNames(qualities)) {
if (qualities[quality] !== 0) {
if (!obj[name]) {
obj[name] = {};
}
obj[name][quality] = qualities[quality];
}
}
}
return obj;
@@ -222,16 +239,18 @@ export class ItemDatabase {
* then 0 is returned.
*
* @param name - The name of the item to get the count of.
* @param quality - The quality of the item to get the count of.
* @returns The count of the item stored.
*/
getItemCount(name: string): number {
getItemCount(name: string, quality: string): number {
checkName(name);
checkQuality(quality);
if (!this._items.has(name)) {
if (!this._items.get(name)?.[quality]) {
return 0;
}
return this._items.get(name)!;
return this._items.get(name)![quality];
}
/**
@@ -241,16 +260,21 @@ export class ItemDatabase {
*
* @param name - The name of the item to add.
* @param count - The count of item to remove.
* @param quality - The quality of the item to add.
*/
addItem(name: string, count: number) {
addItem(name: string, count: number, quality: string) {
checkName(name);
checkCount(count);
checkQuality(quality);
if (this._items.has(name)) {
count += this._items.get(name)!;
if (!this._items.has(name)) {
this._items.set(name, { [quality]: count });
return;
}
this._items.set(name, count);
// Nullish coalescing operator is used to avoid NaN when the quality is not present
let currentCount = this._items.get(name)![quality] ?? 0;
this._items.get(name)![quality] = currentCount + count;
};
/**
@@ -262,24 +286,18 @@ export class ItemDatabase {
*
* @param name - The name of the item to remove.
* @param count - The count of items to remove.
* @param quality - The quality of the item to remove.
*/
removeItem(name: string, count: number) {
checkName(name);
removeItem(name: string, count: number, quality: string) {
checkCount(count);
count = -count;
if (this._items.has(name)) {
count += this._items.get(name)!;
}
this._items.set(name, count);
this.addItem(name, -count, quality);
};
/**
* Allow to iterate through the items.
* @returns array of items.
*/
getEntries(): IterableIterator<[string, number]> {
getEntries(): IterableIterator<[string, ItemCountWithQuality]> {
return this._items.entries();
}
}
+5
View File
@@ -189,6 +189,11 @@ export abstract class Datastore<
return this.data.get(key);
}
// Allow getting the size of the datastore
get size() {
return this.data.size;
}
// Allow iterating through the datastore like a Map.
[Symbol.iterator](): IterableIterator<[K, Readonly<V>]> {
return this.data.entries();
+67 -12
View File
@@ -3,6 +3,8 @@ import zlib from "zlib";
class MapReaderState {
pos = 0;
last_position = { x: 0, y: 0 };
/** True when a version greater than 2.0.0 is detected */
v2 = false;
constructor(
public buf: Buffer
) { }
@@ -159,18 +161,49 @@ function readBoundingBox(state: MapReaderState) {
}
function readCliffSettings(state: MapReaderState) {
return {
return state.v2 ? {
name: readString(state),
elevation_0: readFloat(state),
elevation_interval: readFloat(state),
control: readString(state), // v2
cliff_elevation_0: readFloat(state),
cliff_elevation_interval: readFloat(state),
richness: readFloat(state),
cliff_smoothing: readFloat(state), // v2
} : {
name: readString(state),
cliff_elevation_0: readFloat(state),
cliff_elevation_interval: readFloat(state),
richness: readFloat(state),
};
}
function readMapGenSettings(state: MapReaderState) {
function readTerritorySettings(state: MapReaderState) {
return {
terrain_segmentation: readFloat(state),
water: readFloat(state),
units: readArray(state, readString),
territory_index_expression: readString(state),
territory_variation_expression: readString(state),
minimum_territory_size: readUInt32(state),
};
}
function readMapGenSettings(state: MapReaderState) {
return state.v2 ? {
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
seed: readUInt32(state),
width: readUInt32(state),
height: readUInt32(state),
area_to_generate_at_start: readBoundingBox(state),
starting_area: readFloat(state),
peaceful_mode: readBool(state),
no_enemies_mode: readBool(state), // v2
starting_points: readArray(state, readMapPosition),
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
cliff_settings: readCliffSettings(state),
territory_settings: readOptional(state, readTerritorySettings), // v2
} : {
terrain_segmentation: readFloat(state), // v1
water: readFloat(state), // v1
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
@@ -303,16 +336,36 @@ function readPathFinder(state: MapReaderState) {
}
function readDifficultySettings(state: MapReaderState) {
return {
recipe_difficulty: readUInt8(state),
technology_difficulty: readUInt8(state),
return state.v2 ? {
technology_price_multiplier: readDouble(state),
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
spoil_time_modifier: readDouble(state), // v2
} : {
recipe_difficulty: readUInt8(state), // v1
technology_difficulty: readUInt8(state), // v1
technology_price_multiplier: readDouble(state),
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)], // v1
};
}
function readAsteroids(state: MapReaderState) {
return {
spawning_rate: readOptional(state, readDouble) ?? 1,
max_ray_portals_expanded_per_tick: readOptional(state, readUInt32) ?? 100,
};
}
function readMapSettings(state: MapReaderState) {
return {
return state.v2 ? {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
} : {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
@@ -372,8 +425,10 @@ export function readMapExchangeString(exchangeString: string) {
let data: MapExchangeData;
try {
const version = readVersion(state);
state.v2 = version[0] >= 2;
data = {
version: readVersion(state),
version: version,
unknown: readUInt8(state),
map_gen_settings: readMapGenSettings(state),
map_settings: readMapSettings(state),
+2
View File
@@ -23,6 +23,7 @@ export enum ConnectionClosed {
ProtocolError = 1002, // Protocol not followed
PolicyViolation = 1008, // Generic code for any endpoint policy
InternalError = 1011, // Endpoint failed to fulfil request
TryAgainLater = 1013, // Temporary server condition blocking requests
// Codes after 3000 are available for frameworks
// However they should be registered to IANA
@@ -32,6 +33,7 @@ export enum ConnectionClosed {
// Codes after 4000 are available for applications
MalformedMessage = 4000,
RecoveryMode = 4001,
};
/**
+11
View File
@@ -4,6 +4,7 @@ import * as host from "../data/messages_host";
import * as instance from "../data/messages_instance";
import * as mod from "../data/messages_mod";
import * as user from "../data/messages_user";
import * as plugin from "../data/messages_plugin";
import * as subscriptions from "../subscriptions";
import type { RequestClass, EventClass } from "./link";
@@ -19,6 +20,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.ControllerStopRequest,
controller.ControllerRestartRequest,
controller.ControllerUpdateRequest,
controller.ControllerConfigGetRequest,
controller.ControllerConfigSetFieldRequest,
controller.ControllerConfigSetPropRequest,
@@ -37,6 +39,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.HostStopRequest,
host.HostRestartRequest,
host.HostUpdateRequest,
host.HostConfigGetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
@@ -97,10 +100,12 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModGetRequest,
mod.ModListRequest,
mod.ModSearchRequest,
mod.ModPortalGetAllRequest,
mod.ModDownloadRequest,
mod.ModDeleteRequest,
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
user.PermissionListRequest,
user.RoleListRequest,
@@ -118,4 +123,10 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
user.UserSetBannedRequest,
user.UserDeleteRequest,
user.UserUpdatesEvent,
user.UserBulkImportRequest,
user.UserBulkExportRequest,
plugin.PluginListRequest,
plugin.PluginUpdateRequest,
plugin.PluginInstallRequest,
];
+57 -1
View File
@@ -131,6 +131,11 @@ definePermission({
title: "Restart controller",
description: "Restart the Node.js controller process if the system is set up for restarting.",
});
definePermission({
name: "core.controller.update",
title: "Update controller",
description: "Remotely update the controller if the controller allows for remote updates.",
});
definePermission({
name: "core.controller.get_config",
title: "Get controller config",
@@ -160,6 +165,11 @@ definePermission({
title: "Restart Hosts",
description: "Restart Node.js host processes if the system they run on are set up for restarting.",
});
definePermission({
name: "core.host.update",
title: "Update Hosts",
description: "Remotely update a host if the host allows for remote updates.",
});
definePermission({
name: "core.host.get_config",
title: "Get host configs",
@@ -373,6 +383,12 @@ definePermission({
description: "Search through the list of mods stored on the controller.",
grantByDefault: true,
});
definePermission({
name: "core.mod.search_portal",
title: "Search mod portal",
description: "Search for mods on the Factorio mod portal.",
grantByDefault: true,
});
definePermission({
name: "core.mod.subscribe",
title: "Subscribe to mod updates",
@@ -381,7 +397,7 @@ definePermission({
});
definePermission({
name: "core.mod.upload",
title: "Upload mod",
title: "Upload mods",
description: "Upload mods to the controller.",
});
definePermission({
@@ -395,6 +411,12 @@ definePermission({
title: "Delete mod",
description: "Delete mods stored on the controller.",
});
definePermission({
name: "core.mod.download_from_portal",
title: "Download mods from portal",
description: "Allow downloading mods directly from the Factorio mod portal to the controller.",
grantByDefault: true,
});
definePermission({
name: "core.permission.list",
@@ -484,6 +506,24 @@ definePermission({
title: "Delete user",
description: "Delete users and all data stored for them.",
});
definePermission({
name: "core.user.bulk_import",
title: "Bulk user import",
description: "Bulk import users including admin, whitelist, and bans." +
" (Imports types are restricted by other permissions, e.g. core.user.set_admin)",
});
definePermission({
name: "core.user.bulk_export",
title: "Bulk user export",
description: "Bulk export users including admin, whitelist, and bans.",
});
definePermission({
name: "core.user.bulk_restore",
title: "Bulk user restore",
description: "Bulk restore users including admin, whitelist, and bans." +
" (Restore types are restricted by other permissions, e.g. core.user.set_admin)",
});
definePermission({
name: "core.log.follow",
title: "Follow cluster log",
@@ -495,6 +535,22 @@ definePermission({
description: "Query past entries in the cluster log. Required to see past entries in instance console.",
});
definePermission({
name: "core.plugin.list",
title: "List plugins",
description: "List all installed plugins on a machine.",
});
definePermission({
name: "core.plugin.update",
title: "Update plugin",
description: "Remotely update a plugin if the target allows remote updates of plugins.",
});
definePermission({
name: "core.plugin.install",
title: "Install plugin",
description: "Remotely install a plugin if the target allows remote installs of plugins.",
});
definePermission({
name: "core.debug.dump_ws",
title: "Dump WebSocket",
+54
View File
@@ -0,0 +1,54 @@
import util from "util";
import path from "path";
import { exec } from "child_process";
import { logger } from "./logging";
import { RequestError } from "./errors";
import { PluginNodeEnvInfo } from "./plugin";
const execAsync = util.promisify(exec);
async 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"
}
async function logExec(cmd: string) {
logger.audit(`RCE | ${cmd}`);
if (!isDev()) {
await execAsync(cmd);
}
}
export async function updatePackage(name: string) {
return logExec(`npm update --save ${name}`);
}
export async function installPackage(name: string) {
return logExec(`npm install --save ${name}`);
}
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
}
return await updatePackage(pluginName);
}
export async function handlePluginInstall(pluginName: string) {
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
// https://www.npmjs.com/package/validate-npm-package-name
throw new RequestError(`Invalid plugin name: ${pluginName}`);
}
const packageName = encodeURI(pluginName);
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
method: "HEAD",
});
if (!npmRequest.ok) {
throw new RequestError(`Unknown plugin: ${packageName}`);
}
return await installPackage(packageName);
}
+1 -2
View File
@@ -135,7 +135,6 @@ export function configCommand(yargs: any) {
export async function handleConfigCommand(
args: Record<string, unknown>,
instance: libConfig.Config<any>,
configPath: string
) {
let command = (args._ as string[])[1];
@@ -165,7 +164,7 @@ export async function handleConfigCommand(
try {
instance.set(args.field as string, args.value);
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance, null, "\t"));
await instance.save();
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
logger.error(err.message);
+2 -1
View File
@@ -134,7 +134,7 @@ function minZip<T>(a: T[], b: T[]): [T, T][] {
let previousTotalCpuMs: number[] = [];
let previousIdleCpuMs: number[] = [];
export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean) {
export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean, restartRequired: boolean) {
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);
@@ -165,6 +165,7 @@ export async function gatherSystemInfo(id: SystemInfo["id"], canRestart: boolean
diskCapacity,
diskAvailable,
canRestart,
restartRequired,
Date.now(),
false,
);
+13 -19
View File
@@ -3,31 +3,25 @@ import type JSZip from "jszip";
/**
* Returns the root folder in the zip file
*
* Returns the name of the folder that all files in the zip file is
* contained in. Throws an error if there are multiple such folders.
* Returns the name of the folder that the first entry in the zip file is
* contained in. Throws an error if this is not in a folder.
*
* This matches the logic Factorio uses when determining the folder to look
* for content in a zip file.
*
* @param zip - Zip to search through.
* @returns name of the root folder.
*/
export function findRoot(zip: JSZip) {
let root: undefined | string;
zip.forEach((relativePath, file) => {
let index = relativePath.indexOf("/");
if (index === -1) {
throw new Error(`Zip contains file '${relativePath}' in root dir`);
}
let pathRoot = relativePath.slice(0, index);
if (root === undefined) {
root = pathRoot;
} else if (root !== pathRoot) {
throw new Error("Zip contains multiple root folders");
}
});
if (root === undefined) {
const relativePath = Object.keys(zip.files)[0];
if (relativePath === undefined) {
throw new Error("Empty zip file");
}
return root;
let index = relativePath.indexOf("/");
if (index === -1) {
throw new Error(`Zip contains file '${relativePath}' in root dir`);
}
return relativePath.slice(0, index);
}
+1
View File
@@ -20,6 +20,7 @@
"./src/schema.ts",
"./src/subscriptions.ts",
"./src/zip_ops.ts",
"./src/file_ops.ts",
"./src/ExponentialBackoff.ts",
"./src/RateLimiter.ts",
"./src/TypedEventEmitter.ts",
+12 -11
View File
@@ -3,10 +3,11 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
"prepare": "tsc --build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
@@ -17,11 +18,11 @@
"node": ">=12"
},
"dependencies": {
"@ant-design/icons": "^5.1.4",
"@ant-design/icons": "^5.6.1",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@swc/core": "^1.4.0",
"antd": "^5.13.0",
"@swc/core": "^1.11.1",
"antd": "^5.24.2",
"assert": "^2.0.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
@@ -36,14 +37,14 @@
"rc-field-form": "^1.37.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",
"react-router-dom": "^6.29.0",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.3",
"swc-loader": "^0.2.6",
"terser-webpack-plugin": "^5.3.9",
"typescript": "^5.5.3",
"terser-webpack-plugin": "^5.3.11",
"typescript": "^5.7.3",
"util": "^0.12.5",
"webpack": "^5.88.2",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-manifest-plugin": "^5.0.0"
},
@@ -51,8 +52,8 @@
"access": "public"
},
"devDependencies": {
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7"
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5"
}
}
@@ -1,10 +1,11 @@
import React, {useContext} from "react";
import { Button, Descriptions, Popconfirm, Space, Typography } from "antd";
import React, {useContext, useState} from "react";
import { Button, Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import * as lib from "@clusterio/lib";
import PluginExtra from "./PluginExtra";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
@@ -17,6 +18,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
import webUiPackage from "../../package.json";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
const { Title } = Typography;
@@ -26,6 +28,7 @@ export default function ControllerPage() {
const control = useContext(ControlContext);
const [systems] = useSystems();
const system = systems.get("controller");
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
return <PageLayout nav={[{ name: "Controller" }]}>
<PageHeader
@@ -53,16 +56,24 @@ export default function ControllerPage() {
}
{
account.hasPermission("core.controller.restart")
&& <Button
disabled={system?.canRestart === false}
onClick={() => {
control.send(
new lib.ControllerRestartRequest()
).catch(notifyErrorHandler("Error restarting controller"));
}}
>
Restart
</Button>
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
<Button
disabled={system?.canRestart === false}
onClick={() => {
control.send(
new lib.ControllerRestartRequest()
).catch(notifyErrorHandler("Error restarting controller"));
}}
>
Restart
{!system?.restartRequired ? undefined
: <ExclamationCircleOutlined style={{ color: "yellow" }}/>}
</Button>
</Tooltip>
}
{
hasNpmButtonPermission(true)
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
}
</Space>}
/>
@@ -82,8 +93,15 @@ export default function ControllerPage() {
<Descriptions.Item label="Disk"><MetricDiskUsed system={system} /></Descriptions.Item>
</Descriptions>
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole controller={true} />
<Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["server"]}
/>
</Flex>
<LogConsole controller={true} maxLevel={maxLevel}/>
</>}
{account.hasPermission("core.controller.get_config") && <ControllerConfigTree />}
<PluginExtra component="ControllerPage" />
+41 -4
View File
@@ -1,15 +1,52 @@
import React from "react";
import { Typography } from "antd";
import { Button, Card, Flex, Image, Typography } from "antd";
import example from "../images/console-error-edge.png";
import PageLayout from "./PageLayout";
import type { ErrorProps } from "./App";
const { Paragraph } = Typography;
function SupportLinkCard(props:{
name: string,
href: string,
image: string,
}) {
return <Card style={{ alignContent: "center" }} styles={{ body: { padding: 10 } }}>
<Flex align="center" gap="middle">
<img src={props.image} style={{ width: 50 }}/>
<Button type="primary" href={props.href}>{props.name}</Button>
</Flex>
</Card>;
}
export default function ErrorPage(props: ErrorProps & { throw?: boolean }) {
if (props.throw) {
throw props.error;
}
export default function ErrorPage(props: ErrorProps) {
return <PageLayout nav={[{ name: "Error" }]}>
<h2>An unexpected error occured</h2>
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
<h2>An Unexpected Error Occurred</h2>
<Paragraph code className="error-traceback">{props.error.message}</Paragraph>
<Paragraph>
Further details can found by saving the console log in dev tools
(F12, "Console", "Save as", see example below).
If this is an issue with a plugin then please report it to the plugin creator;
otherwise, please reach out to the Clusterio team via Github or Discord.
</Paragraph>
<Flex gap="middle">
<SupportLinkCard
name="Github Issues"
href="https://github.com/clusterio/clusterio/issues"
image="https://github.githubassets.com/favicons/favicon-dark.svg"
/>
<SupportLinkCard
name="Support Server"
href="https://discord.gg/mzAsgnm"
image="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
/>
</Flex>
<br/>
<Image height={250} src={example}/>
</PageLayout>;
};
+34 -16
View File
@@ -1,5 +1,6 @@
import React, { useContext } from "react";
import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm } from "antd";
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Tag, Typography, Button, Space, Modal, Popconfirm, Flex, Tooltip } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import { useParams } from "react-router-dom";
import * as lib from "@clusterio/lib";
@@ -7,7 +8,7 @@ import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import { useHost } from "../model/host";
@@ -20,6 +21,7 @@ import {
} from "./system_metrics";
import { formatTimestamp } from "../util/time_format";
import { useSystems } from "../model/system";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
const { Title } = Typography;
@@ -32,6 +34,7 @@ export default function HostViewPage() {
const [systems] = useSystems();
const system = systems.get(hostId);
const [host, synced] = useHost(hostId);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
@@ -88,17 +91,25 @@ export default function HostViewPage() {
}
{
account.hasPermission("core.host.restart")
&& <Button
disabled={system?.canRestart === false}
onClick={() => {
control.sendTo(
{ hostId },
new lib.HostRestartRequest()
).catch(notifyErrorHandler("Error restarting host"));
}}
>
Restart
</Button>
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
<Button
disabled={system?.canRestart === false}
onClick={() => {
control.sendTo(
{ hostId },
new lib.HostRestartRequest()
).catch(notifyErrorHandler("Error restarting host"));
}}
>
Restart
{!system?.restartRequired ? undefined
: <ExclamationCircleOutlined style={{ color: "yellow" }} />}
</Button>
</Tooltip>
}
{
hasNpmButtonPermission(false)
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
}
</Space>;
@@ -141,8 +152,15 @@ export default function HostViewPage() {
<InstanceList instances={hostInstances} size="small" hideAssignedHost />
</>}
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole hosts={[hostId]} />
<Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["server", "http"]}
/>
</Flex>
<LogConsole hosts={[hostId]} maxLevel={maxLevel}/>
</>}
{account.hasPermission("core.host.get_config") && <HostConfigTree id={hostId} available={host.connected} />}
<PluginExtra component="HostViewPage" host={host} />
+18 -7
View File
@@ -1,10 +1,11 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Progress, Switch, Table, Tag, Typography } from "antd";
import CopyOutlined from "@ant-design/icons/lib/icons/CopyOutlined";
import { Button, Flex, Form, InputNumber, Modal, Progress, Space, Switch, Table, Tag, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
import * as lib from "@clusterio/lib";
import webUiPackage from "../../package.json";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
@@ -13,6 +14,7 @@ import PluginExtra from "./PluginExtra";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
RestartRequired,
} from "./system_metrics";
import { useHosts } from "../model/host";
import { useSystems } from "../model/system";
@@ -268,6 +270,12 @@ export default function HostsPage() {
{
title: "Version",
dataIndex: "version",
render: (_, host) => <Space>
{host.version}
{webUiPackage.version === host.version ? undefined : <Tooltip title="Version Mismatch">
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
</Tooltip>}
</Space>,
sorter: (a, b) => strcmp(a.version, b.version),
},
{
@@ -278,11 +286,14 @@ export default function HostsPage() {
{
title: "Connected",
key: "connected",
render: (_, host) => <Tag
color={host.connected ? "#389e0d" : "#cf1322"}
>
{host.connected ? "Connected" : "Disconnected"}
</Tag>,
render: (_, host) => <>
<Tag
color={host.connected ? "#389e0d" : "#cf1322"}
>
{host.connected ? "Connected" : "Disconnected"}
</Tag>
<RestartRequired system={systems.get(host.id)}/>
</>,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
},
]}
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Dropdown, MenuProps, Modal, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Dropdown, Flex, MenuProps, Modal, Space, Spin, Switch, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
@@ -12,7 +12,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import InstanceConfigTree from "./InstanceConfigTree";
import LogConsole from "./LogConsole";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
@@ -185,6 +185,8 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
export default function InstanceViewPage() {
let params = useParams();
let instanceId = Number(params.id);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("server");
const [actionsOnly, setActionsOnly] = useState<boolean>(true);
let navigate = useNavigate();
@@ -229,9 +231,30 @@ export default function InstanceViewPage() {
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
&& <Flex justify="space-between" align="baseline">
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
{
account.hasPermission("core.log.follow")
&& <Flex align="center" gap="middle">
<Switch
checkedChildren="Chat"
unCheckedChildren="Log"
checked={actionsOnly}
onChange={setActionsOnly}
/>
<SelectMaxLogLevel
value={maxLevel}
onChange={setMaxLevel}
hidden={["http"]}
/>
</Flex>
}
</Flex>
}
{
account.hasPermission("core.log.follow")
&& <LogConsole instances={[instanceId]} maxLevel={maxLevel} actionsOnly={actionsOnly}/>
}
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance.status !== "running"} />
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal } from "antd";
import { Button, Form, Input, Modal, Select, Tooltip } from "antd";
import * as lib from "@clusterio/lib";
@@ -13,7 +13,7 @@ import { useInstances } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
function CreateInstanceButton() {
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [open, setOpen] = useState(false);
@@ -28,8 +28,14 @@ function CreateInstanceButton() {
let instanceConfig = new lib.InstanceConfig("control");
instanceConfig.set("instance.name", values.instanceName);
const serializedConfig = instanceConfig.toRemote("controller");
await control.send(new lib.InstanceCreateRequest(serializedConfig));
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
values.instanceClone >= 0 ? values.instanceClone : undefined,
));
setOpen(false);
navigate(`/instances/${instanceConfig.get("instance.id")}/view`);
}
@@ -53,6 +59,16 @@ function CreateInstanceButton() {
<Form.Item name="instanceName" label="Name">
<Input />
</Form.Item>
<Tooltip title="Perform a one time copy of an existing config (assigned host is not copied)">
<Form.Item name="instanceClone" label="Copy Config">
<Select
defaultValue={-1}
options={[{ id: -1, name: "Default Config" }, ...props.instances.values()]
.map(i => ({ value: i.id, label: i.name }))
}
/>
</Form.Item>
</Tooltip>
</Form>
</Modal>
</>;
@@ -67,7 +83,7 @@ export default function InstancesPage() {
<PageHeader
title="Instances"
extra={<>
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
{account.hasPermission("core.instance.create") && <CreateInstanceButton instances={instances}/>}
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instances.forEach(instance => {
if (instance.status === "stopped") {
+51 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useContext, useRef, useState, ReactElement } from "react";
import { Typography } from "antd";
import { Select, Typography } from "antd";
import * as lib from "@clusterio/lib";
@@ -51,10 +51,11 @@ function formatParsedOutput(parsed: Parsed, key: number) {
}
type Info = {
level: string;
level: keyof typeof lib.levels;
message: string;
parsed?: Parsed;
};
function formatLog(info: Info, key: number): ReactElement {
if (info.level === "server" && info.parsed) {
return formatParsedOutput(info.parsed, key);
@@ -68,7 +69,30 @@ type LogConsoleProps = {
controller?: boolean;
hosts?: number[];
instances?: number[];
maxLevel?: keyof typeof lib.levels;
actionsOnly?: boolean;
};
export function SelectMaxLogLevel(props: {
value: keyof typeof lib.levels,
hidden?: (keyof typeof lib.levels)[],
onChange: (option: keyof typeof lib.levels) => void
}) {
return <Select
showSearch
style={{ width: 100 }}
defaultValue={props.value}
onChange={props.onChange}
options={(Object.entries(lib.levels) as [keyof typeof lib.levels, number][])
.filter(([level]) => !props.hidden || !props.hidden.includes(level))
.map(([level, index]) => ({
value: level, label: level.charAt(0).toUpperCase() + level.slice(1), index: index,
}))
.sort((a, b) => a.index - b.index)
}
/>;
}
export default function LogConsole(props: LogConsoleProps) {
let account = useAccount();
let control = useContext(ControlContext);
@@ -82,6 +106,7 @@ export default function LogConsole(props: LogConsoleProps) {
controller: props.controller || false,
hostIds: props.hosts || [],
instanceIds: props.instances || [],
maxLevel: props.maxLevel || undefined,
};
// Scroll view to the anchor so it sticks to the bottom
@@ -95,11 +120,19 @@ export default function LogConsole(props: LogConsoleProps) {
logFilter.controller,
logFilter.hostIds,
logFilter.instanceIds,
undefined,
logFilter.maxLevel,
400,
"desc",
)).then(result => {
setPastLines(result.log.map((info, index) => formatLog(info as Info, -index - 1)).reverse());
setPastLines(result.log
.filter(info => (
!props.actionsOnly
|| (info as Info).level !== "server"
|| (info as Info).parsed?.type === "action"
))
.map((info, index) => formatLog(info as Info, -index - 1))
.reverse()
);
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
@@ -108,16 +141,26 @@ export default function LogConsole(props: LogConsoleProps) {
}
function logHandler(info: Info) {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
if (!props.actionsOnly || info.level !== "server" || info.parsed?.type === "action") {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
}
}
setLines([]);
control.onLog(logFilter, logHandler);
return () => {
control.offLog(logFilter, logHandler);
};
}, [props.all, props.controller, (props.hosts || []).join(), (props.instances || []).join()]);
}, [
props.all,
props.controller,
(props.hosts || []).join(),
(props.instances || []).join(),
props.maxLevel,
props.actionsOnly,
]);
return <>
<Paragraph code className="instance-console">
+387 -5
View File
@@ -1,8 +1,12 @@
import React, { Fragment, useState, useContext } from "react";
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload } from "antd";
import ImportOutlined from "@ant-design/icons/ImportOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import {
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
} from "antd";
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
} from "@ant-design/icons";
import { Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
@@ -21,6 +25,12 @@ import UploadButton from "./UploadButton";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
// Define the allowed Factorio versions based on the lib schema
type FactorioVersion = Static<(typeof lib.ModPortalGetAllRequest)["allowedVersions"][number]>;
// Type alias for the mod structure derived from the library response
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
function ImportModPackButton() {
let control = useContext(ControlContext);
let navigate = useNavigate();
@@ -129,6 +139,375 @@ function CreateModPackButton() {
</>;
}
function SearchModsButton() {
const control = useContext(ControlContext);
const account = useAccount();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<FactorioVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// State for client-side pagination and sorting
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sort, setSort] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// Function to handle download from Mod Portal to Controller
const handleControllerDownload = (
modName: string,
modTitle: string | undefined,
modVersion: string | undefined,
portalFactorioVersion: FactorioVersion,
) => {
if (modVersion) {
control.send(
new lib.ModPortalDownloadRequest(
modName,
modVersion,
portalFactorioVersion
)
).then(() => {
notification.success({
message: "Download started",
description: `${modTitle || modName} v${
modVersion
} is being downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
);
}
};
// Fetch all mods from backend when modal opens or version changes
useEffect(() => {
if (!open || !factorioVersion) {
setAllMods([]); // Clear mods if modal closed or no version
return;
}
setLoading(true);
setError(null);
let canceled = false;
control.send(
new lib.ModPortalGetAllRequest(factorioVersion)
).then((response: any) => {
if (canceled) { return; }
setAllMods(response.mods || []);
setLoading(false);
}).catch(err => {
if (canceled) { return; }
notifyErrorHandler("Error fetching mods from portal")(err);
setError(err);
setAllMods([]);
setLoading(false);
});
// eslint-disable-next-line consistent-return
return () => {
canceled = true;
};
// Re-fetch when modal opens or factorio version changes
}, [open, factorioVersion, control]);
// Memoize the filtered, sorted, and paginated mods (logic remains the same)
const displayedMods = useMemo(() => {
let filtered = allMods;
// Apply search filter (case-insensitive)
if (searchText) {
const lowerSearchText = searchText.toLowerCase();
filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText)
|| mod.title.toLowerCase().includes(lowerSearchText)
);
}
// Apply sorting
if (sort && sortOrder) {
filtered = [...filtered].sort((a, b) => {
let aValue: any = a[sort as keyof ModPortalModType] ?? "";
let bValue: any = b[sort as keyof ModPortalModType] ?? "";
// Handle specific types if necessary (e.g., numbers, dates)
if (sort === "downloads_count") {
aValue = a.downloads_count ?? 0;
bValue = b.downloads_count ?? 0;
return sortOrder === "asc" ? aValue - bValue : bValue - aValue;
}
// Default string comparison
const comparison = strcmp(String(aValue), String(bValue));
return sortOrder === "asc" ? comparison : -comparison;
});
}
// Apply pagination
const startIndex = (page - 1) * pageSize;
return filtered.slice(startIndex, startIndex + pageSize);
}, [allMods, searchText, sort, sortOrder, page, pageSize]);
// Calculate total results based on the filtered list *before* pagination (logic remains the same)
const totalResults = useMemo(() => {
let filtered = allMods;
if (searchText) {
const lowerSearchText = searchText.toLowerCase();
filtered = filtered.filter(mod => mod.name.toLowerCase().includes(lowerSearchText)
|| mod.title.toLowerCase().includes(lowerSearchText)
);
}
return filtered.length;
}, [allMods, searchText]);
// Update search text and factorio version from form
const handleSearch = (changedValues: any, allValues: any) => {
const nameValue = allValues.name;
const versionValue = allValues.factorioVersion as FactorioVersion | undefined;
// Only trigger state updates if values actually changed
if (nameValue !== searchText) {
setSearchText(nameValue || "");
}
if (versionValue && versionValue !== factorioVersion) {
setFactorioVersion(versionValue);
setAllMods([]); // Clear mods when version changes, useEffect will fetch new ones
setLoading(true); // Show loading indicator immediately
}
// Reset page only if search text or version changed
if (nameValue !== searchText || (versionValue && versionValue !== factorioVersion)) {
setPage(1);
}
};
// Update pagination and sorting state when table changes (logic remains the same)
const handleTableChange = (
pagination: any,
filters: any,
sorter: any
) => {
if (pagination.current !== page) {
setPage(pagination.current);
}
if (pagination.pageSize !== pageSize) {
setPageSize(pagination.pageSize);
setPage(1); // Reset to first page if page size changes
}
// Update sort state if sorter exists and has changed
if (sorter.field && (sorter.field !== sort || sorter.order !== (sortOrder === "asc" ? "ascend" : "descend"))) {
setSort(sorter.field);
setSortOrder(sorter.order === "ascend" ? "asc" : "desc");
setPage(1); // Reset to first page on sort change
} else if (!sorter.field && sort !== "name") { // Reset only if not already default
// Clear sort if column header clicked without order - default to name ascending
setSort("name");
setSortOrder("asc");
setPage(1);
}
};
// Helper to get antd sort order format (logic remains the same)
const getColumnSortOrder = (columnKey: string): "ascend" | "descend" | undefined => {
if (sort === columnKey) {
return sortOrder === "asc" ? "ascend" : "descend";
}
return undefined;
};
return <>
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Search</Button>
<Modal
title="Search Mod Portal"
open={open}
onCancel={() => { setOpen(false); }}
width={1000}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
Close
</Button>,
]}
>
<Form
form={form}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
onValuesChange={handleSearch}
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
>
<Form.Item name="name" label="Filter by Name or Title">
<Input placeholder="Start typing to filter..." />
</Form.Item>
<Form.Item
name="factorioVersion"
label="Factorio Version"
>
<Select>
{lib.ModPortalGetAllRequest.allowedVersions.map((literalSchema: any) => (
<Select.Option
key={literalSchema.const}
value={literalSchema.const}
>
{literalSchema.const}
</Select.Option>
)).reverse()}
</Select>
</Form.Item>
</Form>
{/* Display error message if fetching failed */}
{error && <Alert
message="Error Fetching Mods"
description={error.message}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>}
<Table
dataSource={displayedMods}
rowKey={record => record.name}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: pageSize,
total: totalResults,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
}}
expandable={{
expandedRowRender: record => (
<div>
<p><strong>Summary:</strong> {record.summary ?? "N/A"}</p>
<p><strong>Downloads:</strong> {record.downloads_count?.toLocaleString() ?? "N/A"}</p>
<p><strong>Latest Release:</strong></p>
{record.latest_release ? (
<ul>
<li>Version: {record.latest_release.version ?? "N/A"}</li>
<li>
Factorio Version: {
record.latest_release.info_json?.factorio_version ?? "N/A"
}
</li>
<li>
Released: {
record.latest_release.released_at
? new Date(record.latest_release.released_at).toLocaleString()
: "N/A"
}
</li>
</ul>
) : <p>No release information available.</p>}
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
<Button
onClick={() => {
handleControllerDownload(
record.name,
record.title,
record.latest_release!.version,
factorioVersion
);
}}
disabled={!record.latest_release?.version}
>
Download Latest Version
</Button>
)}
</div>
),
}}
columns={[
// Columns remain largely the same
{
title: "Name",
dataIndex: "name",
key: "name",
sorter: true,
sortOrder: getColumnSortOrder("name"),
},
{
title: "Title",
dataIndex: "title",
key: "title",
sorter: true,
sortOrder: getColumnSortOrder("title"),
},
{
title: "Author",
dataIndex: "owner",
key: "owner",
sorter: true,
sortOrder: getColumnSortOrder("owner"),
},
{
title: "Downloads",
dataIndex: "downloads_count",
key: "downloads_count",
sorter: true,
sortOrder: getColumnSortOrder("downloads_count"),
align: "right",
render: (count) => count?.toLocaleString() ?? "N/A",
},
{
title: "Latest Version",
key: "version",
render: (_, record) => record.latest_release?.version ?? "N/A",
},
{
title: "Actions",
key: "actions",
align: "center",
render: (_, record) => (
<Space>
<Tooltip title="Open in Factorio Mod Portal">
<a
href={`https://mods.factorio.com/mod/${record.name}`}
target="_blank"
rel="noopener noreferrer"
>
<Button type="text">
<img
src="https://mods.factorio.com/static/favicon.ico"
alt="Factorio Mod Portal"
/>
</Button>
</a>
</Tooltip>
{account.hasPermission("core.mod.download_from_portal") && record.latest_release && (
<Tooltip title="Download to Controller">
<Button
type="text"
icon={<DownloadOutlined />}
disabled={!record.latest_release.version}
onClick={() => {
handleControllerDownload(
record.name,
record.title,
record.latest_release!.version,
factorioVersion
);
}}
/>
</Tooltip>
)}
</Space>
),
},
]}
/>
</Modal>
</>;
}
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
@@ -223,7 +602,10 @@ export default function ModsPage() {
},
})}
/>
<SectionHeader title="Stored Mods" extra={uploadButton} />
<SectionHeader title="Stored Mods" extra={<Space>
<SearchModsButton />
{uploadButton}
</Space>} />
<Upload.Dragger
className="save-list-dragger"
@@ -0,0 +1,211 @@
import { useContext, useEffect, useState } from "react";
import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
type NpmActionProps = {
setApplyAction(func: () => Promise<void>): void,
form: FormInstance,
target: lib.AddressShorthand,
}
function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
if (target === "controller") {
return await control.send(new lib.ControllerUpdateRequest());
}
return await control.sendTo(target, new lib.HostUpdateRequest());
});
return <>
</>;
}
function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
const [plugins, setPlugins] = useState<lib.PluginDetails[]>([]);
setApplyAction(async () => {
const values = form.getFieldsValue();
if (!values.plugin || values.plugin === "") {
throw new Error("Plugin not provided");
}
await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin));
});
useEffect(() => {
control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => {
setPlugins(newPlugins);
}).catch(notifyErrorHandler("Error fetching role list"));
}, [target]);
return <>
<Form.Item label="Plugin" name="plugin">
<Select
showSearch
placeholder="Select a plugin"
optionLabelProp="label"
options={plugins
.filter(p => p.npmPackage !== undefined)
.map(plugin => ({
value: plugin.npmPackage,
label: `${plugin.title} (v${plugin.version})`,
}))
}
/>
</Form.Item>
</>;
}
function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
const values = form.getFieldsValue();
if (!values.newPlugin || values.newPlugin === "") {
throw new Error("Plugin not provided");
}
await control.sendTo(target, new lib.PluginInstallRequest(values.newPlugin));
});
// TODO custom npm search based on tags
return <>
<Form.Item label="Plugin" name="newPlugin">
<Input />
</Form.Item>
</>;
}
export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: boolean, disabled?: boolean }) {
const control = useContext(ControlContext);
const account = useAccount();
const [open, setOpen] = useState(false);
const [formAction, setFormAction] = useState<string | undefined>(undefined);
const [form] = Form.useForm();
const [allows, setAllows] = useState({
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
});
useEffect(() => {
if (props.target === "controller") {
if (!account.hasPermission("core.controller.get_config")) {
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
return; // If a user doesn't have config perms, then assume true to allow the sending the request
}
control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => {
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
setAllows({
remoteUpdates: config.get("controller.allow_remote_updates"),
pluginUpdates: config.get("controller.allow_plugin_updates"),
pluginInstall: config.get("controller.allow_plugin_install"),
});
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
} else {
if (!account.hasPermission("core.host.get_config")) {
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
return; // If a user doesn't have config perms, then assume true to allow the sending the request
}
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => {
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
setAllows({
remoteUpdates: config.get("host.allow_remote_updates"),
pluginUpdates: config.get("host.allow_plugin_updates"),
pluginInstall: config.get("host.allow_plugin_install"),
});
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
}
}, [props.target]);
function onValuesChange({ action } : { action?: string }) {
if (action) {
setFormAction(action);
}
}
let applyAction: () => Promise<void>;
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
async function onOk() {
if (!applyAction) {
form.setFields([{ name: "action", errors: ["Action is required"] }]);
return;
}
await applyAction();
const values = form.getFieldsValue();
if (values.restart) {
// Ternary not used because TS disliked it
if (props.target === "controller") {
await control.sendTo(props.target, new lib.ControllerRestartRequest());
} else {
await control.sendTo(props.target, new lib.HostRestartRequest());
}
}
setFormAction(undefined);
setOpen(false);
}
return <>
<Button
type="default"
disabled={props.disabled}
onClick={() => { setOpen(true); }}
>Updates</Button>
<Modal
title="Updates"
okText="Apply"
open={open}
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`)
? <Radio.Button value="remote_update" disabled={!allows.remoteUpdates}>
Update Clusterio
</Radio.Button>: undefined}
{account.hasPermission("core.plugin.update")
? <Radio.Button value="plugin_update" disabled={!allows.pluginUpdates}>
Update Plugin
</Radio.Button> : undefined}
{account.hasPermission("core.plugin.install")
? <Radio.Button value="plugin_install" disabled={!allows.pluginInstall}>
Install Plugin
</Radio.Button> : undefined}
</Radio.Group>
</Form.Item>
{
formAction !== undefined
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
&& <Form.Item label="Restart on completion" name="restart">
<Checkbox disabled={props.canRestart === false}/>
</Form.Item>
}
{formAction === "remote_update"
? <NpmRemoteUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_update"
? <NpmPluginUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
{formAction === "plugin_install"
? <NpmPluginInstall {...{setApplyAction, form, target: props.target}}/> : undefined}
</Form>
</Modal>
</>;
}
export function hasNpmButtonPermission(controller: boolean) {
const account = useAccount();
return account.hasAnyPermission(
controller ? "core.controller.update" : "core.host.update",
"core.plugin.update", "core.plugin.install"
);
}
+19 -1
View File
@@ -1,6 +1,6 @@
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Dropdown, Layout, Menu, MenuProps } from "antd";
import { Button, Dropdown, Layout, Menu, MenuProps, Tooltip } from "antd";
import UserOutlined from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
@@ -10,6 +10,9 @@ import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import { pages } from "../pages";
import { DraggingContext } from "../model/is_dragging";
import { saveJson } from "../util/save_file";
import { ControlConfig } from "@clusterio/lib";
import { DownloadOutlined } from "@ant-design/icons";
type MenuItem = Required<MenuProps>["items"][number];
@@ -24,6 +27,8 @@ function isActiveDropzone(element: HTMLElement | null): boolean {
while (element && depth < checkDepth) {
if (element.classList.contains("dropzone")) {
return element.classList.contains("enabled");
} else if (element.classList.contains("ant-upload-drag")) {
return !element.classList.contains("disabled");
}
element = element.parentElement;
depth += 1;
@@ -36,6 +41,7 @@ export default function SiteLayout() {
let [currentSidebarPath, setCurrentSidebarPath] = useState<string | null>(null);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
const [dragging, setDragging] = useState(0);
function SetSidebar(props: { path: string | null }) {
@@ -49,12 +55,24 @@ export default function SiteLayout() {
onClick: ({ key }: { key: string }) => {
if (key === "user") {
navigate(`/users/${account.name}/view`);
} else if (key === "ctlConfig") {
const config = new ControlConfig("control", {
"control.controller_token": control.connector.token,
"control.controller_url": (new URL(webRoot, document.location.href)).href,
});
saveJson("config-control.json", config.toJSON());
} else if (key === "logOut") {
account.logOut();
}
},
items: [
{ label: account.name, key: "user" },
{
label: <Tooltip title="Download credentials and configuration file for cli interface">
Ctl Config <Button size="small" icon={<DownloadOutlined />} />
</Tooltip>,
key: "ctlConfig",
},
{ type: "divider" },
{ label: "Log out", danger: true, key: "logOut" },
],
+352 -9
View File
@@ -1,6 +1,15 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useContext, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Space, Table, Tag } from "antd";
import { Static } from "@sinclair/typebox";
import {
Button, Form, FormInstance, Input, InputRef, Modal,
Radio, Space, Table, Tag, Upload, UploadProps,
} from "antd";
import {
InboxOutlined, SearchOutlined,
} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -8,6 +17,7 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { saveJson } from "../util/save_file";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
@@ -16,7 +26,6 @@ import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
function CreateUserButton() {
let control = useContext(ControlContext);
let navigate = useNavigate();
@@ -57,13 +66,319 @@ function CreateUserButton() {
</>;
}
type UserBulkActionProps = {
setApplyAction(func: () => Promise<void>): void,
form: FormInstance,
restore?: boolean,
}
function UserBulkActionImport({ setApplyAction, form, restore }: UserBulkActionProps) {
const control = useContext(ControlContext);
const account = useAccount();
// Combines an array of users into the main array to be sent to the controller
function combineMixed(
prop: "is_admin" | "is_banned" | "is_whitelisted",
users: Array<Static<typeof lib.ClusterioUserExport.factorioUserSchema>>,
usersToSend: Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>
) {
for (const user of users) {
if (typeof user !== "string") {
// It can only be a ban in this case
const existingUser = usersToSend.get(user.username);
if (existingUser) {
existingUser.is_banned = true;
existingUser.ban_reason = existingUser.ban_reason ? existingUser.ban_reason : user.reason;
} else {
usersToSend.set(user.username, {
is_banned: true,
...user,
});
}
} else {
// We only have the username
const existingUser = usersToSend.get(user);
if (existingUser) {
existingUser[prop] = true;
} else {
usersToSend.set(user, {
username: user,
[prop]: true,
});
}
}
}
}
// Parse a user json and combine into the format to be sent to the controller
function parseUsers(
users: Array<Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>,
usersToSend: Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>
) {
for (const user of users) {
const existingUser = usersToSend.get(user.username);
// Combine all fields with an existing user
if (existingUser) {
existingUser.is_admin ||= user.is_admin;
existingUser.is_banned ||= user.is_banned;
existingUser.is_whitelisted ||= user.is_whitelisted;
existingUser.ban_reason = existingUser.ban_reason ? existingUser.ban_reason : user.ban_reason;
}
// We do not send false values
if (user.is_admin === false) {
delete user.is_admin;
}
if (user.is_banned === false) {
delete user.is_banned;
delete user.ban_reason;
}
if (user.is_whitelisted === false) {
delete user.is_whitelisted;
}
usersToSend.set(user.username, user);
}
}
// Parse a ban json and combine into the format to be sent to the controller
function parseBans(
users: Array<Static<typeof lib.ClusterioUserExport.factorioUserSchema>>,
usersToSend: Map<string, string | { username: string, reason: string }>
) {
for (const user of users) {
if (typeof user === "string") {
if (!usersToSend.has(user)) {
usersToSend.set(user, user);
}
} else if (!user.reason) {
if (!usersToSend.has(user.username)) {
usersToSend.set(user.username, user.username);
}
} else {
usersToSend.set(user.username, user);
}
}
}
// Parse an admin or whitelist json and combine into the format to be sent to the controller
function parseAdminsWhitelist(
users: Array<string>,
usersToSend: Set<string>,
) {
for (const user of users) {
usersToSend.add(user);
}
}
setApplyAction(async () => {
let backup: object | undefined;
const values = form.getFieldsValue();
const importType = restore ? values.restoreType : values.importType;
switch (importType) {
case "mixed": {
// Parse and combine multiple json types then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>();
for (const file of values.fileList as File[]) {
const json = JSON.parse(await file.text());
if (json.export_version) {
parseUsers(json.users, usersToSend);
} else if (file.name.includes("ban")) {
const users = new Map();
parseBans(json, users);
combineMixed("is_banned", [...users.values()], usersToSend);
} else if (file.name.includes("admin")) {
const users = new Set<string>();
parseAdminsWhitelist(json, users);
combineMixed("is_admin", [...users.values()], usersToSend);
} else if (file.name.includes("whitelist")) {
const users = new Set<string>();
parseAdminsWhitelist(json, users);
combineMixed("is_whitelisted", [...users.values()], usersToSend);
} else {
throw new Error(`Unknown json (could not guess by file name): ${file.name}`);
}
}
backup = await control.send(new lib.UserBulkImportRequest("users", [...usersToSend.values()], restore));
break;
}
case "users": {
// Parse and user jsons then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.clusterioUserSchema>>();
for (const file of values.fileList as File[]) {
parseUsers(JSON.parse(await file.text()).users, usersToSend);
}
backup = await control.send(new lib.UserBulkImportRequest("users", [...usersToSend.values()], restore));
break;
}
case "bans": {
// Parse and combine ban jsons then send
const usersToSend = new Map<string, Static<typeof lib.ClusterioUserExport.factorioUserSchema>>();
for (const file of values.fileList as File[]) {
parseBans(JSON.parse(await file.text()), usersToSend);
}
backup = await control.send(new lib.UserBulkImportRequest("bans", [...usersToSend.values()], restore));
break;
}
case "admins":
case "whitelist": {
// Parse and combine admin or whitelist jsons then send
const usersToSend = new Set<string>();
for (const file of values.fileList as File[]) {
parseAdminsWhitelist(JSON.parse(await file.text()), usersToSend);
}
backup = await control.send(
new lib.UserBulkImportRequest(importType, [...usersToSend.keys()], restore)
);
break;
}
default: {
// Should be unreachable
throw new Error(`Unknown importType: ${importType}`);
}
}
if (restore && backup) {
saveJson(`${importType}-backup.json`, backup);
}
});
const normaliseFiles = (e: any) => (Array.isArray(e) ? e : e.fileList).map((f: any) => f.originFileObj);
const uploadProps: UploadProps = {
accept: ".json",
multiple: true,
beforeUpload: (file) => false,
};
return <>
<Form.Item
label="Type"
name={restore ? "restoreType" : "importType"}
initialValue={restore ? "users" : "mixed"}
>
<Radio.Group>
{restore ? undefined : <Radio.Button value="mixed" disabled={!account.hasAnyPermission(
"core.user.set_admin", "core.user.set_banned", "core.user.set_whitelisted"
)}>Mixed</Radio.Button>}
<Radio.Button value="users" disabled={!account.hasAllPermission(
"core.user.set_admin", "core.user.set_banned", "core.user.set_whitelisted"
)}>Users</Radio.Button>
<Radio.Button value="admins" disabled={!account.hasPermission(
"core.user.set_admin"
)}>Admins</Radio.Button>
<Radio.Button value="bans" disabled={!account.hasPermission(
"core.user.set_banned"
)}>Bans</Radio.Button>
<Radio.Button value="whitelist" disabled={!account.hasPermission(
"core.user.set_whitelisted"
)}>Whitelist</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name="fileList" valuePropName="fileList" getValueFromEvent={normaliseFiles} noStyle>
<Upload.Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Click or drag file to this area to import</p>
<p className="ant-upload-hint">Support for a single or bulk import.</p>
</Upload.Dragger>
</Form.Item>
</>;
}
function UserBulkActionExport({ setApplyAction, form }: UserBulkActionProps) {
const control = useContext(ControlContext);
setApplyAction(async () => {
const values = form.getFieldsValue();
const response = await control.send(new lib.UserBulkExportRequest(values.exportType));
switch (values.exportType) {
case "users": return saveJson("clusterio-userlist.json", response);
case "admins": return saveJson("server-adminlist.json", response);
case "bans": return saveJson("server-banlist.json", response);
case "whitelist": return saveJson("server-whitelist.json", response);
default: throw new Error(`Unknown exportType: ${values.importType}`);
}
});
return <>
<Form.Item label="Type" name="exportType" initialValue="users">
<Radio.Group>
<Radio.Button value="users">Users</Radio.Button>
<Radio.Button value="admins">Admins</Radio.Button>
<Radio.Button value="bans">Bans</Radio.Button>
<Radio.Button value="whitelist">Whitelist</Radio.Button>
</Radio.Group>
</Form.Item>
</>;
}
function BulkUserActionButton() {
const account = useAccount();
const [open, setOpen] = useState(false);
const [formAction, setFormAction] = useState<string | undefined>(undefined);
const [form] = Form.useForm();
function onValuesChange({ action } : { action?: string }) {
if (action) {
setFormAction(action);
}
}
let applyAction: () => Promise<void>;
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
async function onOk() {
if (!applyAction) {
form.setFields([{ name: "action", errors: ["Action is required"] }]);
return;
}
await applyAction();
setFormAction(undefined);
setOpen(false);
}
return <>
<Button
type="default"
onClick={() => { setOpen(true); }}
>Bulk Actions</Button>
<Modal
title="Bulk Actions"
okText="Apply"
open={open}
okButtonProps={{disabled: formAction === undefined}}
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
onCancel={() => { setOpen(false); }}
destroyOnClose
>
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
<Form.Item label="Action" name="action">
<Radio.Group value={formAction}>
{account.hasPermission("core.user.bulk_import")
? <Radio.Button value="import">Import</Radio.Button> : undefined}
{account.hasPermission("core.user.bulk_export")
? <Radio.Button value="export">Export</Radio.Button> : undefined}
{account.hasPermission("core.user.bulk_restore")
? <Radio.Button value="restore">Restore</Radio.Button> : undefined}
</Radio.Group>
</Form.Item>
{formAction === "import" ? <UserBulkActionImport {...{setApplyAction, form}}/> : undefined}
{formAction === "export" ? <UserBulkActionExport {...{setApplyAction, form}}/> : undefined}
{formAction === "restore" ? <UserBulkActionImport restore {...{setApplyAction, form}}/> : undefined}
</Form>
</Modal>
</>;
}
export default function UsersPage() {
let account = useAccount();
let control = useContext(ControlContext);
let navigate = useNavigate();
let [users] = useUsers();
let [roles, setRoles] = useState(new Map());
const [roles, setRoles] = useState(new Map());
const searchInput = useRef<InputRef>(null);
useEffect(() => {
control.send(new lib.RoleListRequest()).then(newRoles => {
@@ -76,7 +391,11 @@ export default function UsersPage() {
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
extra={<>
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
? <BulkUserActionButton /> : undefined}
</>}
/>
<Table
columns={[
@@ -93,14 +412,33 @@ export default function UsersPage() {
</Space>,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
filterIcon: (filtered) => <SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />,
onFilter: (value, record) => record.name.toLowerCase().includes((value as string).toLowerCase()),
filterDropdownProps: {
onOpenChange: open => open && setTimeout(() => searchInput.current?.select(), 100),
},
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
<Input.Search
allowClear
ref={searchInput}
placeholder={"Search username"}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys([e.target.value])}
onClear={() => clearFilters && clearFilters({ confirm: true, closeDropdown: true })}
onSearch={() => confirm({ closeDropdown: true })}
/>
</div>
),
},
{
title: "Roles",
key: "roles",
render: (_, user) => (
[...user.roleIds].map(id => <Link to={`/roles/${id}/view`} onClick={e => e.stopPropagation()}>
<Tag key={id}>{(roles.get(id) || { name: id }).name}</Tag>
</Link>)
[...user.roleIds]
.map(id => <Link key={id} to={`/roles/${id}/view`} onClick={e => e.stopPropagation()}>
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
</Link>)
),
},
{
@@ -127,7 +465,12 @@ export default function UsersPage() {
},
]}
dataSource={[...users.values()]}
pagination={false}
pagination={{
defaultPageSize: 50,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100", "200"],
showTotal: total => `${total} Users`,
}}
rowKey={user => user.name}
onRow={(user, rowIndex) => ({
onClick: event => {
@@ -1,7 +1,8 @@
import React from "react";
import * as lib from "@clusterio/lib";
import { Progress } from "antd";
import { Progress, Tooltip } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
export function MetricCpuRatio(props: { system?: lib.SystemInfo }) {
if (!props.system) {
@@ -53,3 +54,12 @@ export function MetricDiskUsed(props: { system?: lib.SystemInfo }) {
const capacity = lib.formatBytes(props.system.diskCapacity);
return `${used} / ${capacity}`;
}
export function RestartRequired(props: { system?: lib.SystemInfo }) {
if (!props.system || !props.system.restartRequired) {
return "";
}
return <Tooltip title="Restart Required">
<ReloadOutlined style={{ color: "yellow" }}/>
</Tooltip>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+5
View File
@@ -15,6 +15,7 @@ import RolesPage from "./components/RolesPage";
import RoleViewPage from "./components/RoleViewPage";
import PluginsPage from "./components/PluginsPage";
import PluginViewPage from "./components/PluginViewPage";
import ErrorPage from "./components/ErrorPage";
export const pages: PluginPage[] = [
{
@@ -87,6 +88,10 @@ export const pages: PluginPage[] = [
sidebarPath: "/plugins",
content: <PluginViewPage />,
},
{
path: "/error",
content: <ErrorPage throw error={new Error("Example Error Message")} />,
},
];
export const sidebarPages = [
+25
View File
@@ -0,0 +1,25 @@
/**
* Downloads a file to the users PC, should only be used following direct user input.
* @param name Name of the file to save
* @param blob Content to save
*/
export function saveFile(name: string, blob: Blob) {
// This is the most common and best supported method, for full support we should consider using npm:file-saver
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.addEventListener("click", (e) => {
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
a.click();
};
/**
* Downloads a json file to the users PC
* @param name Name of the file to save, should include .json
* @param json The object to save as a formated json string
*/
export function saveJson(name: string, json: object) {
saveFile(name, new Blob([JSON.stringify(json, null, 2)], { type: "application/json" }));
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function formatDuration(ms: number): string {
result += formatMinute(Math.floor(ms / 60e3));
ms %= 60e3;
}
result += formatSecond(Math.round(ms / 1e3));
result += formatSecond(Math.floor(ms / 1e3));
return result;
}
+1 -1
View File
@@ -11,7 +11,7 @@ type saveListHandler = (saveListEvent: lib.SaveDetails) => void;
type modPackHandler = (modPack: lib.ModPack) => void;
type modInfoHandler = (modInfo: lib.ModInfo) => void;
type userHandler = (rawUser: lib.User) => void;
type logHandler = (info: { level:string, message:string }) => void;
type logHandler = (info: { level: keyof typeof lib.levels, message: string }) => void;
/**
* Connector for control connection to controller
+3 -1
View File
@@ -138,7 +138,9 @@ module.exports = (env = {}) => ({
moduleIds: "deterministic",
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
// TerserPlugin.swcMinify spams log with "false" after bump to @swc/core@1.11.1
// https://github.com/clusterio/clusterio/issues/737
// minify: TerserPlugin.swcMinify,
terserOptions: {
compress: {
keep_classnames: true,
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -28,10 +28,10 @@
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
@@ -78,6 +78,9 @@ end
function serialize.serialize_personal_logistic_slots(player)
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
return nil
end
local serialized = {
enabled = logistic_point.enabled,
trash_not_requested = logistic_point.trash_not_requested,
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-inventory_sync",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Danielv <danielv@danielv.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -33,13 +33,13 @@
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"antd": "^5.13.0",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
+8 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -27,22 +27,21 @@
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"express": "^4.16.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.1"
},
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"antd": "^5.13.0",
"phin": "^3.7.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
+89 -152
View File
@@ -1,6 +1,5 @@
"use strict";
const assert = require("assert").strict;
const phin = require("phin");
const jwt = require("jsonwebtoken");
const mock = require("../../../test/mock");
@@ -11,6 +10,13 @@ const info = require("../dist/node/index").plugin;
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../dist/node/messages");
const lib = require("@clusterio/lib");
function postJSON(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("player_auth", function() {
describe("controller.js", function() {
@@ -39,6 +45,7 @@ describe("player_auth", function() {
describe("ControllerPlugin", function() {
let controllerPlugin;
let controllerUrl;
let endpoint;
before(async function() {
controllerPlugin = await mock.createControllerPlugin(controller.ControllerPlugin, info);
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_length", 10);
@@ -75,11 +82,8 @@ describe("player_auth", function() {
addInstance(3, "running", false, "running unloaded");
addInstance(4, "stopped", false, "stopped unloaded");
addInstance(5, "running", true, undefined);
let result = await phin({
url: `${controllerUrl}/api/player_auth/servers`,
parse: "json",
});
assert.deepEqual(result.body, ["running loaded", "unnamed server"]);
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]);
for (let id of [1, 2, 3, 4, 5]) {
controllerPlugin.controller.instances.delete(id);
}
@@ -87,128 +91,81 @@ describe("player_auth", function() {
});
describe("/api/player_auth/player_code", function() {
before(function() {
endpoint = `${controllerUrl}/api/player_auth/player_code`;
});
it("should return 400 on invalid json", async function() {
let result = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: "invalid",
});
assert.equal(result.statusCode, 400);
const result = await postJSON(endpoint, "invalid");
assert.equal(result.status, 400);
});
it("should return 400 on valid json which is not an object", async function() {
for (let obj of [true, false, null, "string", 123, ["an", "array"]]) {
let result = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: JSON.stringify(obj),
});
assert.equal(result.statusCode, 400);
const result = await postJSON(endpoint, obj);
assert.equal(result.status, 400);
}
});
it("should return 400 if player_code is not a string", async function() {
for (let obj of [true, false, null, 123, [], {}]) {
let result = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: JSON.stringify({ player_code: obj }),
});
assert.equal(result.statusCode, 400);
const result = await postJSON(endpoint, { player_code: obj });
assert.equal(result.status, 400);
}
});
it("should return invalid player_code if the code is expired", async function() {
let expiresMs = Date.now() - 1000;
controllerPlugin.players.set("expired", { playerCode: "expired", verifyCode: null, expiresMs });
let result = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
data: { player_code: "expired" },
parse: "json",
});
assert.deepEqual(result.body, { error: true, message: "invalid player_code" });
const result = await postJSON(endpoint, { player_code: "expired" });
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return a verify code and token if code is valid", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("valid", { playerCode: "valid", verifyCode: null, expiresMs });
let result = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
data: { player_code: "valid" },
parse: "json",
});
assert.equal(typeof result.body.verify_code, "string");
assert.equal(typeof result.body.verify_token, "string");
const result = await postJSON(endpoint, { player_code: "valid" });
const body = await result.json();
assert.equal(typeof body.verify_code, "string");
assert.equal(typeof body.verify_token, "string");
});
});
describe("/api/player_auth/verify", function() {
before(function() {
endpoint = `${controllerUrl}/api/player_auth/verify`;
});
it("should return 400 on invalid json", async function() {
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: "invalid",
});
assert.equal(result.statusCode, 400);
const result = await postJSON(endpoint, "invalid");
assert.equal(result.status, 400);
});
it("should return 400 on valid json which is not an object", async function() {
for (let obj of [true, false, null, "string", 123, ["an", "array"]]) {
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: JSON.stringify(obj),
});
assert.equal(result.statusCode, 400);
const result = await postJSON(endpoint, obj);
assert.equal(result.status, 400);
}
});
it("should return 400 if player_code, verify_code or verify_token are not strings", async function() {
for (let obj of [true, false, null, 123, [], {}]) {
for (let field of ["player_code", "verify_code", "verify_token"]) {
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
player_code: "str",
verify_code: "str",
verify_token: "str",
[field]: obj,
},
const result = await postJSON(endpoint, {
player_code: "str",
verify_code: "str",
verify_token: "str",
[field]: obj,
});
assert.equal(result.statusCode, 400);
assert.equal(result.status, 400);
}
}
});
it("should return error if verify_token is not valid", async function() {
async function verify(tokenParams, error) {
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
const result = await postJSON(endpoint, {
player_code: "player",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
...tokenParams,
}, Buffer.from(tokenParams.secret || "TestSecretDoNotUse", "base64")),
},
parse: "json",
...tokenParams,
}, Buffer.from(tokenParams.secret || "TestSecretDoNotUse", "base64")),
});
assert.deepEqual(result.body.error, true);
assert.deepEqual((await result.json()).error, true);
}
await verify({ secret: "InvalidSecret" });
await verify({ verify_code: "bad" });
@@ -218,21 +175,16 @@ describe("player_auth", function() {
it("should return invalid player_code if the code is expired", async function() {
let expiresMs = Date.now() - 1000;
controllerPlugin.players.set("expired", { playerCode: "expired", verifyCode: "verify", expiresMs });
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
const result = await postJSON(endpoint, {
player_code: "expired",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "expired",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "expired",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
},
parse: "json",
}, Buffer.from("TestSecretDoNotUse", "base64")),
});
assert.deepEqual(result.body, { error: true, message: "invalid player_code" });
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return verified false if verify code has not yet been set", async function() {
let expiresMs = Date.now() + 1000;
@@ -240,60 +192,46 @@ describe("player_auth", function() {
"unverified",
{ playerCode: "unverified", verifyCode: null, expiresMs },
);
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
const result = await postJSON(endpoint, {
player_code: "unverified",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "unverified",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "unverified",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
},
parse: "json",
}, Buffer.from("TestSecretDoNotUse", "base64")),
});
assert.deepEqual(result.body, { verified: false });
assert.deepEqual(await result.json(), { verified: false });
});
it("should return error if user is missing", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("missing", { playerCode: "missing", verifyCode: "verify", expiresMs });
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
const result = await postJSON(endpoint, {
player_code: "missing",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "missing",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "missing",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
},
parse: "json",
}, Buffer.from("TestSecretDoNotUse", "base64")),
});
assert.deepEqual(result.body, { error: true, message: "invalid user" });
assert.deepEqual(await result.json(), { error: true, message: "invalid user" });
});
it("should return verified true with token if valid verification", async function() {
let expiresMs = Date.now() + 1000;
controllerPlugin.players.set("player", { playerCode: "player", verifyCode: "verify", expiresMs });
let result = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: {
const result = await postJSON(endpoint, {
player_code: "player",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
}, Buffer.from("TestSecretDoNotUse", "base64")),
},
parse: "json",
}, Buffer.from("TestSecretDoNotUse", "base64")),
});
assert.equal(result.body.verified, true);
assert.deepEqual(typeof result.body.token, "string", "missing token");
const body = await result.json();
assert.equal(body.verified, true);
assert.deepEqual(typeof body.token, "string", "missing token");
});
});
@@ -338,25 +276,24 @@ describe("player_auth", function() {
new FetchPlayerCodeRequest("test")
);
let playerCodeResult = await phin({
url: `${controllerUrl}/api/player_auth/player_code`,
method: "POST",
data: { player_code: playerCode },
parse: "json",
});
const playerCodeResult = await postJSON(
`${controllerUrl}/api/player_auth/player_code`,
{ player_code: playerCode }
);
let { verify_code, verify_token } = playerCodeResult.body;
let { verify_code, verify_token } = await playerCodeResult.json();
await controllerPlugin.handleSetVerifyCodeRequest(
new SetVerifyCodeRequest("test", verify_code)
);
let verifyResult = await phin({
url: `${controllerUrl}/api/player_auth/verify`,
method: "POST",
data: { player_code: playerCode, verify_code, verify_token },
parse: "json",
});
assert.equal(verifyResult.body.verified, true);
const user = controllerPlugin.controller.userManager.getByName("test");
const token = controllerPlugin.controller.userManager.signUserToken(user);
const verifyResult = await postJSON(
`${controllerUrl}/api/player_auth/verify`,
{ player_code: playerCode, verify_code, verify_token }
);
assert.equal(verifyResult.status, 200);
assert.deepEqual(await verifyResult.json(), { verified: true, token: token });
});
});
});
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -33,10 +33,10 @@
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
+1 -1
View File
@@ -98,7 +98,7 @@ function setForceFlowStatistic(
statisticName,
reverseDirection,
item
).set(value);
);
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.19",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -28,10 +28,10 @@
"devDependencies": {
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
@@ -66,9 +66,19 @@ describe("statistics_exporter plugin", function() {
assert.equal(instance._instanceForceFlowStatistics.labels(
"7357", "nauvis", "player", "item_production_statistics", "input", "inserter").get(),
10);
assert.equal(instance._instanceForceFlowStatistics.labels(
"7357", "nauvis", "player", "item_production_statistics", "output", "iron-plate").get(),
24);
// Assert that it didn't record inserter consumption as production
assert.equal(instance._instanceForceFlowStatistics.labels(
"7357", "nauvis", "player", "item_production_statistics", "output", "inserter").get(),
0);
assert.equal(instance._instanceGameFlowStatistics.labels(
"7357", "nauvis", "pollution_statistics", "input", "boiler").get(),
2000);
assert.equal(instance._instanceGameFlowStatistics.labels(
"7357", "nauvis", "pollution_statistics", "output", "tree-proxy").get(),
560);
assert.equal(instance._instancePlatformMapping.labels(
"7357", "platform-1", "player", "nauvis").get(),
1);
+48 -32
View File
@@ -22,17 +22,17 @@ import {
const exportCounter = new Counter(
"clusterio_subspace_storage_export_total",
"Resources exported by instance",
{ labels: ["instance_id", "resource"] }
{ labels: ["instance_id", "resource", "quality"] }
);
const importCounter = new Counter(
"clusterio_subspace_storage_import_total",
"Resources imported by instance",
{ labels: ["instance_id", "resource"] }
{ labels: ["instance_id", "resource", "quality"] }
);
const controllerInventoryGauge = new Gauge(
"clusterio_subspace_storage_controller_inventory",
"Amount of resources stored on controller",
{ labels: ["resource"] }
{ labels: ["resource", "quality"] }
);
@@ -73,7 +73,7 @@ async function saveDatabase(
export class ControllerPlugin extends BaseControllerPlugin {
items!: lib.ItemDatabase;
itemUpdateRateLimiter!: lib.RateLimiter;
itemsLastUpdate!: Map<string, number>;
itemsLastUpdate: Map<string, lib.ItemCountWithQuality> = new Map();
subscribedControlLinks!: Set<ControlConnection>;
doleMagicId!: ReturnType<typeof setInterval>;
neuralDole!: dole.NeuralDole;
@@ -91,7 +91,10 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
},
});
this.itemsLastUpdate = new Map(this.items.getEntries());
this.itemsLastUpdate = new Map();
for (const [name, qualities] of this.items.getEntries()) {
this.itemsLastUpdate.set(name, { ...qualities });
}
this.neuralDole = new dole.NeuralDole({ items: this.items });
this.doleMagicId = setInterval(() => {
@@ -116,37 +119,48 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
broadcastStorage() {
let itemsToUpdateMap:Map<string, number> = new Map();
for (let [name, count] of this.items.getEntries()) {
if (this.itemsLastUpdate.get(name) === count) {
continue;
let itemsToUpdate: Item[] = [];
for (const [name, qualities] of this.items.getEntries()) {
const lastQualities = this.itemsLastUpdate.get(name);
for (const [quality, count] of Object.entries(qualities)) {
if (!lastQualities || lastQualities[quality] !== count) {
itemsToUpdate.push(new Item(name, count, quality));
}
}
itemsToUpdateMap.set(name, count);
}
if (!itemsToUpdateMap.size) {
if (!itemsToUpdate.length) {
return;
}
let itemsToUpdate = [...itemsToUpdateMap.entries()];
let update = UpdateStorageEvent.fromJSON({ items: itemsToUpdate });
let update = new UpdateStorageEvent(itemsToUpdate);
this.controller.sendTo("allInstances", update);
for (let link of this.subscribedControlLinks) {
link.send(update);
}
this.itemsLastUpdate = new Map(this.items.getEntries());
this.itemsLastUpdate = new Map();
for (const [name, qualities] of this.items.getEntries()) {
this.itemsLastUpdate.set(name, { ...qualities });
}
}
async handleGetStorageRequest() {
return [...this.items.getEntries()];
const result: Item[] = [];
for (const [name, qualities] of this.items.getEntries()) {
for (const [quality, count] of Object.entries(qualities)) {
result.push(new Item(name, count, quality));
}
}
return result;
}
async handlePlaceEvent(request: PlaceEvent, src: lib.Address) {
let instanceId = src.id;
for (let item of request.items) {
this.items.addItem(item.name, item.count);
exportCounter.labels(String(instanceId), item.name).inc(item.count);
this.items.addItem(item.name, item.count, item.quality || "normal");
exportCounter.labels(String(instanceId), item.name, item.quality || "normal").inc(item.count);
}
this.updateStorage();
@@ -164,55 +178,55 @@ export class ControllerPlugin extends BaseControllerPlugin {
let itemsRemoved = [];
if (method === "simple") {
// Give out as much items as possible until there are 0 left. This
// might lead to one host getting all the items and the rest nothing.
for (let item of request.items) {
let count = this.items.getItemCount(item.name);
const quality = item.quality || "normal";
let count = this.items.getItemCount(item.name, quality);
let toRemove = Math.min(count, item.count);
if (toRemove > 0) {
this.items.removeItem(item.name, toRemove);
itemsRemoved.push(new Item(item.name, toRemove));
this.items.removeItem(item.name, toRemove, quality);
itemsRemoved.push(new Item(item.name, toRemove, quality));
}
}
} else {
let instance = this.controller.instances.get(instanceId);
let instanceName = instance ? instance.config.get("instance.name") : "unkonwn";
let instanceName = instance ? instance.config.get("instance.name") : "unknown";
// use fancy neural net to calculate a "fair" dole division rate.
if (method === "neural_dole") {
for (let item of request.items) {
const quality = item.quality || "normal";
let count = this.neuralDole.divider(
{ name: item.name, count: item.count, instanceId, instanceName }
{ name: item.name, quality, count: item.count, instanceId, instanceName }
);
if (count > 0) {
itemsRemoved.push(new Item(item.name, count));
itemsRemoved.push(new Item(item.name, count, quality));
}
}
// Use dole division. Makes it really slow to drain out the last little bit.
} else if (method === "dole") {
for (let item of request.items) {
const quality = item.quality || "normal";
let count = dole.doleDivider({
object: { name: item.name, count: item.count, instanceId, instanceName },
object: { name: item.name, quality, count: item.count, instanceId, instanceName },
items: this.items,
logItemTransfers: this.controller.config.get("subspace_storage.log_item_transfers"),
logger: this.logger,
});
if (count > 0) {
itemsRemoved.push(new Item(item.name, count));
itemsRemoved.push(new Item(item.name, count, quality));
}
}
// Should not be possible
} else {
throw Error(`Unkown division_method ${method}`);
throw Error(`Unknown division_method ${method}`);
}
}
if (itemsRemoved.length) {
for (let item of itemsRemoved) {
importCounter.labels(String(instanceId), item.name).inc(item.count);
importCounter.labels(String(instanceId), item.name, item.quality || "normal").inc(item.count);
}
this.updateStorage();
@@ -242,8 +256,10 @@ export class ControllerPlugin extends BaseControllerPlugin {
async onMetrics() {
if (this.items) {
for (let [key, count] of this.items.getEntries()) {
controllerInventoryGauge.labels(key).set(Number(count) || 0);
for (const [name, qualities] of this.items.getEntries()) {
for (const [quality, count] of Object.entries(qualities)) {
controllerInventoryGauge.labels(name, quality).set(Number(count) || 0);
}
}
}
}
+67 -50
View File
@@ -8,23 +8,23 @@ const prometheusNNDoleGauge = new Gauge(
"clusterio_subspace_storage_nn_dole_gauge",
"Current demand being supplied by Neural Network ; 1 means all demand covered, "+
"0.5 means about half of each supply is covered, 0 means no items are given",
{ labels: ["resource"] }
{ labels: ["resource", "quality"] }
);
const prometheusDoleFactorGauge = new Gauge(
"clusterio_subspace_storage_dole_factor_gauge",
"The current dole division factor for this resource",
{ labels: ["resource"] }
{ labels: ["resource", "quality"] }
);
export class NeuralDole {
getRequestStats(itemname: string, samples: number): number {
getRequestStats(itemname: string, quality: string, samples: number): number {
let sum = 0;
samples = Math.min(samples, (this.stats[itemname] || []).length - 1);
samples = Math.min(samples, (this.stats[itemname+quality] || []).length - 1);
if (samples < 1) {
return 0.1;
}
for (let i = 1; i <= samples; i++) {
sum += this.stats[itemname][i].req;
sum += this.stats[itemname+quality][i].req;
}
if (sum === 0) {
return 0.1;
@@ -42,55 +42,67 @@ export class NeuralDole {
constructor({ items }: { items: lib.ItemDatabase }) {
// Set some storage variables for the dole divider
this.items = items;
this.itemsLastTick = new Map(items.getEntries());
this.itemsLastTick = new Map();
for (let [name, qualities] of items.getEntries()) {
for (let [quality, count] of Object.entries(qualities)) {
this.itemsLastTick.set(name+quality, count);
}
}
}
doMagic() {
for (let [name, count] of this.items.getEntries()) {
let magicData = doleNN.tick(
count,
this.dole[name],
this.itemsLastTick.get(name) || 0,
this.getRequestStats(name, 5)
);
this.stats[name] = this.stats[name] || [];
this.stats[name].unshift({ req: 0, given: 0 }); // stats[name][0] is the one we currently collect
if (this.stats[name].length>10) {
this.stats[name].pop(); // remove if too many samples in stats
}
for (let [name, qualities] of this.items.getEntries()) {
for (let [quality, count] of Object.entries(qualities)) {
let magicData = doleNN.tick(
count,
this.dole[name+quality],
this.itemsLastTick.get(name+quality) || 0,
this.getRequestStats(name, quality, 5)
);
this.stats[name+quality] = this.stats[name+quality] || [];
this.stats[name+quality].unshift({ req: 0, given: 0 }); // stats[name][0] is the one we are collecting
if (this.stats[name+quality].length>10) {
this.stats[name+quality].pop(); // remove if too many samples in stats
}
this.dole[name] = magicData[0];
// DONE handle magicData[1] for graphing for our users
prometheusNNDoleGauge.labels(name).set(magicData[1] || 0);
this.dole[name+quality] = magicData[0];
// DONE handle magicData[1] for graphing for our users
prometheusNNDoleGauge.labels(name+quality).set(magicData[1] || 0);
}
}
this.itemsLastTick = new Map();
for (let [name, qualities] of this.items.getEntries()) {
for (let [quality, count] of Object.entries(qualities)) {
this.itemsLastTick.set(name+quality, count);
}
}
this.itemsLastTick = new Map(this.items.getEntries());
}
divider(object: { name:string, count:number, instanceId:number, instanceName:string }): number {
divider(object: { name:string, quality:string, count:number, instanceId:number, instanceName:string }): number {
let magicData = doleNN.dose(
object.count, // numReq
this.items.getItemCount(object.name) || 0,
this.itemsLastTick.get(object.name) || 0,
this.dole[object.name],
this.carry[`${object.name} ${object.instanceId}`] || 0,
this.lastRequest[`${object.name}_${object.instanceId}_${object.instanceName}`] || 0,
this.getRequestStats(object.name, 5),
this.debt[`${object.name} ${object.instanceId}`] || 0
this.items.getItemCount(object.name, object.quality) || 0,
this.itemsLastTick.get(object.name+object.quality) || 0,
this.dole[object.name+object.quality],
this.carry[`${object.name} ${object.quality} ${object.instanceId}`] || 0,
this.lastRequest[`${object.name}_${object.quality}_${object.instanceId}_${object.instanceName}`] || 0,
this.getRequestStats(object.name, object.quality, 5),
this.debt[`${object.name} ${object.quality} ${object.instanceId}`] || 0
);
if ((this.stats[object.name] || []).length > 0) {
this.stats[object.name][0].req += Number(object.count);
this.stats[object.name][0].given += Number(magicData[0]);
if ((this.stats[object.name+object.quality] || []).length > 0) {
this.stats[object.name+object.quality][0].req += Number(object.count);
this.stats[object.name+object.quality][0].given += Number(magicData[0]);
}
this.lastRequest[`${object.name}_${object.instanceId}_${object.instanceName}`] = object.count;
this.lastRequest[`${object.name}_${object.quality}_${object.instanceId}_${object.instanceName}`] = object.count;
// 0. Number of items to give in that dose
// 1. New dole for item X
// 2. New carry for item X instance Y
this.dole[object.name] = magicData[1];
this.carry[`${object.name} ${object.instanceId}`] = magicData[2];
this.debt[`${object.name} ${object.instanceId}`] = magicData[3];
this.dole[object.name+object.quality] = magicData[1];
this.carry[`${object.name} ${object.quality} ${object.instanceId}`] = magicData[2];
this.debt[`${object.name} ${object.quality} ${object.instanceId}`] = magicData[3];
// Remove item from DB and send it
this.items.removeItem(object.name, magicData[0]);
this.items.removeItem(object.name, magicData[0], object.quality);
return magicData[0];
}
@@ -106,13 +118,13 @@ export function doleDivider(
logItemTransfers,
logger,
} :{
object: { name:string, count:number, instanceId:number, instanceName:string },
object: { name:string, quality:string, count:number, instanceId:number, instanceName:string },
items: lib.ItemDatabase,
logItemTransfers: boolean,
logger: lib.Logger,
},
) {
let itemCount = items.getItemCount(object.name);
let itemCount = items.getItemCount(object.name, object.quality);
// lower rates will equal more dramatic swings
const doleDivisionRetardation = 10;
// a higher cap will divide the store more ways, but will take longer to
@@ -120,13 +132,14 @@ export function doleDivider(
const maxDoleDivision = 250;
const originalCount = Number(object.count) || 0;
object.count /= ((_doleDivisionFactor[object.name] || 0) + doleDivisionRetardation) / doleDivisionRetardation;
const doleFactor = _doleDivisionFactor[object.name+object.quality] || 0;
object.count /= (doleFactor + doleDivisionRetardation) / doleDivisionRetardation;
object.count = Math.round(object.count);
if (logItemTransfers) {
logger.verbose(
`Serving ${object.count}/${originalCount} ${object.name} from ${itemCount} ${object.name} `+
`with dole division factor ${(_doleDivisionFactor[object.name]||0)} `+
`(real=${((_doleDivisionFactor[object.name] || 0) + doleDivisionRetardation) / doleDivisionRetardation}), `+
`with dole division factor ${doleFactor} `+
`(real=${doleFactor + doleDivisionRetardation}), `+
`item is ${itemCount > object.count?"stocked":"short"}.`
);
}
@@ -134,18 +147,22 @@ export function doleDivider(
// Update existing items if item name already exists
if (itemCount > object.count) {
// If successful, increase dole
_doleDivisionFactor[object.name] = Math.max(_doleDivisionFactor[object.name] || 1, 1) - 1;
items.removeItem(object.name, object.count);
const key = object.name + object.quality;
_doleDivisionFactor[key] = Math.max(_doleDivisionFactor[key] || 1, 1) - 1;
items.removeItem(object.name, object.count, object.quality);
prometheusDoleFactorGauge.labels(object.name).set(_doleDivisionFactor[object.name] || 0);
prometheusDoleFactorGauge
.labels(object.name, object.quality)
.set(_doleDivisionFactor[key] || 0);
return object.count;
}
// if we didn't have enough, attempt giving out a smaller amount next time
_doleDivisionFactor[object.name] = Math.min(
maxDoleDivision, Math.max(_doleDivisionFactor[object.name] || 1, 1) * 2
);
prometheusDoleFactorGauge.labels(object.name).set(_doleDivisionFactor[object.name] || 0);
const key = object.name + object.quality;
_doleDivisionFactor[key] = Math.min(maxDoleDivision, Math.max(_doleDivisionFactor[key] || 1, 1) * 2);
prometheusDoleFactorGauge
.labels(object.name, object.quality)
.set(_doleDivisionFactor[key] || 0);
return 0;
// console.log(`failure out of ${object.name}/${object.count} from ${object.instanceID} (${object.instanceName})`);
}
+7 -5
View File
@@ -9,7 +9,7 @@ import {
Item,
} from "./messages";
type IpcItems = [string, number][];
type IpcItems = [name: string, count: number, quality: string][];
export class InstancePlugin extends BaseInstancePlugin {
pendingTasks!: Set<any>;
@@ -26,6 +26,8 @@ export class InstancePlugin extends BaseInstancePlugin {
this.pendingTasks = new Set();
this.instance.server.on("ipc-subspace_storage:output", (output: IpcItems) => {
this.logger.info("Received output items:");
this.logger.info(JSON.stringify(output));
this.provideItems(output).catch(err => this.unexpectedError(err));
});
this.instance.server.on("ipc-subspace_storage:orders", (orders: IpcItems) => {
@@ -78,8 +80,7 @@ export class InstancePlugin extends BaseInstancePlugin {
return;
}
const fromIpcItems = items.map(item => new Item(item[0], item[1]));
const fromIpcItems = items.map(item => new Item(item[0], item[1], item[2]));
this.instance.sendTo("controller", new PlaceEvent(fromIpcItems));
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
@@ -90,8 +91,9 @@ export class InstancePlugin extends BaseInstancePlugin {
// request items --------------------------------------------------------------
async requestItems(requestItems: IpcItems) {
this.logger.info(`Requesting items: ${JSON.stringify(requestItems)}`);
// Request the items all at once
let fromIpcItems = requestItems.map(item => new Item(item[0], item[1]));
const fromIpcItems = requestItems.map(item => new Item(item[0], item[1], item[2]));
let items = await this.instance.sendTo("controller", new RemoveRequest(fromIpcItems));
if (!items.length) {
@@ -115,7 +117,7 @@ export class InstancePlugin extends BaseInstancePlugin {
let items = event.items;
// XXX this should be moved to instance/clusterio api
items.push(new Item("signal-unixtime", Math.floor(Date.now()/1000)));
items.push(new Item("signal-unixtime", Math.floor(Date.now()/1000), "normal"));
let itemsJson = lib.escapeString(JSON.stringify(items));
let task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
+5 -4
View File
@@ -1,25 +1,26 @@
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
import { type } from "os";
export class Item {
constructor(
public name: string,
public count: number
public count: number,
public quality: string = "normal"
) {
}
static jsonSchema = Type.Tuple([
Type.String(),
Type.Number(),
Type.String(),
]);
toJSON() {
return [this.name, this.count];
return [this.name, this.count, this.quality];
}
static fromJSON(json: Static<typeof Item.jsonSchema>): Item {
return new this(json[0], json[1]);
return new this(json[0], json[1], json[2]);
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.18",
"version": "2.0.0-alpha.21",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -34,13 +34,13 @@
"@clusterio/web_ui": "workspace:*",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"antd": "^5.13.0",
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"antd": "^5.24.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
},
+18 -13
View File
@@ -35,7 +35,7 @@ function StoragePage() {
let locale = useLocale();
let itemMetadata = useItemMetadata();
let storage = useStorage(control);
type ItemFilter = ([name, count]: [string, number]) => boolean;
type ItemFilter = ([name, item]: [string, Item]) => boolean;
let [filter, setFilter] = useState<null | ItemFilter>(null);
function getLocaleName(itemName: string) {
@@ -73,9 +73,9 @@ function StoragePage() {
search = search.replace(/(^| )(\w)/g, "$1\\b$2");
search = search.replace(/ +/g, ".*");
let filterExpr = new RegExp(search, "i");
setFilter(() => ((item: [string, number]) => {
let name = getLocaleName(item[0]);
return filterExpr.test(name) || filterExpr.test(item[0]);
setFilter(() => ((item: [string, Item]) => {
let name = getLocaleName(item[1].name);
return filterExpr.test(name) || filterExpr.test(item[1].name);
}));
}}
/>
@@ -86,29 +86,34 @@ function StoragePage() {
title: "Resource",
key: "resource",
sorter: (a, b) => {
let aName = getLocaleName(a[0]);
let bName = getLocaleName(b[0]);
let aName = getLocaleName(a[1].name);
let bName = getLocaleName(b[1].name);
if (aName < bName) { return -1; }
if (aName > bName) { return 1; }
return 0;
},
render: (_, item) => {
let localeName = getLocaleName(item[0]);
let hasMeta = itemMetadata.get(item[0]);
let localeName = getLocaleName(item[1].name);
let hasMeta = itemMetadata.get(item[1].name);
return <>
<span className={`factorio-icon item-${hasMeta ? item[0] : "unknown-item"}`}/>
<span className={`factorio-icon item-${hasMeta ? item[1].name : "unknown-item"}`}/>
{localeName}
</>;
},
},
{
title: "Quality",
key: "quality",
render: (_, item) => item[1].quality,
},
{
title: "Quantity",
key: "quantity",
align: "right",
defaultSortOrder: "descend",
sorter: (a, b) => a[1] - b[1],
render: (_, item) => numberFormat.format(item[1]),
sorter: (a, b) => a[1].count - b[1].count,
render: (_, item) => numberFormat.format(item[1].count),
},
]}
dataSource={filter ? storage.filter(filter) : storage}
@@ -119,7 +124,7 @@ function StoragePage() {
}
export class WebPlugin extends BaseWebPlugin {
storage = new Map<string, number>();
storage = new Map<string, Item>();
callbacks: (() => void)[] = [];
async init() {
@@ -165,7 +170,7 @@ export class WebPlugin extends BaseWebPlugin {
updateStorage(items: Item[]) {
for (let item of items) {
this.storage.set(item.name, item.count);
this.storage.set(`${item.name}:${item.quality}`, item);
}
for (let callback of this.callbacks) {
callback();
+128 -3
View File
@@ -1,7 +1,7 @@
"use strict";
const assert = require("assert").strict;
const { Controller } = require("@clusterio/controller");
const { ControllerConfig, Address, RequestError } = require("@clusterio/lib");
const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller");
const { ControllerConfig, Address, RequestError, InstanceConfig, HostConfig, SystemInfo } = require("@clusterio/lib");
const { EventEmitter } = require("stream");
class MockEvent {}
@@ -28,7 +28,7 @@ describe("controller/src/Controller", function() {
/** @type {Controller} */
let controller, mockInstanceConfig;
before(async function() {
controller = new Controller({}, [], "", new ControllerConfig("controller"));
controller = new Controller({}, [], new ControllerConfig("controller"));
mockInstanceConfig = new MockInstanceConfig(new Map([
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
]));
@@ -189,5 +189,130 @@ describe("controller/src/Controller", function() {
assert(controller.instances.dirty === true, "dirty flag was not set");
});
});
describe(".finaliseHosts()", function() {
it("resets the connected state to false", function() {
const hostInfo = new HostInfo(1, "", "", new Map(), true);
Controller.finaliseHosts(hostInfo);
assert.equal(hostInfo.connected, false);
assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("does not increment updatedAtMs when there are no changes", function() {
const hostInfo = new HostInfo(1, "", "", new Map(), false);
Controller.finaliseHosts(hostInfo);
assert.equal(hostInfo.connected, false);
assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
});
describe(".finaliseInstances()", function() {
it("resets the stats for assigned instances", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
const instanceInfo = new InstanceInfo(instanceConfig, "running");
Controller.finaliseInstances(instanceInfo);
assert.equal(instanceInfo.status, "unknown");
assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("resets the status for unassigned instances", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
const instanceInfo = new InstanceInfo(instanceConfig, "running");
Controller.finaliseInstances(instanceInfo);
assert.equal(instanceInfo.status, "unassigned");
assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("does not update updatedAtMs when there are no changes (assigned)", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
const instanceInfo = new InstanceInfo(instanceConfig, "unknown");
Controller.finaliseInstances(instanceInfo);
assert.equal(instanceInfo.status, "unknown");
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
it("does not update updatedAtMs when there are no changes (unassigned)", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
const instanceInfo = new InstanceInfo(instanceConfig, "unassigned");
Controller.finaliseInstances(instanceInfo);
assert.equal(instanceInfo.status, "unassigned");
assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
});
});
describe("migrations", function() {
describe("SystemInfo", function() {
it("migrates 'canRestart' from undefined to false", function() { // Alpha 17
const result = Controller.migrateSystems([
{}, { canRestart: true }, { canRestart: false },
]);
assert.equal(result[0].canRestart, false);
assert.equal(result[1].canRestart, true);
assert.equal(result[2].canRestart, false);
});
it("migrates 'restartRequired' from undefined to false", function() { // Alpha 21
const result = Controller.migrateSystems([
{}, { restartRequired: true }, { restartRequired: false },
]);
assert.equal(result[0].restartRequired, false);
assert.equal(result[1].restartRequired, true);
assert.equal(result[2].restartRequired, false);
});
it("does nothing for upto date data", function() {
const systemInfo = new SystemInfo(
1, "name", "n", "k", "m", "cm", "cr", "mc", "ma", "dc", "da", false, false, 0, false
);
const jsonString = JSON.stringify(systemInfo);
const systemInfoJson = JSON.parse(jsonString);
const systemInfoJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateSystems([
systemInfoJson, systemInfoJson, systemInfoJson,
]);
assert.deepEqual(result, [
systemInfoJsonCopy, systemInfoJsonCopy, systemInfoJsonCopy,
]);
});
});
describe("HostInfo", function() {
it("migrates to the new format", function() { // Alpha 19
const result = Controller.migrateHosts([
["", "foo"], ["", "bar"], ["", "baz"],
]);
assert.deepEqual(result, [
"foo", "bar", "baz",
]);
});
it("does nothing for upto date data", function() {
const hostInfo = new HostInfo(1, "", "", new Map());
const jsonString = JSON.stringify(hostInfo);
const hostInfoJson = JSON.parse(jsonString);
const hostInfoJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateHosts([
hostInfoJson, hostInfoJson, hostInfoJson,
]);
assert.deepEqual(result, [
hostInfoJsonCopy, hostInfoJsonCopy, hostInfoJsonCopy,
]);
});
});
describe("InstanceInfo", function() {
it("migrates to the new format", function() { // Alpha 14
const result = Controller.migrateInstances([
{ name: "foo" }, { name: "bar" }, { name: "baz" },
]);
assert.deepEqual(result, [
{ config: { name: "foo" }, status: "running" },
{ config: { name: "bar" }, status: "running" },
{ config: { name: "baz" }, status: "running" },
]);
});
it("does nothing for upto date data", function() {
const instanceConfig = new InstanceConfig("controller");
const instanceInfo = new InstanceInfo(instanceConfig, "unknown");
const jsonString = JSON.stringify(instanceInfo);
const instanceInfoJson = JSON.parse(jsonString);
const instanceInfoJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateInstances([
instanceInfoJson, instanceInfoJson, instanceInfoJson,
]);
assert.deepEqual(result, [
instanceInfoJsonCopy, instanceInfoJsonCopy, instanceInfoJsonCopy,
]);
});
});
});
});
+1 -1
View File
@@ -10,7 +10,7 @@ describe("controller/src/WsServer", function() {
before(function() {
let config = new ControllerConfig("controller");
config.set("controller.trusted_proxies", "127.0.0.0/8, ::1");
let controller = new Controller({}, [], "", config);
let controller = new Controller({}, [], config);
wsServer = controller.wsServer;
});
describe(".remoteAddr()", function() {
+126 -127
View File
@@ -4,13 +4,11 @@ const events = require("events");
const FormData = require("form-data");
const jwt = require("jsonwebtoken");
const http = require("http");
const phin = require("phin");
const { wait } = require("@clusterio/lib");
const routes = require("@clusterio/controller/dist/node/src/routes");
const mock = require("../mock");
describe("controller/src/routes", function() {
let controller;
let server;
@@ -27,20 +25,23 @@ describe("controller/src/routes", function() {
server.close();
});
describe("/api/stream/:id", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/stream`;
});
it("should respond with 404 if stream doesn't exist", async function() {
let response;
response = await phin(`http://localhost:${port}/api/stream/01020304`);
assert.equal(response.statusCode, 404);
response = await phin({
url: `http://localhost:${port}/api/stream/01020304`, method: "PUT",
data: "test stream",
response = await fetch(`${endpoint}/01020304`);
assert.equal(response.status, 404);
response = await fetch(`${endpoint}/01020304`, {
method: "PUT", body: "test stream",
});
assert.equal(response.statusCode, 404);
assert.equal(response.status, 404);
});
it("should respond with 500 if stream times out", async function() {
let stream = await routes.createProxyStream(controller.app);
let response = await phin(`http://localhost:${port}/api/stream/${stream.id}`);
assert.equal(response.statusCode, 500);
let response = await fetch(`${endpoint}/${stream.id}`);
assert.equal(response.status, 500);
});
it("should passthrough a stream", async function() {
let stream;
@@ -49,121 +50,123 @@ describe("controller/src/routes", function() {
responses = await Promise.all([
(async function() {
await wait(100);
return await phin({
url: `http://localhost:${port}/api/stream/${stream.id}`, method: "PUT",
data: "test content",
return await fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
});
}()),
phin(`http://localhost:${port}/api/stream/${stream.id}`),
fetch(`${endpoint}/${stream.id}`),
]);
assert.equal(responses[0].statusCode, 200);
assert.equal(responses[1].statusCode, 200);
assert.equal(responses[1].body.toString(), "test content");
assert.equal(responses[0].status, 200);
assert.equal(responses[1].status, 200);
assert.equal(await responses[1].text(), "test content");
stream = await routes.createProxyStream(controller.app);
responses = await Promise.all([
phin({
url: `http://localhost:${port}/api/stream/${stream.id}`, method: "PUT",
data: "test content",
fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
}),
(async function() {
await wait(100);
return await phin(`http://localhost:${port}/api/stream/${stream.id}`);
return await fetch(`${endpoint}/${stream.id}`);
}()),
]);
assert.equal(responses[0].statusCode, 200);
assert.equal(responses[1].statusCode, 200);
assert.equal(responses[1].body.toString(), "test content");
assert.equal(responses[0].status, 200);
assert.equal(responses[1].status, 200);
assert.equal(await responses[1].text(), "test content");
});
});
describe("/api/upload-save", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/upload-save`;
});
it("should respond with 401 with invalid token", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: { "Content-Type": "application/zip" },
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 401);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
assert.equal(response.status, 401);
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": "invalid",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 401);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
assert.equal(response.status, 401);
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign(
{ aud: "user", user: "invalid" }, Buffer.from("TestSecretDoNotUse", "base64")
),
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 401);
assert.equal(response.status, 401);
controller.userManager.getByName("test").tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign(
{ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")
),
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 401);
assert.equal(response.status, 401);
});
it("should respond with 403 when user has insufficient permission", async function() {
let response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
let response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign(
{ aud: "user", user: "player" }, Buffer.from("TestSecretDoNotUse", "base64")
),
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 403);
assert.equal(response.status, 403);
});
let testToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
it("should respond with 415 if content type is missing or invalid", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "invalid",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "text/plain",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] });
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
});
it("should respond with 400 if invalid multipart request", async function() {
@@ -172,96 +175,96 @@ describe("controller/src/routes", function() {
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "text/plain" });
response = await phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
body: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid file Content-Type"] });
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
form = new FormData();
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
form.append("instance_id", "123");
response = await phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
body: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.equal(response.status, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["instance_id must come before files uploaded"] }
await response.json(), { request_errors: ["instance_id must come before files uploaded"] }
);
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.txt", contentType: "application/zip" });
response = await phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
body: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.equal(response.status, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["filename must end with .zip"] }
await response.json(), { request_errors: ["filename must end with .zip"] }
);
form = new FormData();
form.append("instance_id", "invalid");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
response = await phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
body: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.equal(response.status, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["invalid instance_id"] }
await response.json(), { request_errors: ["invalid instance_id"] }
);
});
it("should respond with 400 if invalid file request", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["Missing or invalid filename parameter"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.txt`, method: "POST",
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["Missing or invalid filename parameter"] });
response = await fetch(`${endpoint}?instance_id=123&filename=test.txt`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["filename must end with .zip"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?filename=test.zip`, method: "POST",
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["filename must end with .zip"] });
response = await fetch(`${endpoint}?filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.equal(response.status, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["Missing or invalid instance_id parameter"] }
await response.json(), { request_errors: ["Missing or invalid instance_id parameter"] }
);
});
@@ -274,82 +277,78 @@ describe("controller/src/routes", function() {
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
response = await phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
body: form.getBuffer(),
});
assert.equal(response.statusCode, 500);
assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 500);
assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] });
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
controller.sendToHostByInstanceId = async (request) => {
await new Promise(() => {});
};
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.equal(response.statusCode, 500);
assert.equal(response.status, 500);
assert.deepEqual(
JSON.parse(response.body), { errors: ["Timed out establishing stream to host"], request_errors: [] }
await response.json(), { errors: ["Timed out establishing stream to host"], request_errors: [] }
);
});
it("should complete a valid transfer", async function() {
controller.sendToHostByInstanceId = async (request) => {
let stream = await phin({
url: `http://localhost:${port}/api/stream/${request.streamId}`,
});
assert.equal(stream.body.toString(), "totally a zip file");
const stream = await fetch(`http://localhost:${port}/api/stream/${request.streamId}`);
assert.equal(await stream.text(), "totally a zip file");
return request.name;
};
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.deepEqual(JSON.parse(response.body), { saves: ["test.zip"] });
assert.equal(response.statusCode, 200);
assert.deepEqual(await response.json(), { saves: ["test.zip"] });
assert.equal(response.status, 200);
});
it("should complete a valid transfer with non-standerd mime type", async function() {
controller.sendToHostByInstanceId = async (request) => {
let stream = await phin({
url: `http://localhost:${port}/api/stream/${request.streamId}`,
});
assert.equal(stream.body.toString(), "totally a zip file");
const stream = await fetch(`http://localhost:${port}/api/stream/${request.streamId}`);
assert.equal(await stream.text(), "totally a zip file");
return request.name;
};
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/x-zip-compressed",
},
data: "totally a zip file",
body: "totally a zip file",
});
assert.deepEqual(JSON.parse(response.body), { saves: ["test.zip"] });
assert.equal(response.statusCode, 200);
assert.deepEqual(await response.json(), { saves: ["test.zip"] });
assert.equal(response.status, 200);
});
});
});
+8 -6
View File
@@ -10,13 +10,15 @@ const { _discoverInstances } = require("@clusterio/host/dist/node/src/Host");
describe("Host testing", function() {
describe("discoverInstances()", function() {
it("should discover test instance", async function() {
let instancePath = path.join("test", "file", "instances");
let instanceInfos = await _discoverInstances(instancePath);
const instancePath = path.join("test", "file", "instances");
const instanceInfos = await _discoverInstances(instancePath);
let referenceConfig = new lib.InstanceConfig("host");
referenceConfig.set("instance.id", 1);
referenceConfig.set("instance.name", "test");
referenceConfig.dirty = false;
const configPath = path.join(instancePath, "test", "instance.json");
const referenceConfig = new lib.InstanceConfig("host", {
...Host.instanceConfigWarning,
"instance.id": 1,
"instance.name": "test",
}, configPath);
assert.deepEqual(instanceInfos, new Map([
[1, {
+1 -3
View File
@@ -200,7 +200,7 @@ describe("class Instance", function() {
beforeEach(function() {
instance.config.set("factorio.sync_banlist", "bidirectional");
instance.config.set("factorio.sync_adminlist", "bidirectional");
// instance.config.set("factorio.sync_whitelist", "bidirectional"); // Not Implemented
instance.config.set("factorio.sync_whitelist", "bidirectional");
});
it("should send InstanceBanlistUpdateEvent for bans", function() {
instance._recordUserUpdate("BAN", "player", "reason");
@@ -257,7 +257,6 @@ describe("class Instance", function() {
);
});
it("should send InstanceWhitelistUpdateEvent for whitelist add", function() {
this.skip(); // Bidirectional not implemented
instance._recordUserUpdate("WHITELISTED", "player");
assert.deepEqual(
connector.sentMessages[0],
@@ -271,7 +270,6 @@ describe("class Instance", function() {
);
});
it("should send InstanceWhitelistUpdateEvent for whitelist remove", function() {
this.skip(); // Bidirectional not implemented
instance._recordUserUpdate("UNWHITELISTED", "player");
assert.deepEqual(
connector.sentMessages[0],
+408 -12
View File
@@ -199,12 +199,16 @@ describe("Integration of Clusterio", function() {
it("should download mods from controller", async function() {
slowTest(this);
await runWithAltHost(async () => {
const clusterioLib = path.join("temp", "test", "alt-mods", "clusterio_lib_0.1.2.zip");
assert(!await fs.pathExists(clusterioLib), "mod was downloaded before the test");
// Use the latest vesion in /dist instead of a hardcoded version
async function checkModDownloaded() {
let files = await fs.readdir(path.join("temp", "test", "alt-mods"));
return files.find(name => name.startsWith("clusterio_lib_"));
}
assert(!await checkModDownloaded(), "mod was downloaded before the test");
await execCtl("instance create alt-mod --id 98");
await execCtl("instance assign alt-mod 5");
await execCtl("instance save create alt-mod");
assert(await fs.pathExists(clusterioLib), "mod was not downloaded by the test");
assert(await checkModDownloaded(), "mod was not downloaded by the test");
});
});
it("should auto start instances with auto_start enabled", async function() {
@@ -252,6 +256,7 @@ describe("Integration of Clusterio", function() {
assert.equal(Object.prototype.hasOwnProperty.call(result, "controller.auth_secret"), false);
});
});
describe("controller config set", function() {
it("sets given config option", async function() {
await execCtl('controller config set controller.name "Test Cluster"');
@@ -269,11 +274,58 @@ describe("Integration of Clusterio", function() {
});
});
describe("controller plugin update", function() {
it("runs", async function() {
// In dev plugins have no npm package, so best we can do is get an error from the controller
assert.rejects(
execCtl("controller plugin update foo"),
"Plugin foo is not installed on this machine"
);
});
// Update always fails, we can not test restart option
});
describe("controller plugin install", function() {
it("runs", async function() {
// Default is to disallow updates, changing this value would require a restart
// Additionally, it can not be changed via ctl, so best we can do is get an error from the controller
assert.rejects(
execCtl("controller plugin install foo"),
"Plugin installs are disabled on this machine"
);
});
// Install always fails, we can not test restart option
});
describe("controller update", function() {
it("runs", async function() {
await execCtl("controller update");
});
it("accepts --restart", async function() {
// We cannot restart the controller, so we check for controller error instead
assert.rejects(
execCtl("controller update --restart"),
"Cannot restart, controller does not have a process monitor to restart it."
);
});
});
describe("controller restart", function() {
it("runs", async function() {
// We cannot restart the controller, so we check for controller error instead
assert.rejects(
execCtl("controller restart"),
"Cannot restart, controller does not have a process monitor to restart it."
);
});
});
describe("host list", function() {
it("runs", async function() {
await execCtl("host list");
});
});
describe("host config", function() {
it("changes host config", async function() {
await execCtl("host config set 4 host.name My-Host");
@@ -297,6 +349,59 @@ describe("Integration of Clusterio", function() {
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
});
});
describe("host plugin list", function() {
it("runs", async function() {
await execCtl("host plugin list 4");
});
});
describe("host plugin update", function() {
it("runs", async function() {
// In dev plugins have no npm package, so best we can do is get an error from the host
assert.rejects(
execCtl("host plugin update 4 foo"),
"Plugin foo is not installed on this machine"
);
});
// Update always fails, we can not test restart option
});
describe("host plugin install", function() {
it("runs", async function() {
// Default is to disallow updates, changing this value would require a restart
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
assert.rejects(
execCtl("host plugin install 4 foo"),
"Plugin installs are disabled on this machine"
);
});
// Install always fails, we can not test restart option
});
describe("host update", function() {
it("runs", async function() {
await execCtl("host update 4");
});
it("accepts --restart", async function() {
// We cannot restart the host, so we check for host error instead
assert.rejects(
execCtl("host update 4 --restart"),
"Cannot restart, host does not have a process monitor to restart it."
);
});
});
describe("host restart", function() {
it("runs", async function() {
// We cannot restart the host, so we check for host error instead
assert.rejects(
execCtl("host restart 4"),
"Cannot restart, host does not have a process monitor to restart it."
);
});
});
describe("host generate-token", function() {
it("runs", async function() {
await execCtl("host generate-token --id 42");
@@ -305,6 +410,7 @@ describe("Integration of Clusterio", function() {
await execCtl("host generate-token");
});
});
describe("host revoke-token", async function() {
it("should disconnect existing host", async function() {
slowTest(this);
@@ -326,6 +432,7 @@ describe("Integration of Clusterio", function() {
assert(sawDisconnected, "No host update with status disconnected was sent after revoking token");
});
});
describe("instance list", function() {
it("runs", async function() {
await execCtl("instance list");
@@ -347,6 +454,23 @@ describe("Integration of Clusterio", function() {
await execCtl(`instance config set-prop test factorio.settings visibility "${value}"`);
await execCtl("instance config set-prop test factorio.settings require_user_verification false");
});
it("can clone an instance", async function() {
slowTest(this);
await execCtl("instance create testClone --id 440 --from 44");
const instances = await getInstances();
assert(instances.has(440), "instance was not created");
assert.equal(instances.get(440).status, "unassigned", "incorrect instance status");
const config = await getControl().send(new lib.InstanceConfigGetRequest(44));
assert.deepEqual(config["factorio.settings"]["visibility"], { lan: true, public: false });
assert.equal(config["factorio.settings"]["require_user_verification"], false);
});
it("errors when cloning an invalid instance", async function() {
slowTest(this);
assert.rejects(
execCtl("instance create testClone --id 440 --from 441"),
new lib.RequestError("Instance with ID 441 does not exist")
);
});
});
describe("instance assign", function() {
@@ -430,6 +554,18 @@ describe("Integration of Clusterio", function() {
await execCtl("instance start test --save world.zip");
await checkInstanceStatus(44, "running");
});
it("allows having a separate console log", async function() {
slowTest(this);
await execCtl("instance stop 44");
await execCtl("instance config set 44 factorio.console_logging true");
await execCtl("instance start test");
await checkInstanceStatus(44, "running");
const checkMessage = `check${Date.now()}`;
await sendRcon(44, checkMessage);
const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log"));
assert(consoleLog.includes(checkMessage));
await execCtl("instance config set 44 factorio.console_logging false");
});
it("copies the save if an autosave is the target", async function() {
slowTest(this);
await execCtl("instance stop 44");
@@ -571,9 +707,8 @@ describe("Integration of Clusterio", function() {
for (let [, , configName, expectedResult] of testConfigs) {
assert.equal(await sendRcon(44, `/config get ${configName}`), `${expectedResult}\n`);
}
});
it("should not change the instance status", async function() {
slowTest(this);
// should not change instance status
await checkInstanceStatus(44, "running");
});
it("should allow creating and removing props", async function() {
@@ -775,17 +910,28 @@ describe("Integration of Clusterio", function() {
await execCtl("instance config set 44 statistics_exporter.load_plugin false");
await execCtl("instance config set 44 subspace_storage.load_plugin false");
let exchangeString = testStrings.modified.replace(/[\n\r]+/g, "");
let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
await execCtl("instance start 44");
const instance = (await getInstances()).get(44);
const isV2 = lib.integerFactorioVersion(instance.factorioVersion) > lib.integerFactorioVersion("2.0.0");
const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, "");
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
await execCtl("instance stop 44");
await execCtl(`instance load-scenario test ${args}`);
await checkInstanceStatus(44, "running");
await sendRcon(44, '/c game.print("disable achievements")');
await sendRcon(44, '/c game.print("disable achievements")');
assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n");
assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n");
assert.equal(
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
);
if (isV2) {
assert.equal(
await sendRcon(44, "/c rcon.print(game.difficulty_settings.spoil_time_modifier)"), "1\n"
);
} else {
assert.equal(
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
);
}
});
});
@@ -1072,8 +1218,15 @@ describe("Integration of Clusterio", function() {
});
describe("mod-pack list", function() {
let result = null;
it("runs", async function() {
await execCtl("mod-pack list");
result = await execCtl("mod-pack list");
});
it("contains defaults", function() {
assert(result !== null, "Failed to return a value");
const stdout = result.stdout.trim();
assert(stdout.indexOf("Base Game") >= 0, "No base game pack");
assert(stdout.indexOf("Space Age") >= 0, "No space age pack");
});
});
@@ -1386,5 +1539,248 @@ describe("Integration of Clusterio", function() {
assert.equal(getControl().userUpdates[0].isDeleted, true);
});
});
describe("user import", function() {
it("should import users", async function() {
await fs.writeJSON(path.join("temp", "test", "import-users.json"), {
export_version: "2.0.0.alpha20",
users: [
{ username: "import_user_admin", is_admin: true },
{ username: "import_user_banned", is_banned: true },
{ username: "import_user_ban_reason", is_banned: true, ban_reason: "banned" },
{ username: "import_user_whitelist", is_whitelisted: true },
{ username: "import_user_none" },
],
});
await execCtl("user import --users import-users.json");
const users = await getUsers();
assert(users.get("import_user_admin").isAdmin, "import_user_admin was not admin");
assert(users.get("import_user_banned").isBanned, "import_user_banned was not banned");
assert(
users.get("import_user_whitelist").isWhitelisted,
"import_user_whitelist was not whitelisted"
);
const userNone = users.get("import_user_none");
assert(!userNone.isAdmin, "import_user_none was admin");
assert(!userNone.isBanned, "import_user_none was banned");
assert(!userNone.isWhitelisted, "import_user_none was whitelisted");
const userBanReason = users.get("import_user_ban_reason");
assert(userBanReason.isBanned, "import_user_ban_reason was not banned");
assert.equal(userBanReason.banReason, "banned", "import_user_ban_reason had incorrect ban reason");
});
it("should import users by json format", async function() {
await fs.writeJSON(path.join("temp", "test", "import-users.json"), {
export_version: "2.0.0.alpha20",
users: [{ username: "import_user", is_admin: true, is_whitelisted: true }],
});
await execCtl("user import import-users.json");
const userFoo = (await getUsers()).get("import_user");
assert(userFoo.isAdmin, "import_user was not admin");
assert(userFoo.isWhitelisted, "import_user was not whitelisted");
});
it("should import bans", async function() {
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
{ username: "import_ban_reason", reason: "banned" },
"import_ban",
]);
await execCtl("user import --bans import-bans.json");
const users = await getUsers();
const userFoo = users.get("import_ban_reason");
assert(userFoo.isBanned, "import_ban_reason was not banned");
assert.equal(userFoo.banReason, "banned", "import_ban_reason had incorrect ban reason");
const userBar = users.get("import_ban");
assert(userBar.isBanned, "import_ban was not banned");
assert.equal(userBar.banReason, "", "import_ban had incorrect ban reason");
});
it("should import bans by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [
"import_ban_filename",
]);
await execCtl("user import import-bans.json");
assert((await getUsers()).get("import_ban_filename").isBanned, "import_ban_filename was not banned");
});
it("should import admins", async function() {
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
"import_admin",
]);
await execCtl("user import --admins import-admins.json");
assert((await getUsers()).get("import_admin").isAdmin, "import_admin was not admin");
});
it("should import admins by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [
"import_admin_filename",
]);
await execCtl("user import import-admins.json");
assert((await getUsers()).get("import_admin_filename").isAdmin, "import_admin_filename was not admin");
});
it("should import whitelist", async function() {
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
"import_whitelist",
]);
await execCtl("user import --whitelist import-whitelist.json");
assert(
(await getUsers()).get("import_whitelist").isWhitelisted,
"import_whitelist was not whitelisted"
);
});
it("should import whitelist by filename", async function() {
await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [
"import_whitelist_filename",
]);
await execCtl("user import import-whitelist.json");
assert(
(await getUsers()).get("import_whitelist_filename").isWhitelisted,
"import_whitelist_filename was not whitelisted"
);
});
it("should reject multiple provided options", async function() {
assert.rejects(execCtl("user import --bans --admins import-admins.json"));
});
});
describe("user export", function() {
before(async function() {
await execCtl("user create export_control");
});
it("should export users", async function() {
slowTest(this);
await execCtl("user set-admin export_user --create");
await execCtl("user set-whitelisted export_user");
await execCtl("user export --users user-export.json");
const data = await fs.readJson(path.join("temp", "test", "user-export.json"));
assert(data.users, "Users array does not exist");
assert(!data.users.find(u => u.username === "export_control"), "Control user is present");
assert.deepEqual(data.users.find(u => u.username === "export_user"), {
username: "export_user", is_admin: true, is_whitelisted: true,
});
});
it("should export bans", async function() {
slowTest(this);
await execCtl("user set-banned export_ban --create");
await execCtl("user set-banned export_ban_reason --create --reason banned");
await execCtl("user export --bans ban-export.json");
const data = await fs.readJson(path.join("temp", "test", "ban-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_ban") >= 0, "export_ban is missing");
assert.deepEqual(data.find(u => typeof u === "object" && u.username === "export_ban_reason"), {
username: "export_ban_reason", reason: "banned",
});
});
it("should export admins", async function() {
await execCtl("user set-admin export_admin --create");
await execCtl("user export --admins admin-export.json");
const data = await fs.readJson(path.join("temp", "test", "admin-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_admin") >= 0, "export_admin is missing");
});
it("should export whitelist", async function() {
await execCtl("user set-whitelisted export_whitelist --create");
await execCtl("user export --whitelist whitelist-export.json");
const data = await fs.readJson(path.join("temp", "test", "whitelist-export.json"));
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
assert(data.indexOf("export_whitelist") >= 0, "export_whitelist is missing");
});
});
describe("user restore", function() {
beforeEach(async function() {
slowTest(this);
await execCtl("user set-admin restore_control --create");
await execCtl("user set-banned restore_control");
await execCtl("user set-whitelisted restore_control");
});
it("should restore users", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-users.json"), {
export_version: "2.0.0.alpha20",
users: [{ username: "restore_user", is_admin: true, is_whitelisted: true }],
});
await execCtl("user restore restore-users.json");
const users = await getUsers();
const user = users.get("restore_user");
assert(user.isAdmin, "restore_user was not admin");
assert(user.isWhitelisted, "restore_user was not whitelisted");
const control = users.get("restore_control");
assert(!control.isAdmin, "restore_user was admin");
assert(!control.isBanned, "restore_user was banned");
assert(!control.isWhitelisted, "restore_user was whitelisted");
const data = await fs.readJson(path.join("temp", "test", "users-backup.json"));
assert(data.users, "Users array does not exist");
assert.deepEqual(data.users.find(u => u.username === "restore_control"), {
username: "restore_control", is_admin: true, is_banned: true, is_whitelisted: true,
});
});
it("should restore bans", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-bans.json"), [
{ username: "restore_ban_reason", reason: "banned" },
"restore_ban",
]);
await execCtl("user restore --bans restore-bans.json");
const users = await getUsers();
const userFoo = users.get("restore_ban_reason");
assert(userFoo.isBanned, "restore_ban_reason was not banned");
assert.equal(userFoo.banReason, "banned", "restore_ban_reason had incorrect ban reason");
const userBar = users.get("restore_ban");
assert(userBar.isBanned, "restore_ban was not banned");
assert.equal(userBar.banReason, "", "restore_ban had incorrect ban reason");
assert(!users.get("restore_control").isBanned, "restore_user was banned");
const data = await fs.readJson(path.join("temp", "test", "bans-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should restore admins", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-admins.json"), [
"restore_admin",
]);
await execCtl("user restore --admins restore-admins.json");
const users = await getUsers();
assert(users.get("restore_admin").isAdmin, "restore_admin was not admin");
assert(!users.get("restore_control").isAdmin, "restore_user was admin");
const data = await fs.readJson(path.join("temp", "test", "admins-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should restore whitelist", async function() {
await fs.writeJSON(path.join("temp", "test", "restore-whitelist.json"), [
"restore_whitelist",
]);
await execCtl("user restore --whitelist restore-whitelist.json");
const users = await getUsers();
assert(users.get("restore_whitelist").isWhitelisted, "restore_whitelist was not whitelisted");
assert(!users.get("restore_control").isWhitelisted, "restore_user was whitelisted");
const data = await fs.readJson(path.join("temp", "test", "whitelist-backup.json"));
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
});
it("should reject multiple provided options", async function() {
assert.rejects(execCtl("user restore --bans --admins restore-admins.json"));
});
});
});
});
-1
View File
@@ -4,7 +4,6 @@ const fs = require("fs-extra");
const jwt = require("jsonwebtoken");
const path = require("path");
const events = require("events");
const phin = require("phin");
const child_process = require("child_process");
const util = require("util");
+39 -44
View File
@@ -18,9 +18,6 @@ require("../../plugins/research_sync/dist/node/index");
require("../../plugins/statistics_exporter/dist/node/index");
require("../../plugins/subspace_storage/dist/node/index");
const subspaceStorageVersion = "1.99.8";
const subspaceStorageGitHubTag = "1.99.6"; // Tag subspaceStorageVersion was released under on GitHub
class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
@@ -178,6 +175,16 @@ async function exec(command, options = {}) {
return await util.promisify(child_process.exec)(command, options);
}
async function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
return await exec(...args);
}
async function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
return await exec(...args);
}
async function execCtl(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
return await exec(...args);
@@ -194,8 +201,15 @@ function getControl() {
function spawn(name, cmd, waitFor) {
// eslint-disable-next-line node/no-process-env
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
function log(...args) {
if (!silent || bootstrap) {
console.log(...args);
}
}
return new Promise((resolve, reject) => {
console.log(cmd);
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
let stdout = new LineSplitter({ readableObjectMode: true });
@@ -209,10 +223,10 @@ function spawn(name, cmd, waitFor) {
}
resolve(process);
}
console.log(name, line);
log(name, line);
};
let onDataErr = line => {
console.log(name, line.toString("utf8"));
log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
@@ -266,53 +280,35 @@ before(async function() {
sourceDir: "packages/host/lua/clusterio_lib",
outputDir: modsDir,
});
const ssv = subspaceStorageVersion;
const ssTag = subspaceStorageGitHubTag;
const ssZip = `subspace_storage_${ssv}.zip`;
if (!await fs.pathExists(`mods/${ssZip}`)) {
console.log(`Downloading ${ssZip}`);
const ssUrl = `https://github.com/clusterio/subspace_storage/releases/download/${ssTag}/${ssZip}`;
let response = await phin({
url: ssUrl,
method: "GET",
stream: false, // phin leaks open sockets when stream and followRedirects is combined
followRedirects: true,
});
if (response.statusCode !== 200) {
throw new Error(`${ssUrl} returned ${response.statusCode} ${response.statusMessage}`);
}
await fs.outputFile(`mods/${ssZip}`, response.body);
}
await fs.copyFile(`mods/${ssZip}`, `${modsDir}/${ssZip}`);
console.log("Setting Controller Config");
await exec("node ../../packages/controller config set controller.auth_secret TestSecretDoNotUse");
await exec("node ../../packages/controller config set controller.http_port 8880");
await exec("node ../../packages/controller config set controller.https_port 4443");
await exec("node ../../packages/controller config set controller.heartbeat_interval 0.25");
await exec("node ../../packages/controller config set controller.session_timeout 2");
await exec("node ../../packages/controller config set controller.tls_certificate ../../test/file/tls/cert.pem");
await exec("node ../../packages/controller config set controller.tls_private_key ../../test/file/tls/key.pem");
await execController("config set controller.auth_secret TestSecretDoNotUse");
await execController("config set controller.http_port 8880");
await execController("config set controller.https_port 4443");
await execController("config set controller.heartbeat_interval 0.25");
await execController("config set controller.session_timeout 2");
await execController("config set controller.tls_certificate ../../test/file/tls/cert.pem");
await execController("config set controller.tls_private_key ../../test/file/tls/key.pem");
console.log("Setting Controller Plugins");
await exec("node ../../packages/ctl plugin add ../../plugins/global_chat");
await exec("node ../../packages/ctl plugin add ../../plugins/research_sync");
await exec("node ../../packages/ctl plugin add ../../plugins/statistics_exporter");
await exec("node ../../packages/ctl plugin add ../../plugins/subspace_storage");
await exec("node ../../packages/ctl plugin add ../../plugins/player_auth");
await execCtl("plugin add ../../plugins/global_chat");
await execCtl("plugin add ../../plugins/research_sync");
await execCtl("plugin add ../../plugins/statistics_exporter");
await execCtl("plugin add ../../plugins/subspace_storage");
await execCtl("plugin add ../../plugins/player_auth");
console.log("Bootstrapping");
await exec("node ../../packages/controller bootstrap create-admin test");
await exec("node ../../packages/controller bootstrap create-ctl-config test");
await exec("node ../../packages/ctl control-config set control.tls_ca ../../test/file/tls/cert.pem");
await execController("bootstrap create-admin test");
await execController("bootstrap create-ctl-config test");
await execCtl("control-config set control.tls_ca ../../test/file/tls/cert.pem");
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
await execCtl("host create-config --id 4 --name host --generate-token");
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await exec(`node ../../packages/host config set host.factorio_directory ${relativeFactorioDir}`);
await exec("node ../../packages/host config set host.tls_ca ../../test/file/tls/cert.pem");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
await execHost("config set host.tls_ca ../../test/file/tls/cert.pem");
hostProcess = await spawnNode("host:", "../../packages/host run", /Started host/);
@@ -325,9 +321,8 @@ before(async function() {
const testPack = lib.ModPack.fromJSON({});
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = "1.1.0";
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "0.1.2" });
testPack.mods.set("subspace_storage", { name: "subspace_storage", enabled: true, version: subspaceStorageVersion });
testPack.factorioVersion = "2.0.0";
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "2.0.20" });
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
await control.sendTo(
"controller",
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more