Compare commits

..
Author SHA1 Message Date
Danielv123 5ddf3fe34f fix(minimap): restore nested previous_recipe name access
Furnace previous_recipe ids are nested under prev.name.name in this context. Restore the original access path and leave an inline maintainer note to prevent repeat regressions.
2026-02-21 19:49:59 +01:00
Danielv123 17480aafff fix(minimap): clarify dual filter API pattern in data source
Dual filter API methods (setInstanceSurfaceFilters and setInstanceSurfaceFilter) both exist for backward compatibility with different plugin implementations. Added comment explaining why both are checked in fallback pattern.
2026-02-21 17:16:06 +01:00
Danielv123 387a9425ce refactor(minimap): deduplicate tile parsing and parallelize file cleanup 2026-02-21 17:11:33 +01:00
Danielv123 550ea22724 fix(minimap): improve error handling and performance in tile parsing
- Add warning logging in renderChunkToPixels catch block before fallback
- Switch maxTick filtering from skip/continue to early break (append-only optimization)
- Use existsSync for directory existence check in loadExistingTagContent
2026-02-21 17:02:50 +01:00
Danielv123 9375c462af fix(minimap): use existing player_position channel for join start event
Replace unsupported minimap:player_session_start IPC with existing minimap:player_position channel to emit immediate session start on join.
2026-02-21 16:49:03 +01:00
Danielv123 e28b4121e3 fix(minimap): use physical position and emit session start on join
- Use player.physical_surface/physical_position for accurate tracking when in vehicles/remotes
- Emit session start record immediately on on_player_joined_game
- Make storage.minimap initialization consistent (init() ensures all fields exist)
2026-02-21 16:46:32 +01:00
Danielv123 a1e8d009ff fix(minimap): restore chart tag interval semantics after task 3
Task 3 inadvertently changed chart tag data schema from start_tick/end_tick
interval model to single tick field. This restores the original interval
semantics for chart tags while preserving legitimate Task 3 fixes.

Reverted:
- create_chart_tag_data(tick) -> create_tag_data(start_tick, end_tick)
- Single-emission in on_chart_tag_modified -> dual-emission (end old, start new)
- All chart tag event handlers use interval model again

Preserved from Task 3:
- prev.name.name fix (both locations)
- Hot-loop local aliases (math_floor, bit32_rshift, table_insert)
- IIFE removal for recipe icon lookup
- Recipe data single tick field (within Task 3 scope)
2026-02-21 16:43:31 +01:00
Danielv123 7c6dd1ff66 fix(minimap): fix prev.name bug and lua code quality improvements
- Fix prev.name.name bug: previous_recipe.name is RecipeID, not nested
- Add hot-loop local references (math_floor, bit32_rshift, table_insert)
- Remove IIFE pattern for icon lookup, use inline logic
- Rename create_tag_data to create_chart_tag_data for consistency
- Use recipe nilness + single tick field instead of start_tick/end_tick split
2026-02-21 16:40:31 +01:00
Danielv123 bb74b615a7 refactor(minimap): tighten typed parse helper returns for player records 2026-02-21 16:34:40 +01:00
Danielv123 c3136532b8 refactor(minimap): use discriminated union for PlayerPositionRecord and extract readSignedInt24 2026-02-21 16:31:20 +01:00
Danielv123 245ceb14fa security(minimap): fix path traversal in controller file loading 2026-02-21 16:25:11 +01:00
Danielv123 5ff7d7ee42 minimap: fix debounced zoom cleanup 2026-02-09 19:19:48 +01:00
Danielv123 35575a93a3 minimap: remove redundant instance RCON init 2026-02-09 19:13:24 +01:00
Danielv123 57b50048bc minimap: address small review feedback 2026-02-09 18:54:55 +01:00
Danielv123 923316b44d Order player session records by receive time 2026-02-05 22:45:20 +01:00
Danielv123 81fcea8398 Revert keyToLabels label parsing 2026-02-05 22:38:17 +01:00
Danielv123 3f8ef85987 Harden minimap tile storage paths 2026-02-05 22:38:13 +01:00
Danielv123 78fc90d7a0 Align minimap plugin package metadata 2026-02-05 22:37:56 +01:00
Danielv123 ebbc14ba71 Use type-only import for InputPartialVersion props 2026-02-05 22:37:56 +01:00
Danielv123 3be1d637c8 Validate tile paths 2026-02-05 21:55:05 +01:00
Danielv123 3d9c682d7c Fix surfaces with underscore in the name 2026-02-03 21:51:53 +01:00
Danielv123 b0fd90d273 Extend minimap components to allow external use 2026-01-29 23:18:26 +01:00
Danielv123 73ab8fc4f3 Player session end tracking 2026-01-29 21:51:19 +01:00
Danielv123 2eedddf17f Don't break version selector 2026-01-28 09:06:52 +01:00
Danielv123 9061d2aa92 Stable label creation, forgot imports of messages 2026-01-28 00:15:41 +01:00
Danielv123 3932ac2cae Merge branch 'plugin-minimap' of https://github.com/clusterio/clusterio into plugin-minimap 2026-01-28 00:08:10 +01:00
Danielv123 107201e6e5 Add antd icons to minimap 2026-01-28 00:07:42 +01:00
Danielv123 a7ab63ba3c Add antd icons to minimap 2026-01-28 00:02:50 +01:00
Danielv123 ec0c068d44 Add button to delete minimap data 2026-01-27 23:49:25 +01:00
Danielv123 264a32431e Fix react errors in factorio version selector 2026-01-27 23:12:15 +01:00
Danielv123 01f6397983 Fix circular imort breaking web UI 2026-01-27 20:37:01 +01:00
Danielv123 979d961618 Merge remote-tracking branch 'origin/master' into plugin-minimap 2026-01-27 17:45:23 +01:00
Danielv123 38d5fdfdba Use filtered subscriptions to save data 2025-08-10 00:28:48 +02:00
Danielv123 ea144863f9 Fix incorrect type in player path request response 2025-08-10 00:28:48 +02:00
Danielv123 48437a5f6c Use SubscriptionController 2025-08-10 00:28:48 +02:00
Danielv123 70ef8a60ad Fix lint, use of sync method in web UI 2025-08-10 00:28:48 +02:00
Danielv123 c4d4cb3afd Remove unused filter 2025-08-10 00:28:48 +02:00
Danielv123 093b71045b Move player position parsing to shared util, fix player ID persistence 2025-08-10 00:28:48 +02:00
Danielv123 a36e7bddca Fix player ID assignments 2025-08-10 00:28:48 +02:00
Danielv123 3737659cc3 Remove extraneous logging 2025-08-10 00:28:48 +02:00
Danielv123 5ba6056848 Don't load Lua in 1.1 2025-08-10 00:28:48 +02:00
Danielv123 89b6d5aa13 Attempt to fix tests 2025-08-10 00:28:48 +02:00
Danielv123 630a0d3898 Clean up UI, fix focus issues 2025-08-10 00:28:48 +02:00
Danielv123 047d3c22f5 Add player position tracking 2025-08-10 00:28:48 +02:00
Danielv123 57f89de746 Fix pixel parsing offset error 2025-08-10 00:28:48 +02:00
Danielv123 14f69cda11 Fix pixel change record bounds check
From cursor review: Insufficient bounds checking
for pixel change records (type 2) in renderTileToPixels,
extractAvailableTicks, and parseTileData. The code
checks for 6 bytes of header data but reads 10 bytes
(tick, pixelCount, newColor, oldColor), potentially
leading to reading out of bounds. The check should
be offset + 10 > tileData.length.
2025-08-10 00:28:48 +02:00
Danielv123 2bce506d96 Lint and refactor 2025-08-10 00:28:48 +02:00
Danielv123 ae7a1744ad Further compress tile pixel delta data
7 bytes header + 6 bytes per pixel -> 11 byte header + 2 bytes per pixel
2025-08-10 00:28:48 +02:00
Danielv123 ab8321ff4b Document tile data storage 2025-08-10 00:28:48 +02:00
Danielv123 c5c26bfad1 Use binary storage for recipe overlay for 8x space reduction 2025-08-10 00:28:48 +02:00
Danielv123 1c06611cca Timeline support for recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 7db716eceb Fix recipe data deduplication 2025-08-10 00:28:48 +02:00
Danielv123 3b7fd164af Implement recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 cf2efb1380 Fix deleting and modifying tags, tags on timeline 2025-08-10 00:28:48 +02:00
Danielv123 fc466ec799 Add chart tag support
Also fixes react state management issues, moving a few things over to useRef and showing all instances instead of using a custom solution
2025-08-10 00:28:48 +02:00
Danielv123 f5aeaff256 Optimize canvas to render independent of react 2025-08-10 00:28:48 +02:00
Danielv123 6ce4d0d20b F and G as timeline navigation shortcuts 2025-08-10 00:28:48 +02:00
Danielv123 0785798b7d Incremental rendering of timeline scrubbing 2025-08-10 00:28:48 +02:00
Danielv123 9dc5f8ff37 Working timelapse rendering functionality 2025-08-10 00:28:48 +02:00
Danielv123 be21f22707 Add timeline selection components and track tick on changes 2025-08-10 00:28:48 +02:00
Danielv123 0d294479b3 Use same parsing utilities on backend and frontend 2025-08-10 00:28:48 +02:00
Danielv123 626188dd16 Calculate and store deltas for changes 2025-08-10 00:28:48 +02:00
Danielv123 a516b6b129 Implement new tile storage format with tentative timelapse support 2025-08-10 00:28:48 +02:00
Danielv123 2dcc92c96b Document new data flow and tile storage format 2025-08-10 00:28:48 +02:00
Danielv123 015fd2c30b Add readme 2025-08-10 00:28:48 +02:00
Danielv123 3f06bd0993 Smooth keyboard movement 2025-08-10 00:28:48 +02:00
Danielv123 cfc1b7687e Allow click and drag to pan 2025-08-10 00:28:48 +02:00
Danielv123 285254e3bc Fix issues with black seams from incorrect scaling math 2025-08-10 00:28:48 +02:00
Danielv123 b70c7bd84f Fix negative coordinates when drawing updated chunks 2025-08-10 00:28:48 +02:00
Danielv123 3bf7a84de4 Switch to custom canvas based map renderer 2025-08-10 00:28:48 +02:00
Danielv123 063e622721 Render map tiles as pixelated 2025-08-10 00:28:48 +02:00
Danielv123 5b9f650946 Use encode_string and support multiple instances 2025-08-10 00:28:48 +02:00
Danielv123 c62eae0009 Improve webUI loading speed with 512x512 tiles, still corrupted looking 2025-08-10 00:28:48 +02:00
Danielv123 e9a4627118 Cleanup, web interface now shows images (but corrupted) 2025-08-10 00:28:48 +02:00
Danielv123 6cbd3229f6 Use new chunk charting API 2025-08-10 00:28:48 +02:00
Danielv123 f7658e1118 Create minimap core plugin based on gridworld plugin 2025-08-10 00:28:48 +02:00
Danielv123 f31fb535df Add tests for subscription filters 2025-08-09 23:57:13 +02:00
Danielv123 75b2dac371 Add filtered subscriptions 2025-08-09 23:44:01 +02:00
196 changed files with 12173 additions and 8423 deletions

No files matched your search

-6
View File
@@ -40,12 +40,6 @@ jobs:
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
lint:
runs-on: ubuntu-latest
+4
View File
@@ -13,6 +13,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
@@ -31,6 +33,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
+12 -3
View File
@@ -29,13 +29,22 @@ dist/
node_modules
/factorio*
Factorio*
!*Factorio*.ts
!*Factorio*.tsx
!*Factorio*.js
/factorio
!/test/file/factorio
!/test/file/html/*.html
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
/mods
/database
/plugin-list.json
/config-*.json
/config-*.json.lock
/config-controller.json
/config-control.json
/config-host.json
pnpm-lock.yaml
mod_cache.json
/static/
-60
View File
@@ -31,66 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.23
### Major Features
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
### Features
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce separate spritesheets for recipes, signals, technologies, planets, qualities, entities, and static UI icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifing a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and modpacks can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where muliple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#XXX](https://github.com/clusterio/clusterio/pull/XXX)
- Fix that the documentation had a dead link to details of the message passing protocol (#812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#1234](https://github.com/clusterio/clusterio/issues/1234)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
### Breaking Changes
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Daniel](https://github.com/Danielv123)
[@Double](https://github.com/DoubleThoughtTheProgrammer)
[@Hornwitser](https://github.com/Hornwitser)
[@Ryan Walker](https://github.com/solarcloud7)
## Version 2.0.0-alpha.22
### Major Features
+1 -1
View File
@@ -146,7 +146,7 @@ Your instances (save files etc) will be stored there.
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use the local `factorio` directory for the Factorio installation, you can let the installer download the latest headless release automatically, or manually download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
3. If you chose to use local factorio directory for the Factorio installation, download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
+1 -1
View File
@@ -104,7 +104,7 @@ async function main() {
removeBuildArtifacts();
removeTestArtifacts();
})
.command("$0", "Remove all build artifacts", () => {}, argv => {
.command("fast", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
+16
View File
@@ -182,6 +182,14 @@ You can generate an access token with `clusterioctl host generate-token --id <ho
Defaults to "enter token here".
### host.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### host.public_address
External address instances hosted on this host can be accessed on.
@@ -405,6 +413,14 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
+4 -19
View File
@@ -63,25 +63,10 @@ This creates a private key file named `key.pem` and a public certificate file na
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
**For run-host.cmd**
npx clusteriohost config set host.tls_ca cert.pem
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
**For run-host.sh**
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
**For clusteriohost.service**
Environment=NODE_EXTRA_CA_CERTS=path/to/cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
# On Windows
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
# On Linux
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
# You can now use clusterioctl
npx clusterioctl instance list
npx clusterioctl control-config set control.tls_ca cert.pem
+5 -2
View File
@@ -7,7 +7,7 @@
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"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",
"cover": "nyc pnpm test",
@@ -15,7 +15,9 @@
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -56,6 +58,7 @@
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"phin": "^3.7.1",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
+12 -19
View File
@@ -32,7 +32,6 @@ const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import UserRecord from "./src/UserRecord";
import { version } from "./package.json";
// globals
@@ -134,22 +133,13 @@ async function handleBootstrapCommand(
let subCommand = args._[1];
const databaseDirectory = controllerConfig.get("controller.database_directory");
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
).bootstrap());
const userManager = new UserManager(users, roles, controllerConfig);
const userManager = new UserManager(controllerConfig, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
@@ -160,12 +150,15 @@ async function handleBootstrapCommand(
return;
}
const user = userManager.getOrCreateUser(args.name);
user.roleIds.add(lib.Role.DefaultAdminRoleId);
user.isAdmin = true;
user.saveRecord();
let admin = userManager.getByName(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
await userManager.records.save();
let adminRole = lib.ensureDefaultAdminRole(roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
@@ -438,7 +431,7 @@ async function startup() {
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.users.records.size === 0) {
if (args.checkUserCount && controller.userManager.users.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
@@ -490,7 +483,7 @@ I version of clusterio. Expect things to break. I
`
);
startup().catch(err => {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
if (err instanceof lib.StartupError) {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
+3 -5
View File
@@ -1,15 +1,13 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceRecord } from "./src/InstanceRecord";
export { default as InstanceManager } from "./src/InstanceManager";
export { default as HostRecord } from "./src/HostRecord";
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 UserRecord } from "./src/UserRecord";
export { default as User } from "./src/User";
export { default as WsServer } from "./src/WsServer";
if (module === require.main) {
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -32,7 +32,6 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -107,8 +106,4 @@ export default class BaseConnection extends lib.Link {
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
@@ -3,7 +3,7 @@ import type {
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceRecord from "./InstanceRecord";
import type InstanceInfo from "./InstanceInfo";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
@@ -91,7 +91,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -117,7 +117,7 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -261,7 +261,7 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
+99 -111
View File
@@ -6,8 +6,7 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -41,7 +40,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: User, // The user making this connection.
public user: ControllerUser, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -127,6 +126,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -250,7 +250,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
@@ -268,20 +268,20 @@ export default class ControlConnection extends BaseConnection {
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instances.createInstance(instanceConfig);
await this._controller.instanceCreate(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instances.deleteInstance(request.instanceId);
await this._controller.instanceDelete(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
@@ -296,14 +296,14 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let instance = this._controller.getRequestInstance(request.instanceId);
let { field, prop, value } = request;
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) {
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
await this._controller.instanceAssign(request.instanceId, request.hostId);
}
async handleInstanceSaveDetailsListRequest() {
@@ -337,8 +337,8 @@ export default class ControlConnection extends BaseConnection {
if (request.sourceInstanceId === request.targetInstanceId) {
throw new lib.RequestError("Source and target instance may not be the same");
}
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId);
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
@@ -614,21 +614,22 @@ export default class ControlConnection extends BaseConnection {
return modInfos;
}
// eslint-disable-next-line complexity
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const skipSearch = new Set(builtInModNames);
const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const builtInModNames = new Set(_builtInModNames);
const skipSearch = new Set(_builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
const incompatible = new Set<string>();
const invalid = new Set<string>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
@@ -649,44 +650,10 @@ export default class ControlConnection extends BaseConnection {
return releases;
}
function selectModRelease(releases: ModPortalModType | null, versionRange: lib.ModVersionRange) {
if (!releases || !releases.releases) {
return null;
}
// Select the latest matching version
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
return null;
}
// Create the mod info class from the selected
try {
return lib.ModInfo.fromJSON({
...release.info_json, // includes dependencies
sha1: release.sha1,
version: release.version,
name: releases.name,
author: releases.owner,
title: releases.title,
});
} catch {
// Can error if there are invalid dependencies
errors.set(releases.name, "invalidDependency");
return null;
}
}
while (searchQueue.length > 0) {
// Dequeue the next mod, skip if incompatible
const mod = searchQueue.shift()!;
if (mod.incompatible) {
errors.set(mod.name, "incompatible");
incompatible.add(mod.name);
continue;
}
@@ -707,13 +674,13 @@ export default class ControlConnection extends BaseConnection {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
invalid.add(mod.name);
}
}
// Do not add dependencies for errors, skips, optionals, or valid candidates
// Do not add dependencies for invalids, optionals, skips, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (errors.has(mod.name) || skipSearch.has(mod.name) || optionalRequirements.has(mod.name) || (
if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
@@ -728,11 +695,28 @@ export default class ControlConnection extends BaseConnection {
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const releases = await fetchModReleases(mod.name);
const release = selectModRelease(releases, versionRange);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || release.integerVersion > candidate.integerVersion)) {
candidate = release;
const modReleases = await fetchModReleases(mod.name);
if (modReleases && modReleases.releases) {
const release = modReleases.releases
.filter(info => (
info.info_json.factorio_version === factorioVersion
&& versionRange.testVersion(info.version)
))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) {
candidate = lib.ModInfo.fromJSON({
...release.info_json, // Also includes dependences
sha1: release.sha1,
version: release.version,
name: modReleases.name,
author: modReleases.owner,
title: modReleases.title,
});
}
}
}
@@ -748,22 +732,14 @@ export default class ControlConnection extends BaseConnection {
}
}
// Add the not found dependencies as errors
for (const modName of dependencyRequirements.keys()) {
if (!candidateReleases.has(modName) && !errors.has(modName)) {
errors.set(modName, "notFound");
}
}
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()], errors,
[...candidateReleases.values()],
[...invalid.values(), ...[...incompatible.values()].filter(mod => (
candidateReleases.has(mod) || builtInModNames.has(mod)
))],
[...dependencyRequirements.keys()].filter(mod => (
!candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod)
)),
);
}
@@ -870,33 +846,35 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.roles.delete(role);
for (const user of this._controller.users.values()) {
if (user.removeRole(id)) {
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
let name = request.name;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user.toUserDetails();
return user;
}
async handleUserListRequest(): Promise<lib.UserDetails[]> {
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
this._controller.users.createUser(request.name);
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.users.getByName(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -910,10 +888,11 @@ export default class ControlConnection extends BaseConnection {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.users.getByName(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -924,33 +903,35 @@ export default class ControlConnection extends BaseConnection {
}
}
user.set("roleIds", new Set(request.roles));
user.roleIds = new Set(request.roles);
this._controller.userPermissionsUpdated(user);
this._controller.usersUpdated([user]);
}
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
let { name, create, admin } = request;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.set("isAdmin", admin);
user.isAdmin = admin;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
}
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.users.getByNameMutable(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
@@ -958,34 +939,37 @@ export default class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
user.saveRecord();
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
}
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.users.createUser(name);
user = this._controller.userManager.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.set("isWhitelisted", whitelisted);
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.users.getByName(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
this._controller.users.deleteUser(user);
user.isDeleted = true;
this._controller.userManager.users.delete(name);
this._controller.usersUpdated([user]);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -998,15 +982,14 @@ export default class ControlConnection extends BaseConnection {
}
}
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
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.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
@@ -1027,7 +1010,7 @@ export default class ControlConnection extends BaseConnection {
const expected = new Set(
users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase()))
);
for (const user of this._controller.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
@@ -1039,7 +1022,7 @@ export default class ControlConnection extends BaseConnection {
const users = request.users as string[];
const expected = new Set(users.map(u => u.toLowerCase()));
const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.valuesMutable()) {
for (const user of this._controller.userManager.users.values()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
@@ -1048,9 +1031,11 @@ export default class ControlConnection extends BaseConnection {
}
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<User["id"], User>();
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));
@@ -1058,12 +1043,11 @@ export default class ControlConnection extends BaseConnection {
}
// Will get a user or attempt to create one
// UserManager.getOrCreateUser not used due to inclusion of checkPermission
const getUserOrCreate = (username: string) => {
const user = this._controller.users.getByNameMutable(username);
if (user) { return updated.get(user.id) ?? user; }
const user = this._controller.userManager.getByName(username);
if (user) { return user; }
this.user.checkPermission("core.user.create");
return this._controller.users.createUser(username);
return this._controller.userManager.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
@@ -1121,14 +1105,14 @@ export default class ControlConnection extends BaseConnection {
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.users.records.setMany(updatedUsers);
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.users.values()) {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -1152,7 +1136,7 @@ export default class ControlConnection extends BaseConnection {
if (request.exportType === "users") {
// Send a full user export
const usersToSend = [] as lib.ClusterioUserExport["users"];
for (const user of this._controller.users.values()) {
for (const user of this._controller.userManager.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
@@ -1178,7 +1162,7 @@ export default class ControlConnection extends BaseConnection {
} else if (request.exportType === "bans") {
// Bans have extra logic to handle ban reason
const usersToSend = [] as Array<string | { username: string, reason: string }>;
for (const user of this._controller.users.values()) {
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 });
@@ -1193,7 +1177,7 @@ export default class ControlConnection extends BaseConnection {
// This is a whitelist or admins export
const usersToSend = [] as string[];
const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted";
for (const user of this._controller.users.values()) {
for (const user of this._controller.userManager.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
@@ -1239,6 +1223,10 @@ export default class ControlConnection extends BaseConnection {
this.connector._socket!.clusterio_ignore_dump = true;
this._controller.debugEvents.on("message", this.ws_dumper);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
module.exports = ControlConnection;
+204 -87
View File
@@ -18,17 +18,16 @@ import * as lib from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import HttpCloser from "./HttpCloser";
import InstanceRecord from "./InstanceRecord";
import InstanceInfo from "./InstanceInfo";
import * as metrics from "./metrics";
import * as routes from "./routes";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostRecord from "./HostRecord";
import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -80,10 +79,6 @@ export default class Controller {
/** Event subscription controller */
subscriptions = new lib.SubscriptionController();
/** User manager for the cluster, also contains access to the user records */
users: UserManager;
/** Instance manager for the cluster, also contains access to the user records */
instances: InstanceManager;
// Possible states are new, starting, running, stopping, stopped
private _state: string = "new";
@@ -101,7 +96,6 @@ export default class Controller {
devMiddleware: any | null = null;
startedAtMs = Date.now();
autosaveInterval?: ReturnType<typeof setInterval>;
systemMetricsInterval?: ReturnType<typeof setInterval>;
@@ -112,25 +106,12 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(async () => {
try {
return await lib.fetchFactorioVersions();
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio versions ${err.message}`);
return [];
}
});
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
@@ -139,13 +120,13 @@ export default class Controller {
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostRecord.fromJSON.bind(HostRecord),
HostInfo.fromJSON.bind(HostInfo),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
InstanceRecord.fromJSON.bind(InstanceRecord),
json => InstanceInfo.fromJSON(json, "controller"),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
@@ -166,11 +147,8 @@ export default class Controller {
this.migrateRoles,
).bootstrap());
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
undefined, this.finaliseUsers,
).bootstrap());
const userManager = new UserManager(config, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
@@ -182,8 +160,8 @@ export default class Controller {
}
// Add default roles
lib.Role.ensureDefaultAdminRole(roles);
lib.Role.ensureDefaultPlayerRole(roles);
lib.ensureDefaultAdminRole(roles);
lib.ensureDefaultPlayerRole(roles);
return [
systems,
@@ -193,7 +171,7 @@ export default class Controller {
modPacks,
modStore,
roles,
users,
userManager,
] as const;
}
@@ -213,10 +191,10 @@ export default class Controller {
*/
public recoveryMode: boolean = false,
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** Mapping of host id to host record */
public hosts = new lib.SubscribableDatastore<HostRecord>(),
/** Mapping of instance id to instance record */
instances = new lib.SubscribableDatastore<InstanceRecord>(),
/** Mapping of host id to host info */
public hosts = new lib.SubscribableDatastore<HostInfo>(),
/** Mapping of instance id to instance info */
public instances = new lib.SubscribableDatastore<InstanceInfo>(),
/** Mapping of save id to save details */
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
/** Mapping of mod pack id to mod pack */
@@ -225,8 +203,8 @@ export default class Controller {
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
/** Mapping of mod pack id to mod pack */
public roles = new lib.SubscribableDatastore<lib.Role>(),
/** Mapping of user id to user record */
users = new lib.SubscribableDatastore<UserRecord>(),
/** User and roles manager for the cluster */
public userManager = new UserManager(config, roles),
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
@@ -239,9 +217,6 @@ export default class Controller {
this.trustedProxies = this.parseTrustedProxies();
this.wsServer = new WsServer(this);
this.users = new UserManager(users, roles, config);
this.instances = new InstanceManager(instances, this);
this.modStore.on("change", mod => {
this.modsUpdated([mod]);
});
@@ -261,11 +236,10 @@ export default class Controller {
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.bind(this));
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
this.roles.on("update", this.rolesUpdated.bind(this));
this.users.records.on("update", this.usersUpdated.bind(this));
}
async start(args: ControllerArgs) {
@@ -330,6 +304,9 @@ export default class Controller {
}
lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
});
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
// Make sure we're actually going to listen on a port
let httpPort = this.config.get("controller.http_port");
@@ -420,7 +397,6 @@ export default class Controller {
this.onSystemMetricsIntervalChanged();
logger.info("Started controller");
this.startedAtMs = Date.now();
this._state = "running";
}
@@ -577,7 +553,7 @@ export default class Controller {
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -665,13 +641,16 @@ export default class Controller {
this.config.save(),
this.systems.save(),
this.hosts.save(),
this.instances.records.save(),
this.instances.save(),
this.saves.save(),
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
]);
if (this.userManager.dirty) {
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
}
await lib.invokeHook(this.plugins, "onSaveData");
}
@@ -684,17 +663,11 @@ export default class Controller {
if (!json.restartRequired) { // Added in 2.0.0.alpha.21
json.restartRequired = false;
}
if (json.systemStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.systemStartedAtMs = Date.now();
}
if (json.processStartedAtMs === undefined) { // Added in 2.0.0.alpha.23
json.processStartedAtMs = Date.now();
}
return json;
});
}
static migrateHosts(rawJson: unknown[]): Static<typeof HostRecord.jsonSchema>[] {
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
let serialized = rawJson as any;
// New format 2.0.0.alpha.19
@@ -705,7 +678,7 @@ export default class Controller {
return serialized;
}
static finaliseHosts(host: HostRecord): HostRecord {
static finaliseHosts(host: HostInfo): HostInfo {
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
@@ -713,8 +686,8 @@ export default class Controller {
return host;
}
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceRecord.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceRecord.jsonSchema>[];
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
return serialized.map(json => {
if (!json.config) { // New format 2.0.0.alpha.14
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
@@ -723,7 +696,7 @@ export default class Controller {
});
}
static finaliseInstances(instance: InstanceRecord): InstanceRecord {
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
@@ -757,20 +730,6 @@ export default class Controller {
});
}
static finaliseUsers(user: UserRecord, users: Map<UserRecord["id"], UserRecord>): UserRecord {
const existingUser = users.get(user.id);
if (existingUser) {
// All lowercase ids in 2.0.0.alpha.19
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
// We assume the user with the lower online time is the duplicate
existingUser.merge(user);
return existingUser;
}
user.merge(existingUser);
}
return user;
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
let manifest = {};
try {
@@ -889,7 +848,7 @@ export default class Controller {
);
}
hostsUpdated(hosts: HostRecord[]) {
hostsUpdated(hosts: HostInfo[]) {
let updates = hosts.map(host => host.toHostDetails());
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
@@ -901,6 +860,75 @@ export default class Controller {
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
}
/**
* Get instance by ID for a request
*
* @param instanceId - ID of instance to get.
* @returns Info for the given instance if it exists
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getRequestInstance(instanceId:number): InstanceInfo {
let instance = this.instances.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/**
* Create a new instance
*
* Adds common Factorio settings to the provided instance config then
* creates an instance using that config in the cluster.
*
* @example
* let instanceConfig = new lib.InstanceConfig("controller");
* instanceConfig.set("instance.name", "My instance");
* let instance = await controller.instanceAssign(instanceConfig);
* await controller.instanceAssign(instance.id, hostId);
*
* @param instanceConfig -
* Config to base newly created instance on.
* @returns The created instance
*/
async instanceCreate(instanceConfig: lib.InstanceConfig): Promise<InstanceInfo> {
let instanceId = instanceConfig.get("instance.id");
if (this.instances.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${this.config.get("controller.name")}`,
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
"max_upload_slots": 5,
"ignore_player_limit_for_returning_players": false,
"allow_commands": "admins-only",
"autosave_interval": 10,
"autosave_slots": 5,
"afk_autokick_interval": 0,
"auto_pause": false,
"only_admins_can_pause_the_game": true,
"autosave_only_on_server": true,
"non_blocking_saving": false,
...instanceConfig.get("factorio.settings"),
};
instanceConfig.set("factorio.settings", settings);
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
this.instances.set(instance);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
this.addInstanceHooks(instance);
return instance;
}
/**
* Removes all saves currently stored for the given instance, if any
* @param instanceId - Id of Instance to clear saves for.
@@ -910,6 +938,84 @@ export default class Controller {
this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId));
}
/**
* Change assigned host of an instance
*
* Unassigns instance from existing host if already assigned to one and
* then assigns it to the given host. This is the only supported way of
* changing the instance.assigned_host config entry.
*
* Note: this will not transfer any files or saves stored on the host
* the instance was previously assgined to the new one.
*
* @param instanceId - ID of Instance to assign.
* @param hostId - ID of host to assign instance to.
*/
async instanceAssign(instanceId: number, hostId?: number) {
let instance = this.getRequestInstance(instanceId);
// Check if target host is connected
let newHostConnection: HostConnection | undefined;
if (hostId !== undefined) {
newHostConnection = this.wsServer.hostConnections.get(hostId);
if (!newHostConnection) {
// The case of the host not getting the assign instance message
// still have to be handled, so it's not a requirement that the
// target host be connected to the controller while doing the
// assignment, but it is IMHO a better user experience if this
// is the case.
throw new lib.RequestError("Target host is not connected to the controller");
}
}
// Unassign from currently assigned host if it is connected.
let currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost !== null && hostId !== currentAssignedHost) {
let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId));
}
}
// Remove saves recorded from currently assigned host if any
this.clearSavesOfInstance(instanceId);
// Assign to target
instance.config.set("instance.assigned_host", hostId ?? null);
if (hostId !== undefined && newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host"))
);
} else {
instance.status = "unassigned";
}
this.instances.set(instance);
}
/**
* Delete an instance
*
* Permanently deletes the instance from its assigned host (if assigned)
* and the controller. This action cannot be undone.
*
* @param instanceId - ID of instance to delete.
*/
async instanceDelete(instanceId: number) {
let instance = this.getRequestInstance(instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId));
}
let prev = instance.status;
this.instances.delete(instance);
this.clearSavesOfInstance(instanceId);
this.userManager.clearStatsOfInstance(instanceId);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
}
/**
* Notify the config of an instance updated
*
@@ -919,7 +1025,7 @@ export default class Controller {
* @param instance -
* Instance to nodify the config updated.
*/
async instanceConfigUpdated(instance: InstanceRecord) {
async instanceConfigUpdated(instance: InstanceInfo) {
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
let connection = this.wsServer.hostConnections.get(hostId);
@@ -931,7 +1037,16 @@ export default class Controller {
}
}
instanceUpdated(instances: InstanceRecord[]) {
addInstanceHooks(instance: InstanceInfo) {
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
instance.updatedAtMs = Date.now();
this.instances.set(instance); // Trigger update logic
lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev);
});
}
instanceDetailsUpdated(instances: InstanceInfo[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
@@ -983,12 +1098,17 @@ export default class Controller {
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
}
usersUpdated(users: UserRecord[]) {
usersUpdated(users: ControllerUser[]) {
const now = Date.now();
for (const user of users) {
user.updatedAtMs = now;
}
this.userManager.dirty = true;
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
const users = [...this.users.records.values()].filter(
const users = [...this.userManager.users.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
return users.length ? new lib.UserUpdatesEvent(users) : null;
@@ -999,7 +1119,7 @@ export default class Controller {
* permissions for this user may have changed.
* @param user - User permisions updated for.
*/
userPermissionsUpdated(user: Readonly<User>) {
userPermissionsUpdated(user: ControllerUser) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
@@ -1075,9 +1195,6 @@ export default class Controller {
this.plugins.set(pluginInfo.name, controllerPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -1261,7 +1378,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
const instance = this.instances.getForRequest(dst.id);
let instance = this.getRequestInstance(dst.id);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1296,7 +1413,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
const instance = this.instances.get(dst.id);
let instance = this.instances.get(dst.id);
if (!instance) {
throw new Error(`Instance with ID ${dst.id} does not exist`);
}
@@ -1355,7 +1472,7 @@ export default class Controller {
}
async sendRequestToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>) {
let instance = this.instances.getForRequest(request.instanceId);
let instance = this.getRequestInstance(request.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1368,7 +1485,7 @@ export default class Controller {
}
sendEventToHostByInstanceId<T extends { instanceId: number }>(event: T & lib.Event<T>) {
let instance = this.instances.getForRequest(event.instanceId);
let instance = this.getRequestInstance(event.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
return;
+96
View File
@@ -0,0 +1,96 @@
import { Static, Type } from "@sinclair/typebox";
import {
IControllerUser, PermissionError, PlayerStats,
Role, SubscribableDatastore, User, permissions,
} from "@clusterio/lib";
export default class ControllerUser extends User implements IControllerUser {
constructor(
private _controllerRoles: SubscribableDatastore<Role>,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
controllerRoles,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+49 -42
View File
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
/**
@@ -16,7 +16,7 @@ import HostRecord from "./HostRecord";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostRecord;
private info: HostInfo;
plugins: Map<string, string>;
constructor(
@@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostRecord(
this.info = new HostInfo(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
@@ -57,7 +57,7 @@ export default class HostConnection extends BaseConnection {
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
const instances: InstanceRecord[] = [];
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
@@ -68,7 +68,7 @@ export default class HostConnection extends BaseConnection {
instance.status = "unknown";
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instances.records.setMany(instances);
this._controller.instances.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
@@ -207,14 +207,9 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
// If the instance has transitioned to running then updated the started at time
if (request.status === "running" && prev === "starting") {
instance.startedAtMs = Date.now();
}
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
this._controller.instances.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -230,7 +225,7 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceRecord[] = [];
const instanceUpdates: InstanceInfo[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
@@ -251,9 +246,6 @@ export default class HostConnection extends BaseConnection {
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.factorioVersion = instanceData.factorioVersion;
if (instanceData.status === "running" && prev === "starting") {
controllerInstance.startedAtMs = Date.now();
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
@@ -264,30 +256,31 @@ export default class HostConnection extends BaseConnection {
}
instanceConfig.set("instance.assigned_host", this.id);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
instanceUpdates.push(newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
this._controller.instances.setMany(instanceUpdates);
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -336,18 +329,21 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
if (event.type === "join") {
user.notifyJoin(instanceId);
this._controller.userManager.notifyJoin(user, instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
this._controller.userManager.notifyLeave(user, instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
user.saveRecord();
this._controller.usersUpdated([user]);
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
@@ -363,9 +359,13 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isAdmin = event.admin;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
@@ -379,11 +379,14 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isBanned = event.banned;
user.banReason = event.reason;
user.saveRecord();
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
@@ -398,9 +401,13 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isWhitelisted = event.whitelisted;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
@@ -1,8 +1,10 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/** Underlying data class for hosts on the controller */
export default class HostRecord {
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
constructor(
/** Id of this host */
public id: number,
@@ -31,12 +33,12 @@ export default class HostRecord {
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"connected": Type.Optional(Type.Boolean()),
"remote_address": Type.Optional(Type.String()),
"public_address": Type.Optional(Type.String()),
"token_valid_after": Type.Optional(Type.Number()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
"connected": Type.Boolean(),
"remote_address": Type.String(),
"public_address": Type.String(),
"token_valid_after": Type.Number(),
"updated_at_ms": Type.Number(),
"is_deleted": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -54,50 +56,19 @@ export default class HostRecord {
);
}
toJSON() {
const json = {
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
} as Static<typeof HostRecord.jsonSchema>;
if (this.connected !== false) {
json.connected = this.connected;
}
if (this.remoteAddress !== "") {
json.remote_address = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.public_address = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.token_valid_after = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.is_deleted = this.isDeleted;
}
return json;
}
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
return new HostRecord(
details.id,
details.name,
details.version,
plugins,
details.connected,
details.remoteAddress,
details.publicAddress,
details.tokenValidAfter,
details.updatedAtMs,
details.isDeleted,
);
connected: this.connected,
remote_address: this.remoteAddress,
public_address: this.publicAddress,
token_valid_after: this.tokenValidAfter,
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
}
toHostDetails() {
@@ -114,3 +85,4 @@ export default class HostRecord {
);
}
}
@@ -1,22 +1,52 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: lib.TargetVersion,
public startedAtMs = 0,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
get id(): number {
return this.config.get("instance.id");
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
json.updatedAtMs,
);
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
get isDeleted() {
@@ -30,70 +60,8 @@ export default class InstanceRecord {
this.status = "deleted";
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
);
}
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
const json: any = {
config: this.config.toJSON(),
status: this.status,
};
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
return json;
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
}
}
-274
View File
@@ -1,274 +0,0 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller";
import type HostConnection from "./HostConnection";
import InstanceRecord from "./InstanceRecord";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
tags: ["clusterio"],
max_players: 0,
visibility: { public: true, lan: true },
game_password: "",
require_user_verification: true,
max_upload_in_kilobytes_per_second: 0,
max_upload_slots: 5,
ignore_player_limit_for_returning_players: false,
allow_commands: "admins-only",
autosave_interval: 10,
autosave_slots: 5,
afk_autokick_interval: 0,
auto_pause: false,
only_admins_can_pause_the_game: true,
autosave_only_on_server: true,
non_blocking_saving: false,
} as const;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
) {
for (const instance of records.values()) {
this.addInstanceHooks(instance);
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: InstanceRecord["id"]): boolean {
return this.records.has(id);
}
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
return this.records.get(id);
}
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
return this.getMutable(id);
}
valuesMutable(): IterableIterator<InstanceRecord> {
return this.records.values();
}
values(): IterableIterator<Readonly<InstanceRecord>> {
return this.valuesMutable();
}
/**
* Get instance by ID for a request.
*
* Convenience wrapper around {@link getMutable} that throws
* if the instance does not exist.
*
* @param instanceId - ID of instance to get.
* @returns The instance record.
* @throws {module:lib.RequestError} if the instance does not exist.
*/
getForRequest(instanceId: number): InstanceRecord {
const instance = this.records.get(instanceId);
if (!instance) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/* ------------------------------------------------------------------------- */
/* Internal Helpers */
/* ------------------------------------------------------------------------- */
private async _notifyStatusChanged(
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceStatusChanged",
instance,
prev,
);
}
private async _notifyConfigFieldChanged(
instance: InstanceRecord,
field: string,
curr: unknown,
prev: unknown,
): Promise<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
}
/**
* Attach change listeners to an instance record.
*
* Hooks into configuration field changes and ensures the datastore
* is updated and plugins are notified when a config field changes.
*
* @param instance - The instance record to attach hooks to.
* @internal
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
});
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Create a new instance.
*
* @example
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
* const instance = await controller.instances.createInstance(config);
*
* @param instanceConfig - Config to base the newly created instance on.
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
* @returns The created instance record.
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
*/
async createInstance(
instanceConfig: lib.InstanceConfig,
_suppressChanges?: boolean,
): Promise<InstanceRecord> {
const instanceId = instanceConfig.get("instance.id");
if (this.records.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
if (!_suppressChanges) {
this.records.set(instance);
await this._notifyStatusChanged(instance);
}
return instance;
}
/**
* Change the assigned host of an instance.
*
* Unassigns the instance from the currently assigned host (if connected)
* and assigns it to the provided host.
*
* This is the only supported way of modifying the
* `instance.assigned_host` configuration entry.
*
* Note:
* - This does NOT transfer any files or saves between hosts.
* - If the instance is already assigned to the specified host, no action is taken.
*
* @param instanceId - ID of instance to assign.
* @param hostId - ID of host to assign the instance to. If undefined,
* the instance will be unassigned.
*
* @throws {module:lib.RequestError} if the instance does not exist.
* @throws {module:lib.RequestError} if the target host is not connected.
*/
async assignInstance(
instanceId: number,
hostId: number | null,
): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostConnections = this._controller.wsServer.hostConnections;
const currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost === hostId) {
return;
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
newHostConnection = hostConnections.get(hostId);
if (!newHostConnection) {
throw new lib.RequestError("Target host is not connected to the controller");
}
}
if (currentAssignedHost !== null) {
const oldHostConnection = hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(
new lib.InstanceUnassignInternalRequest(instanceId),
);
}
}
this._controller.clearSavesOfInstance(instanceId);
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} else {
instance.status = "unassigned";
}
this.records.set(instance);
}
/**
* Unassign an instance from its current host.
*
* Convenience wrapper around {@link assignInstance} that removes
* the assigned host.
*
* @param instanceId - ID of instance to unassign.
*/
async unassignInstance(instanceId: number): Promise<void> {
return this.assignInstance(instanceId, null);
}
/**
* Delete an instance permanently.
*
* Sends a delete request to the assigned host (if any),
* removes the instance from the datastore,
* clears associated saves and user statistics.
*
* @param instanceId - ID of instance to delete.
*
* @throws {module:lib.RequestError} if the instance does not exist.
*/
async deleteInstance(instanceId: number): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this._controller.sendTo(
{ hostId },
new lib.InstanceDeleteInternalRequest(instanceId),
);
}
const prevStatus = instance.status;
this.records.delete(instance);
this._controller.clearSavesOfInstance(instanceId);
this._controller.users.clearStatsOfInstance(instanceId);
await this._notifyStatusChanged(instance, prevStatus);
}
}
-146
View File
@@ -1,146 +0,0 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord";
import { Static } from "@sinclair/typebox";
/**
* Represents a user as viewed from the controller.
*
* All methods are safe to use when readonly.
* Only use mutable when needing to change multiple values.
*/
export default class User extends UserRecord implements IUser {
constructor(
private _controllerUsers: SubscribableDatastore<UserRecord>,
private _controllerRoles: SubscribableDatastore<Role>,
...args: ConstructorParameters<typeof UserRecord>
) {
super(...args);
}
static fromJSON(
json: Static<typeof this.jsonSchema>,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
) {
return this.fromUserRecord(
UserRecord.fromJSON(json),
_controllerUsers,
_controllerRoles,
);
}
static fromUserRecord(
userRecord: UserRecord,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
): User {
return new this(
_controllerUsers,
_controllerRoles,
userRecord.tokenValidAfter,
userRecord.name,
userRecord.roleIds,
userRecord.instances,
userRecord.isAdmin,
userRecord.isBanned,
userRecord.isWhitelisted,
userRecord.banReason,
userRecord.updatedAtMs,
userRecord.isDeleted,
userRecord.instanceStats,
);
}
/** Roles this user has */
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/** Save the underlying user record */
saveRecord() {
this._controllerUsers.set(this);
}
/** Set a property on the underlying record, then save it. */
set<K extends keyof this>(key: K, value: this[K]) {
this[key] = value;
this.saveRecord();
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
this.saveRecord();
}
/** Add a role to this user */
addRole(roleId: number) {
const role = this._controllerRoles.get(roleId);
if (!role) {
throw new Error(`role ${roleId} does not exist`);
}
this.roleIds.add(roleId);
this.saveRecord();
}
/** Remove a role from user */
removeRole(roleId: number) {
if (!this.roleIds.delete(roleId)) {
return false;
}
this.saveRecord();
return true;
}
/** Set a user as being online on a specific instance */
notifyJoin(instanceId: number) {
this.instances.add(instanceId);
this.saveRecord();
}
/** Set a user as being offline on a specific instance */
notifyLeave(instanceId: number) {
this.instances.delete(instanceId);
this.saveRecord();
}
/** Clear the stats of a player on a specific instance */
clearInstanceStats(instanceId: number) {
this.instances.delete(instanceId);
this.instanceStats.delete(instanceId);
this.recalculatePlayerStats();
this.saveRecord();
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (const roleId of this.roleIds) {
const role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+76 -106
View File
@@ -3,45 +3,65 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import User from "./User";
import UserRecord from "./UserRecord";
import ControllerUser from "./ControllerUser";
/**
* Access layer for users on the controller.
*
* In performance critical paths the raw records can be accessed directly.
* Otherwise this class will wrap data in a view class with a set and save method.
*
* Manages users and roles
* @alias module:controller/src/UserManager
*/
export default class UserManager {
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
) {
}
/**
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
* This migration separates them so they can be loaded by datastore.
*
* @param usersFilePath The src and dst file path for the users json file
* @param rolesFilePath The dst for the roles json file
*/
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
try {
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
await this._roles.save(); // Force a save to prevent data loss
content = content.users;
}
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
let duplicates = 0;
for (const serializedUser of content) {
const user = ControllerUser.fromJSON(serializedUser, this._roles);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
duplicates += 1;
// We assume the user with the lower online time is the duplicate
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
existingUser.merge(user);
continue; // Skip users.set
}
user.merge(existingUser);
}
this.users.set(user.id, user);
}
if (duplicates) {
const backupPath = `${filePath}.${Date.now()}.bak`;
lib.logger.warn(
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
);
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
@@ -49,116 +69,66 @@ export default class UserManager {
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: UserRecord["id"]) {
return this.records.has(id);
}
getMutable(id: UserRecord["id"]) {
const userRecord = this.records.getMutable(id);
if (!userRecord) {
return undefined;
}
return User.fromUserRecord(
userRecord,
this.records,
this._controllerRoles,
async save(filePath: string): Promise<void> {
this.dirty = false;
await lib.safeOutputFile(filePath,
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
);
}
get(id: UserRecord["id"]): Readonly<User> | undefined {
return this.getMutable(id);
}
hasName(name: UserRecord["name"]) {
return this.has(name.toLowerCase());
}
getByNameMutable(name: UserRecord["name"]) {
return this.getMutable(name.toLowerCase());
}
getByName(name: UserRecord["name"]) {
return this.get(name.toLowerCase());
}
* valuesMutable(): IterableIterator<User> {
const roles = this._controllerRoles;
const users = this.records;
for (const record of users.values()) {
yield User.fromUserRecord(record, users, roles);
}
}
values(): IterableIterator<Readonly<User>> {
return this.valuesMutable();
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Creates a new user and add it to the user database.
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name: string): User {
createUser(name: string): ControllerUser {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
return user;
}
/** Get or create a user with a given name. */
getOrCreateUser(name: string): User {
return this.getByNameMutable(name) ?? this.createUser(name);
}
/**
* Deletes an existing user
*
* @param user - user to delete
*/
deleteUser(user: UserRecord) {
return this.records.delete(user);
}
/* ------------------------------------------------------------------------- */
/* Helpers */
/* ------------------------------------------------------------------------- */
/**
* Sign access token for the given user
*
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(user: UserRecord): string {
signUserToken(user: ControllerUser): string {
return jwt.sign(
{ aud: "user", user: user.id },
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
}
this.dirty = true;
}
}
-102
View File
@@ -1,102 +0,0 @@
import { UserDetails } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/** Underlying data class for users on the controller */
export default class UserRecord extends UserDetails {
constructor(
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof UserDetails>
) {
super(...args);
}
static jsonSchema = Type.Object({
token_valid_after: Type.Optional(Type.Number()),
...UserDetails.jsonSchema.properties,
});
// _a and _b are a ts workaround for allowing User to have extra arguments
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
return this.fromUserDetails(
UserDetails.fromJSON(json),
json.token_valid_after,
);
}
toJSON() {
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
if (this.tokenValidAfter > 0) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
static fromUserDetails(
userDetails: UserDetails,
tokenValidAfter = 0,
) {
return new this(
tokenValidAfter,
userDetails.name,
userDetails.roleIds,
userDetails.instances,
userDetails.isAdmin,
userDetails.isBanned,
userDetails.isWhitelisted,
userDetails.banReason,
userDetails.updatedAtMs,
userDetails.isDeleted,
userDetails.instanceStats,
);
}
toUserDetails() {
return new UserDetails(
this.name,
this.roleIds,
this.instances,
this.isAdmin,
this.isBanned,
this.isWhitelisted,
this.banReason,
this.updatedAtMs,
this.isDeleted,
this.instanceStats,
);
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: UserDetails) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
// Player stats must be recalculated after modifying instance stats
this.recalculatePlayerStats();
}
}
+1 -1
View File
@@ -334,7 +334,7 @@ ${err.stack}`
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.users.getByNameMutable(tokenPayload.user);
user = this.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
+4 -5
View File
@@ -189,7 +189,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -240,9 +240,9 @@ async function uploadExport(req: Request, res: Response) {
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
@@ -263,7 +263,7 @@ async function uploadExport(req: Request, res: Response) {
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacks.set(modPack);
@@ -701,6 +701,5 @@ export const webRoutes = [
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+22 -122
View File
@@ -43,9 +43,6 @@ const availablePlugins = [
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const windowsHeadlessDownloadUrl = "https://factorio-repackager.fly.dev/get-download/latest/headless/win64";
const headlessDownloadPlatforms = new Set(["linux", "win32"]);
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
@@ -56,10 +53,6 @@ const factorioLocations = {
],
};
function supportsHeadlessDownload(platform = process.platform) {
return headlessDownloadPlatforms.has(platform);
}
class LineSplitter extends stream.Transform {
constructor(options) {
@@ -342,7 +335,7 @@ async function migrateRename(args) {
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode, "");
await writeScripts(mode);
}
logger.info(
@@ -427,7 +420,7 @@ async function groupIdToName(gid) {
}
}
async function writeScripts(mode, selfSignedCert) {
async function writeScripts(mode) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
@@ -481,13 +474,12 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
${extraCaCertsLine}:restart
:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
@@ -495,20 +487,18 @@ goto restart
`
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
${extraCaCertsLine}while true; do
while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
"systemd/clusteriohost.service",
`\
@@ -522,7 +512,7 @@ WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -654,14 +644,14 @@ async function inquirerMissingArgs(args) {
foundLocations.push(location);
}
}
if (supportsHeadlessDownload()) {
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "Automatically download latest headless Factorio release?",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
@@ -678,7 +668,7 @@ async function inquirerMissingArgs(args) {
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory", value: "factorio" },
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
@@ -808,87 +798,6 @@ async function downloadLinuxServer() {
}
}
async function getFactorioVersion(factorioDir) {
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
const changelog = await fs.readFile(changelogPath, "utf8");
for (const line of changelog.split(/\r?\n/)) {
const index = line.indexOf(":");
if (index === -1) {
continue;
}
if (line.slice(0, index).trim().toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
throw new Error(`Unable to detect Factorio version from ${changelogPath}`);
}
async function findFactorioRoot(rootDir) {
const entries = await fs.readdir(rootDir);
const factorioRoot = entries.find(entry => entry === "factorio" || entry === "Factorio")
?? (entries.length === 1 ? entries[0] : null);
if (!factorioRoot) {
throw new Error(`Unable to locate extracted Factorio directory in ${rootDir}`);
}
return path.join(rootDir, factorioRoot);
}
async function downloadWindowsServer() {
let res = await fetch(windowsHeadlessDownloadUrl);
if (res.status === 202) {
throw new Error("Latest Windows headless package is not ready yet, try again in a few minutes.");
}
if (!res.ok) {
throw new Error(`Failed to fetch ${windowsHeadlessDownloadUrl}: HTTP ${res.status} ${res.statusText}`);
}
if (!res.body) {
throw new Error("Windows headless download did not include an archive payload");
}
const tmpDir = path.join("temp", "create-temp");
const archivePath = path.join(tmpDir, "factorio-headless-win64.zip");
const tmpArchivePath = `${archivePath}.tmp`;
const tmpExtractDir = path.join(tmpDir, "win64-extract");
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Windows headless release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
const expandArchiveCommand =
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' ` +
`-DestinationPath '${tmpExtractDir.replace(/'/g, "''")}' -Force`;
await execFile("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
expandArchiveCommand,
]);
} catch (err) {
logger.error("error executing command - do you have PowerShell available?");
throw err;
}
const tmpFactorioDir = await findFactorioRoot(tmpExtractDir);
const version = await getFactorioVersion(tmpFactorioDir);
const factorioDir = path.join("factorio", version);
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
async function main() {
let args = yargs
.option("log-level", {
@@ -912,7 +821,7 @@ async function main() {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/host]",
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
@@ -930,9 +839,6 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("self-signed-cert", {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
@@ -949,17 +855,17 @@ async function main() {
;
if (process.platform === "linux") {
args = args.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
});
}
if (supportsHeadlessDownload()) {
args = args.option("download-headless", {
nargs: 0,
describe: "Automatically download and unpack the latest headless Factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
});
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
args = args.argv;
@@ -989,13 +895,7 @@ async function main() {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
if (process.platform === "linux") {
await downloadLinuxServer();
} else if (process.platform === "win32") {
await downloadWindowsServer();
} else {
throw new InstallError("--download-headless is only supported on Linux and Windows");
}
await downloadLinuxServer();
}
if (!dev) {
@@ -1047,7 +947,7 @@ async function main() {
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode, args.selfSignedCert);
await writeScripts(answers.mode);
}
if (answers.mode === "ctl") {
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22b",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -37,7 +37,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
//%if instance
async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) {
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
@@ -50,7 +50,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) {
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
+14 -3
View File
@@ -27,8 +27,8 @@ import BaseCtlPlugin from "./src/BaseCtlPlugin";
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, token: string) {
super(url, maxReconnectDelay);
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
@@ -54,6 +54,8 @@ class ControlConnector extends lib.WebSocketClientConnector {
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Certificate authority used to validate TLS connections to the controller. */
tlsCa?: string;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
@@ -62,10 +64,12 @@ export class Control extends lib.Link {
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
tlsCa: string | undefined,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = controlConfig;
this.tlsCa = tlsCa;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
@@ -295,13 +299,20 @@ async function startControl() {
return;
}
let tlsCa: string | undefined;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, ctlPlugins);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
+2 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -24,6 +24,7 @@
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"phin": "catalog:",
"as-table": "^1.0.55",
"yargs": "^17.7.2"
},
-4
View File
@@ -1,4 +0,0 @@
export function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
File diff suppressed because it is too large. Load diff
-201
View File
@@ -1,201 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const controllerCommands = new lib.CommandTree({ name: "controller", description: "Controller management" });
controllerCommands.add(new lib.Command({
definition: ["stop", "Stop controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerStopRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["restart", "Restart controller"],
handler: async function(_args: object, control: Control) {
await control.send(new lib.ControllerRestartRequest());
},
}));
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { restart: boolean }, control: Control) {
await control.send(new lib.ControllerUpdateRequest());
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Controller updated; a restart is required to apply the changes.");
}
},
}));
const controllerConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "controller config management",
});
controllerConfigCommands.add(new lib.Command({
definition: ["list", "List controller configuration"],
handler: async function(args: object, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set <field> [value]", "Set field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["set-prop <field> <prop> [value]", "Set property of field in controller config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args: { field: string, prop: string, value?: string, stdin?: boolean }, control: Control) {
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.ControllerConfigSetPropRequest(args.field, args.prop, value));
},
}));
controllerConfigCommands.add(new lib.Command({
definition: ["edit [editor]", "Edit controller configuration", (yargs) => {
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { editor: string }, control: Control) {
let config = await control.send(new lib.ControllerConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let allConfigElements = serializedConfigToString(
config, lib.ControllerConfig, {}
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
controllerCommands.add(controllerConfigCommands);
const controllerPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "controller plugin inspection",
});
controllerPluginCommands.add(new lib.Command({
definition: ["list", "List plugins on controller"],
handler: async function(args: object, control: Control) {
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/plugins";
let response = await fetch(url);
print(asTable(await response.json() as []));
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
controllerPluginCommands.add(new lib.Command({
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { plugin: string, restart: boolean }, control: Control) {
await control.sendTo("controller", new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
controllerCommands.add(controllerPluginCommands);
-11
View File
@@ -1,11 +0,0 @@
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by controller"],
handler: async function(args: object, control: Control) {
await control.send(new lib.DebugDumpWsRequest());
control.keepOpen = true;
},
}));
-305
View File
@@ -1,305 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const hostCommands = new lib.CommandTree({ name: "host", description: "Host management" });
hostCommands.add(new lib.Command({
definition: ["stop <host>", "Stop the given host", (yargs) => {
yargs.positional("host", { describe: "Host to stop", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostStopRequest());
},
}));
hostCommands.add(new lib.Command({
definition: ["restart <host>", "Restart the given host", (yargs) => {
yargs.positional("host", { describe: "Host to restart", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostRestartRequest());
},
}));
hostCommands.add(new lib.Command({
definition: [["list", "l"], "List hosts connected to the controller"],
handler: async function(args: object, control: Control) {
let hosts = await control.send(new lib.HostListRequest());
print(asTable(hosts));
},
}));
hostCommands.add(new lib.Command({
definition: ["generate-token", "Generate token for a host", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
}],
handler: async function(args: { id?: number }, control: Control) {
let hostId = typeof args.id === "number" ? args.id : undefined;
let response = await control.send(new lib.HostGenerateTokenRequest(hostId));
print(response);
},
}));
hostCommands.add(new lib.Command({
definition: ["revoke-token <host>", "Revoke all tokens for a host", (yargs) => {
yargs.positional("host", { describe: "Host to revoke tokens for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.send(new lib.HostRevokeTokensRequest(hostId));
},
}));
hostCommands.add(new lib.Command({
definition: ["create-config", "Create host config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Host id" });
yargs.option("name", { type: "string", nargs: 1, describe: "Host name" });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-host.json",
});
}],
handler: async function(
args: { id?: number, name?: string, generateToken: boolean, output: string },
control: Control
) {
let config = await control.send(
new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken)
);
let content = JSON.stringify(config, null, "\t");
if (args.output === "-") {
print(content);
} else {
logger.info(`Writing ${args.output}`);
try {
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
hostCommands.add(new lib.Command({
definition: ["update <host>", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.HostUpdateRequest());
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Host updated; a restart is required to apply the changes.");
}
},
}));
const hostConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Host config management",
});
hostConfigCommands.add(new lib.Command({
definition: ["list <host>", "List configuration for a host", (yargs) => {
yargs.positional("host", { describe: "Host to list config for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set <host> <field> [value]", "Set field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(args.field, args.value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["set-prop <host> <field> <prop> [value]", "Set property of field in host config", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { host: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let hostId = await lib.resolveHost(control, args.host);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.sendTo({ hostId }, new lib.HostConfigSetPropRequest(args.field, args.prop, value));
},
}));
hostConfigCommands.add(new lib.Command({
definition: ["edit <host> [editor]", "Edit host configuration", (yargs) => {
yargs.positional("host", { describe: "Host to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { host: string, editor: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
let config = await control.sendTo({ hostId }, new lib.HostConfigGetRequest());
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"host.id": 0};
let allConfigElements = serializedConfigToString(
config,
lib.HostConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
hostCommands.add(hostConfigCommands);
const hostPluginCommands = new lib.CommandTree({
name: "plugin", alias: ["p"], description: "host plugin inspection",
});
hostPluginCommands.add(new lib.Command({
definition: ["list <host>", "List plugins on a host", (yargs) => {
yargs.positional("host", { describe: "Host to list for", type: "string" });
}],
handler: async function(args: { host: string }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
const plugins = await control.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
}))));
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to update on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginUpdateRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin updated; a restart is required to apply the changes.");
}
},
}));
hostPluginCommands.add(new lib.Command({
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
yargs.positional("host", { describe: "Host to install on", type: "string" });
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
}],
handler: async function(args: { host: string, plugin: string, restart: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
await control.sendTo({ hostId }, new lib.PluginInstallRequest(args.plugin));
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
print("Plugin installed; a restart is required to apply the changes.");
}
},
}));
hostCommands.add(hostPluginCommands);
-685
View File
@@ -1,685 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import os from "os";
import child_process from "child_process";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const instanceCommands = new lib.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
instanceCommands.add(new lib.Command({
definition: [["list", "l"], "List instances known to the controller"],
handler: async function(args: object, control: Control) {
let list = await control.send(new lib.InstanceDetailsListRequest());
print(asTable(list));
},
}));
instanceCommands.add(new lib.Command({
definition: ["create <name>", "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
const instanceConfigCommands = new lib.CommandTree({
name: "config", alias: ["c"], description: "Instance config management",
});
instanceConfigCommands.add(new lib.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
for (let [name, value] of Object.entries(config)) {
print(`${name} ${JSON.stringify(value)}`);
}
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, value?: string, stdin?: boolean },
control: Control,
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await control.send(new lib.InstanceConfigSetFieldRequest(instanceId, args.field, args.value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: { instance: string, field: string, prop: string, value?: string, stdin?: boolean },
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await lib.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let value;
try {
if (args.value !== undefined) {
value = JSON.parse(args.value);
}
} catch (err: any) {
// If this is from stdin or looks like an array, object or string
// literal throw the parse error, otherwise assume this is a string.
// The resoning behind this is that correctly quoting the string
// with the all the layers of quote removal at play is difficult.
// See the following table for how to pass "That's a \" quote" in
// different environments:
// ----------------------- WARNING -------------------------------
// This table is most likely invalid due a change in yargs parsing
// ---------------------------------------------------------------
// cmd : """""That's a \\"" quote"""""
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
// PowerShell : '"""""That''s a \\"" quote"""""'
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
// bash : '""That'\''s a \" quote""'
// bash + npx : '""That'\''s a \" quote""'
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value!)) {
throw new lib.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
value = args.value;
}
await control.send(new lib.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value));
},
}));
instanceConfigCommands.add(new lib.Command({
definition: ["edit <instance> [editor]", "Edit instance configuration", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("editor", {
describe: "Editor to use",
type: "string",
default: "",
});
}],
handler: async function(args: { instance: string, editor: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let config = await control.send(new lib.InstanceConfigGetRequest(instanceId));
let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir());
let editor = await getEditor(args.editor);
if (editor === undefined) {
throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars
Try "ctl controller config edit <editor of choice>"`);
}
let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0};
let allConfigElements = serializedConfigToString(
config,
lib.InstanceConfig,
disallowedList
);
await fs.writeFile(tmpFile, allConfigElements);
let editorSpawn = child_process.spawn(editor, [tmpFile], {
stdio: "inherit",
detached: false,
});
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
}
try {
await fs.unlink(tmpFile);
} catch (err) {
print("err: temporary file", tmpFile, "could not be deleted.");
print("This is not fatal, but they may build up over time if the issue persists.");
}
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new lib.Command({
definition: ["assign <instance> [host]", "Assign instance to a host", (yargs) => {
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("host", { describe: "Host to assign to or unassign if none", type: "string" });
}],
handler: async function(args: { instance: string, host?: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let hostId = args.host ? await lib.resolveHost(control, args.host) : undefined;
await control.send(new lib.InstanceAssignRequest(instanceId, hostId));
},
}));
async function loadMapSettings(
args: { seed?: number, mapExchangeString?: string, mapGenSettings?: string, mapSettings?: string},
) {
let seed = args.seed;
let mapGenSettings;
let mapSettings;
if (args.mapExchangeString) {
let parsed = lib.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse((await fs.readFile(args.mapGenSettings)).toString());
}
if (args.mapSettings) {
mapSettings = JSON.parse((await fs.readFile(args.mapSettings)).toString());
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
const instanceSaveCommands = new lib.CommandTree({
name: "save", alias: ["s"], description: "Instance save management",
});
instanceSaveCommands.add(new lib.Command({
definition: ["list <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let saves = await control.sendTo("controller", new lib.InstanceSaveDetailsListRequest());
saves = saves.filter(save => save.instanceId === instanceId);
print(asTable(saves.map(
({ mtimeMs, ...rest }) => ({ ...rest, mtime: new Date(mtimeMs).toLocaleString() })
)));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["create <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(
args: {
instance: string,
name: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
},
control: Control
) {
let instanceId = await lib.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo(
{ instanceId },
new lib.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings),
);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["rename <instance> <old-name> <new-name>", "Rename a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to rename save on", type: "string" });
yargs.positional("old-name", { describe: "Old name of save.", type: "string" });
yargs.positional("new-name", { describe: "New name of save.", type: "string" });
}],
handler: async function(args: { instance: string, oldName: string, newName: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["copy <instance> <source> <destination>", "Copy a save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to copy save on", type: "string" });
yargs.positional("source", { describe: "Save to copy.", type: "string" });
yargs.positional("destination", { describe: "Name of copy.", type: "string" });
}],
handler: async function(args: { instance: string, source: string, destination: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceCopySaveRequest(instanceId, args.source, args.destination));
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["upload <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args: { instance: string, filepath: string, name?: string }, control: Control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Save name must end with .zip");
}
let instanceId = await lib.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", String(instanceId));
url.searchParams.append("filename", filename);
const response = await fetch(url, {
method: "POST",
body: await fs.createReadStream(args.filepath),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: string[], request_errors?: string[], saves?: string[] };
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.saves && result.saves.length) {
logger.info(`Successfully uploaded as ${result.saves[0]}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading save failed");
}
},
}));
instanceSaveCommands.add(new lib.Command({
definition: [
"transfer <source-instance> <source-save> <target-instance> [target-save]",
"Transfer a save between instances",
(yargs) => {
yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" });
yargs.positional("source-save", { describe: "Save to transfer.", type: "string" });
yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" });
yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" });
yargs.options({
"copy": { describe: "Copy instead of moving the save", type: "boolean", default: false },
});
},
],
handler: async function(
args: {
sourceInstance: string,
sourceSave: string,
targetInstance: string,
targetSave: string,
copy: boolean,
},
control: Control
) {
let sourceInstanceId = await lib.resolveInstance(control, args.sourceInstance);
let targetInstanceId = await lib.resolveInstance(control, args.targetInstance);
let storedName = await control.send(
new lib.InstanceTransferSaveRequest(
sourceInstanceId,
args.sourceSave,
targetInstanceId,
args.targetSave || args.sourceSave,
args.copy
)
);
print(`Transferred as ${storedName} to ${args.targetInstance}.`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["download <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server.");
}
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
let filename = await lib.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
}));
instanceSaveCommands.add(new lib.Command({
definition: ["delete <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args: { instance: string, save: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteSaveRequest(instanceId, args.save));
},
}));
instanceCommands.add(instanceSaveCommands);
instanceCommands.add(new lib.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExportDataRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["extract-players <instance>", "Extract players from running save into the cluster.", (yargs) => {
yargs.positional("instance", { describe: "Instance to extract players and online time from", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceExtractPlayersRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["restart <instance>", "Restart instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
yargs.options({
"force": {
describe: "Start instances even if they have exclude_from_start_all set",
nargs: 0,
type: "boolean",
default: false,
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
// Only start instances that are currently stopped
if (instance.status !== "stopped") {
return false;
}
// Respect exclude_from_start_all setting unless --force is used
if (!args.force && instance.excludeFromStartAll) {
return false;
}
return true;
});
if (instancesToStart.length === 0) {
print("No instances to start.");
return;
}
print(`Starting ${instancesToStart.length} instance(s):`);
for (const instance of instancesToStart) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
}
});
await Promise.all(startPromises);
print("Start all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
instance: string,
scenario: string,
seed?: number,
mapExchangeString?: string,
mapGenSettings?: string,
mapSettings?: string,
keepOpen: boolean,
},
control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.sendTo(
{ instanceId },
new lib.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings),
);
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStopRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
if (instancesToStop.length === 0) {
print("No instances to stop.");
return;
}
print(`Stopping ${instancesToStop.length} instance(s):`);
for (const instance of instancesToStop) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
}
});
await Promise.all(stopPromises);
print("Stop all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceKillRequest());
},
}));
instanceCommands.add(new lib.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args: { instance: string }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
await control.send(new lib.InstanceDeleteRequest(instanceId));
},
}));
instanceCommands.add(new lib.Command({
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args: { instance: string, command: string }, control: Control) {
let result = await control.sendTo(
{ instanceId: await lib.resolveInstance(control, args.instance) },
new lib.InstanceSendRconRequest(args.command),
);
// Factorio includes a newline in its response output.
process.stdout.write(result);
},
}));
-90
View File
@@ -1,90 +0,0 @@
import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import type { Control } from "../ctl";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
definition: ["follow", "follow cluster log", (yargs) => {
yargs.options({
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Follow log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Follow log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
});
}],
handler: async function(
args: { all: boolean, controller: boolean, host: string | null, instance: string | null },
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds });
control.keepOpen = true;
},
}));
logCommands.add(new lib.Command({
definition: ["query", "Query cluster log", (yargs) => {
yargs.options({
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
"controller": { describe: "Query log of the controller", nargs: 0, type: "boolean", default: false },
"host": { describe: "Query log of given host", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: undefined },
"limit": { describe: "Max number of entries to return", nargs: 1, type: "number", default: 1000 },
"start": { describe: "Limit from the start instead of the end", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
all: boolean,
controller: boolean,
host: string | null,
instance: string | null,
maxLevel?: string,
limit: number,
start: boolean,
},
control: Control
) {
if (!args.all && !args.controller && !args.host && !args.instance) {
logger.error("At least one of --all, --controller, --host and --instance must be passed");
process.exitCode = 1;
return;
}
let instanceIds = args.instance ? [await lib.resolveInstance(control, args.instance)] : [];
let hostIds = args.host ? [await lib.resolveHost(control, args.host)] : [];
let result = await control.send(new lib.LogQueryRequest(
args.all,
args.controller,
hostIds,
instanceIds,
args.maxLevel as keyof typeof levels,
args.limit,
args.start ? "asc" : "desc",
));
if (!args.start) {
result.log.reverse();
}
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
format: new lib.TerminalFormat({ showTimestamp: true }),
transports: [
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
],
});
for (let info of result.log) {
stdoutLogger.log(info as any);
}
},
}));
-237
View File
@@ -1,237 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
import stream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
export const modCommands = new lib.CommandTree({ name: "mod", description: "Manage uploaded mods" });
modCommands.add(new lib.Command({
definition: ["show <name> <mod-version>", "Show details for a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Mod name to show details for", type: "string" });
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
print(`${field}:`);
for (let entry of value) {
print(` ${entry}`);
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modCommands.add(new lib.Command({
definition: [["list", "l"], "List mods stored in the cluster", (yargs) => {
yargs.options({
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(args: { fields: string[] }, control: Control) {
let mods = await control.send(new lib.ModListRequest());
if (!args.fields.includes("all")) {
for (let entry of mods) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
print(asTable(mods));
},
}));
modCommands.add(new lib.Command({
definition: ["search <factorio-version> [query]", "Search mods stored in the cluster", (yargs) => {
yargs.positional("factorio-version", { describe: "Major version of Factorio to search for", type: "string" });
yargs.positional("query", { describe: "Search query", type: "string", default: "" });
yargs.options({
"page": {
describe: "Result page to show",
type: "number",
default: 1,
},
"page-size": {
describe: "Results per page to show",
type: "number",
default: 10,
},
"sort": {
describe: "sort results by given field",
type: "string",
},
"sort-order": {
describe: "order to sort results in (asc/desc)",
type: "string",
default: "asc",
},
"fields": {
describe: "Fields to show, supports 'all'.",
array: true,
type: "string",
default: ["name", "version", "title", "factorio_version"],
},
});
}],
handler: async function(
args: {
factorioVersion: string,
query: string,
page: number,
pageSize: number,
sort?: string,
sortOrder: "asc" | "desc",
fields: string[],
},
control: Control
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
args.sortOrder
));
let results = response.results.flatMap(result => result.versions);
if (!args.fields.includes("all")) {
for (let entry of results) {
for (let field of Object.keys(entry)) {
if (!args.fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
}
for (let issue of response.queryIssues) {
print(issue);
}
print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`);
print(asTable(results));
},
}));
modCommands.add(new lib.Command({
definition: ["upload <file>", "Upload mod to the cluster", (yargs) => {
yargs.positional("file", { describe: "File to upload", type: "string" });
}],
handler: async function(args: { file: string }, control: Control) {
let filename = path.basename(args.file);
if (!filename.endsWith(".zip")) {
throw new lib.CommandError("Mod filename must end with .zip");
}
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += "api/upload-mod";
url.searchParams.append("filename", filename);
let response = await fetch(url, {
method: "POST",
body: fs.createReadStream(args.file),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
"Content-Type": "application/zip",
},
});
type ResponseType = { errors?: [], request_errors?: [], mods: Parameters<typeof lib.ModInfo.fromJSON>[0][]};
const result = await response.json() as ResponseType;
for (let error of result.errors || []) {
logger.error(error);
}
for (let requestError of result.request_errors || []) {
logger.error(requestError);
}
if (result.mods && result.mods.length) {
const mod = lib.ModInfo.fromJSON(result.mods[0]);
logger.info(`Successfully uploaded ${mod.filename}`);
}
if ((result.errors || []).length || (result.request_errors || []).length) {
throw new lib.CommandError("Uploading mod failed");
}
},
}));
modCommands.add(new lib.Command({
definition: ["download <name> <mod-version>", "Download a mod from the cluster", (yargs) => {
yargs.positional("name", { describe: "Internal name of mod to download", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server");
}
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${filename}`);
},
}));
modCommands.add(new lib.Command({
definition: ["delete <name> <mod-version>", "Delete a mod stored in the cluster", (yargs) => {
yargs.positional("name", { describe: "Name of mod to delete", type: "string" });
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
},
}));
-315
View File
@@ -1,315 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const modPackCommands = new lib.CommandTree({ name: "mod-pack", description: "Mod Pack" });
modPackCommands.add(new lib.Command({
definition: ["show <mod-pack>", "Show details of mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
let mods = new Map(
(await control.send(new lib.ModListRequest())).map(m => [m.id, m])
);
let modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
for (let [field, value] of Object.entries(modPack)) {
if (field === "mods") {
print(`${field}:`);
for (let [name, entry] of value) {
const mod = mods.get(`${name}_${entry.version}`);
const missing = !mod && "? ";
const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! ";
const warn = missing || badChecksum || "";
const enabled = entry.enabled ? "" : "(disabled) ";
print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`);
}
} else if (field === "settings") {
print(`${field}:`);
for (let [scope, settings] of Object.entries(value)) {
print(` ${scope}:`);
for (let [setting, settingValue] of settings as any) {
print(` ${setting}: ${JSON.stringify(settingValue.value)}`);
}
}
} else if (field === "exportManifest") {
print(`${field}:`);
if (value && value.assets) {
print(" assets:");
for (let [name, fileName] of Object.entries(value.assets)) {
print(` ${name}: ${fileName}`);
}
}
} else {
print(`${field}: ${value}`);
}
}
},
}));
modPackCommands.add(new lib.Command({
definition: [["list", "l"], "List mod packs in the cluster"],
handler: async function(args: object, control: Control) {
let modPacks = await control.send(new lib.ModPackListRequest());
let fields = ["id", "name", "factorioVersion"];
for (let entry of modPacks) {
for (let field of Object.keys(entry)) {
if (!fields.includes(field)) {
// @ts-expect-error terrible hack
delete entry[field];
}
}
}
print(asTable(modPacks));
},
}));
function setModPackSettings(
modPack: lib.ModPack,
args: {
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
) {
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
for (let i = 0; i + 2 < settings.length; i += 3) {
const [scope, name, value] = settings.slice(i, i + 3);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
modPack.settings[checkedScope].set(name, { value: cast(value) });
}
}
doSettings(args.boolSetting || [], value => {
if (!["true", "false"].includes(value)) {
throw new lib.CommandError(`boolean value must be one of true or false, not ${value}`);
}
return value === "true";
});
doSettings(args.intSetting || [], value => {
// eslint-disable-next-line radix
let number = Number.parseInt(value);
if (isNaN(number)) {
throw new lib.CommandError(`int value must be an integer, not ${value}`);
}
return number;
});
doSettings(args.doubleSetting || [], value => {
let number = Number.parseFloat(value);
if (isNaN(number)) {
throw new lib.CommandError(`double value must be a number, not ${value}`);
}
return number;
});
doSettings(args.stringSetting || [], value => value);
doSettings(args.colorSetting || [], value => {
let color = JSON.parse(value);
if (typeof color !== "object" || color === null) {
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
}
return color;
});
}
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
for (let mod of mods || []) {
const [name, version, sha1] = mod.split(":");
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
throw new lib.CommandError("sha1 must be a 40 digit lower case hex string");
}
modPack.mods.set(name, { name, enabled: true, version, sha1 });
}
}
function setModPackModsEnabled(modPack: lib.ModPack, mods: string[] | undefined, enabled: boolean) {
for (let mod of mods || []) {
if (!modPack.mods.has(mod)) {
throw new lib.CommandError(`Mod named ${mod} does not exist in the mod pack`);
}
modPack.mods.get(mod)!.enabled = enabled;
}
}
modPackCommands.add(new lib.Command({
definition: ["create <name> <factorio-version>", "Create mod pack", (yargs) => {
yargs.positional("name", { describe: "Name of mod pack to create", type: "string" });
yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" });
yargs.options({
"description": { describe: "Description for mod pack", type: "string" },
"mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" },
"disabled-mods": { describe: "Mods that are in the pack but not enabled", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(
args: {
name: string,
factorioVersion: string,
description?: string,
mods?: string[],
disabledMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
) {
const modPack = new lib.ModPack();
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.mods);
setModPackModsEnabled(modPack, args.disabledMods, false);
setModPackSettings(modPack, args);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["import <string>", "Import mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack string to import", type: "string" });
}],
handler: async function(args: { string: string }, control: Control) {
const modPack = lib.ModPack.fromModPackString(args.string);
await control.send(new lib.ModPackCreateRequest(modPack));
print(`Created mod pack ${modPack.name} (${modPack.id})`);
},
}));
modPackCommands.add(new lib.Command({
definition: ["export <mod-pack>", "Export mod pack string", (yargs) => {
yargs.positional("string", { describe: "Mod pack to export", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
print(modPack.toModPackString());
},
}));
modPackCommands.add(new lib.Command({
definition: ["edit <mod-pack>", "Edit mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" });
yargs.options({
"name": { describe: "New name for mod pack", type: "string" },
"description": { describe: "New description for mod pack", type: "string" },
"factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" },
"add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" },
"enable-mods": { describe: "Mods to set as enabled", array: true, type: "string" },
"disable-mods": { describe: "Mods to set as disabled", array: true, type: "string" },
"remove-mods": { describe: "Name of mods to remove", array: true, type: "string" },
"bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" },
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
handler: async function(
args: {
modPack: string,
name?: string,
description?: string,
factorioVersion?: string,
addMods?: string[],
enableMods?: string[],
disableMods?: string[],
removeMods?: string[],
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
) {
const modPack = await control.send(
new lib.ModPackGetRequest(await lib.resolveModPack(control, args.modPack))
);
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
setModPackMods(modPack, args.addMods);
for (let name of args.removeMods || []) {
if (modPack.mods.has(name)) {
modPack.mods.delete(name);
} else {
logger.warn(`Mod ${name} did not exist on ${modPack.name}`);
}
}
setModPackModsEnabled(modPack, args.disableMods, false);
setModPackModsEnabled(modPack, args.enableMods, true);
setModPackSettings(modPack, args);
if (args.removeSetting) {
for (let i = 0; i + 1 < args.removeSetting.length; i += 2) {
const [scope, name] = args.removeSetting.slice(i, i + 2);
if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) {
throw new lib.CommandError(
`Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}`
);
}
const checkedScope = scope as "startup" | "runtime-global" | "runtime-per-user";
if (modPack.settings[checkedScope].has(name)) {
modPack.settings[checkedScope].delete(name);
} else {
logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`);
}
}
}
await control.send(new lib.ModPackUpdateRequest(modPack));
},
}));
modPackCommands.add(new lib.Command({
definition: ["delete <mod-pack>", "Delete mod pack", (yargs) => {
yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" });
}],
handler: async function(args: { modPack: string }, control: Control) {
const id = await lib.resolveModPack(control, args.modPack);
await control.send(new lib.ModPackDeleteRequest(id));
},
}));
-16
View File
@@ -1,16 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const permissionCommands = new lib.CommandTree({ name: "permission", description: "Permission inspection" });
permissionCommands.add(new lib.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args: object, control: Control) {
let permissions = await control.send(new lib.PermissionListRequest());
print(asTable(permissions));
},
}));
-100
View File
@@ -1,100 +0,0 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const roleCommands = new lib.CommandTree({ name: "role", description: "Role management" });
roleCommands.add(new lib.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args: object, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
print(asTable(roles));
},
}));
roleCommands.add(new lib.Command({
definition: ["create <name>", "Create a new role", (yargs) => {
yargs.positional("name", { describe: "Name of role to create", type: "string" });
yargs.options({
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args: { name: string, description: string, permissions: string[] }, control: Control) {
let id = await control.send(new lib.RoleCreateRequest(
args.name,
args.description,
args.permissions,
));
logger.info(`Created role ID ${id}`);
},
}));
roleCommands.add(new lib.Command({
definition: ["edit <role>", "Edit existing role", (yargs) => {
yargs.positional("role", { describe: "Role to edit", type: "string" });
yargs.options({
"name": { describe: "New name for role", nargs: 1, type: "string" },
"description": { describe: "New description for role", nargs: 1, type: "string" },
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
"remove-perms": {
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
},
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
handler: async function(
args: {
role: string,
name?: string,
description?: string,
setPerms?: string[],
addPerms?: string[],
removePerms?: string[],
grantDefault?: boolean,
},
control: Control
) {
let role = await lib.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
}
if (args.description !== undefined) {
role.description = args.description;
}
if (args.addPerms) {
for (const perm of args.addPerms) {
role.permissions.add(perm);
}
}
if (args.removePerms) {
for (let perm of args.removePerms) {
role.permissions.delete(perm);
}
}
if (args.setPerms !== undefined) {
role.permissions = new Set(args.setPerms);
}
await control.send(new lib.RoleUpdateRequest(role.id, role.name, role.description, [...role.permissions]));
if (args.grantDefault) {
await control.send(new lib.RoleGrantDefaultPermissionsRequest(role.id));
}
},
}));
roleCommands.add(new lib.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args: { role: string }, control: Control) {
let role = await lib.retrieveRole(control, args.role);
await control.send(new lib.RoleDeleteRequest(role.id));
},
}));
-304
View File
@@ -1,304 +0,0 @@
import fs from "fs-extra";
import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
import type { Control } from "../ctl";
import { print } from "./command_ops";
const asTable = asTableModule.configure({ delimiter: " | " });
export const userCommands = new lib.CommandTree({ name: "user", alias: ["u"], description: "User management" });
userCommands.add(new lib.Command({
definition: ["show <name>", "Show details for one user", (yargs) => {
yargs.positional("name", { decribe: "Name of user to show", type: "string" });
yargs.options({
"instance-stats": { describe: "include per-instance stats", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { name: string, instanceStats: boolean }, control: Control) {
let user = await control.send(new lib.UserGetRequest(args.name));
Object.assign(user, user.playerStats);
// @ts-expect-error Terrible hack
delete user.playerStats;
let instanceStats = user.instanceStats;
// @ts-expect-error Terrible hack
delete user.instanceStats;
print(asTable(Object.entries(user).map(([property, value]) => ({ property, value }))));
if (args.instanceStats) {
let instances = await control.send(new lib.InstanceDetailsListRequest());
function instanceName(id: number) {
let instance = instances.find(i => i.id === id);
if (instance) {
return instance.name;
}
return "<deleted>";
}
for (let [id, playerInstanceStats] of instanceStats || []) {
print();
print(`Instance ${instanceName(id)} (${id}):`);
print(asTable(Object.entries(playerInstanceStats).map(([property, value]) => ({ property, value }))));
}
}
},
}));
userCommands.add(new lib.Command({
definition: [["list", "l"], "List user in the cluster", (yargs) => {
yargs.options({
"stats": { describe: "include user stats", nargs: 0, type: "boolean", default: false },
"attributes": { describe: "include admin/whitelisted/banned", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { stats: boolean, attributes: boolean }, control: Control) {
let users = await control.send(new lib.UserListRequest());
for (let user of users) {
if (args.stats) {
Object.assign(user, user.playerStats);
}
// @ts-expect-error Terrible hack
delete user.playerStats;
// @ts-expect-error Terrible hack
delete user.isDeleted;
// @ts-expect-error Terrible hack
delete user.banReason;
// @ts-expect-error Terrible hack
delete user.instanceStats;
if (!args.attributes) {
// @ts-expect-error Terrible hack
delete user.isAdmin;
// @ts-expect-error Terrible hack
delete user.isWhitelisted;
// @ts-expect-error Terrible hack
delete user.isBanned;
}
}
print(asTable(users));
},
}));
userCommands.add(new lib.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserCreateRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["revoke-token <name>", "Revoke token for user", (yargs) => {
yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" });
}],
handler: async function(args: { name: string }, control: Control) {
await control.send(new lib.UserRevokeTokenRequest(args.name));
},
}));
userCommands.add(new lib.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
yargs.options({
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, revoke: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetAdminRequest(args.user, args.create, !args.revoke));
},
}));
userCommands.add(new lib.Command({
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
yargs.options({
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { user: string, remove: boolean, create: boolean }, control: Control) {
await control.send(new lib.UserSetWhitelistedRequest(args.user, args.create, !args.remove));
},
}));
userCommands.add(new lib.Command({
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
yargs.options({
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(
args: {
user: string,
pardon: boolean,
reason: string,
create: boolean
},
control: Control
) {
await control.send(new lib.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason));
},
}));
userCommands.add(new lib.Command({
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args: { user: string, roles: string[] }, control: Control) {
let roles = await control.send(new lib.RoleListRequest());
let resolvedRoles = [];
for (let roleName of args.roles) {
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
resolvedRoles.push(roleId);
} else {
let found = false;
for (let role of roles) {
if (role.name === roleName) {
resolvedRoles.push(role.id);
found = true;
break;
}
}
if (!found) {
throw new lib.CommandError(`No role named ${roleName}`);
}
}
}
await control.send(new lib.UserUpdateRolesRequest(args.user, resolvedRoles));
},
}));
userCommands.add(new lib.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args: { user: string }, control: Control) {
await control.send(new lib.UserDeleteRequest(args.user));
},
}));
async function handleImportOrRestore(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
noBackup?: boolean,
}, control: Control, restore: boolean) {
let importType: ConstructorParameters<typeof lib.UserBulkImportRequest>[0];
const data = JSON.parse((await fs.readFile(args.filepath)).toString());
const optionCount = [args.users, args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
const filename = path.basename(args.filepath);
if (args.users || (optionCount === 0 && data.export_version)) {
importType = "users";
} else if (args.bans || (optionCount === 0 && filename.includes("ban"))) {
importType = "bans";
} else if (args.admins || (optionCount === 0 && filename.includes("admin"))) {
importType = "admins";
} else if (args.whitelist || (optionCount === 0 && filename.includes("whitelist"))) {
importType = "whitelist";
} else {
throw new lib.CommandError("Unknown json file, please specify an option");
}
const backup = await control.send(
new lib.UserBulkImportRequest(importType, importType === "users" ? data.users : data, restore)
);
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
userCommands.add(new lib.Command({
definition: ["import <filepath>", "Import user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Import users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Import banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Import adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Import whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, false);
},
}));
userCommands.add(new lib.Command({
definition: ["restore <filepath>", "Restore user data from a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to import", type: "string" });
yargs.options({
"users": { describe: "Restore users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Restore banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Restore adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Restore whitelist json", nargs: 0, type: "boolean", default: false },
"no-backup": { describe: "Don't save a backup to the cwd", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: any, control: any) {
await handleImportOrRestore(args, control, true);
},
}));
userCommands.add(new lib.Command({
definition: ["export <filepath>", "Export user data to a file", (yargs) => {
yargs.positional("filepath", { describe: "Path to the file to save to", type: "string" });
yargs.options({
"users": { describe: "Export users json", nargs: 0, type: "boolean", default: false },
"bans": { describe: "Export banlist json", nargs: 0, type: "boolean", default: false },
"admins": { describe: "Export adminlist json", nargs: 0, type: "boolean", default: false },
"whitelist": { describe: "Export whitelist json", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: {
filepath: string,
users: boolean,
bans: boolean,
admins: boolean,
whitelist: boolean,
}, control: Control) {
let exportType = "users" as ConstructorParameters<typeof lib.UserBulkExportRequest>[0];
const optionCount = [args.bans, args.admins, args.whitelist]
.reduce((acc, bool) => (bool ? acc + 1 : acc), 0);
if (optionCount > 1) {
throw new lib.CommandError("Can not specify multiple options");
}
// Assign based on options or attempt to guess based on filename
if (args.users) {
exportType = "users";
} else if (args.bans) {
exportType = "bans";
} else if (args.admins) {
exportType = "admins";
} else if (args.whitelist) {
exportType = "whitelist";
}
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
-58
View File
@@ -1,58 +0,0 @@
import * as lib from "@clusterio/lib";
export async function getEditor(argsEditor: string) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
export async function configToKeyVal(data: string) {
let final: Record<string, string> = {};
let splitData = data.split(/\r?\n/);
// split on newlines
let filtered = splitData.filter((value) => value[0] !== "#").filter((a) => a);
// the last filter removes empty elements left by the first. Not done on one line due to readability.
for (let index in filtered) {
if (index in filtered) {
let split = filtered[index].split("=");
let finalIndex = filtered[index][0].trim();
// split on the = we added earlier, giving us both value and key
let part = "";
try {
part = split[1].trim();
// it's a string if we can read it
} catch (err) {
// if we can't read it, it's a empty field and therefor null
part = "";
}
final[finalIndex] = part;
}
}
return final;
}
export function serializedConfigToString(
serializedConfig: lib.ConfigSchema,
configGroup: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig,
disallowedList: Record<string, unknown>,
) {
let allConfigElements = "";
for (let [name, value] of Object.entries(serializedConfig)) {
if (name in disallowedList) {
continue;
}
let desc = "";
try {
desc += (configGroup.fieldDefinitions as any)[name]!.description;
} catch (err) {
desc += "No description found";
}
// split onto two lines for readability and es-lint
if (String(value) === "null") {
value = "";
}
allConfigElements += `${name} = ${value}\n\n`;
}
return allConfigElements;
}
+11 -3
View File
@@ -51,11 +51,13 @@ void new lib.Gauge(
export class HostConnector extends lib.WebSocketClientConnector {
constructor(
public hostConfig: lib.HostConfig,
tlsCa: string | undefined,
public pluginInfos: lib.PluginNodeEnvInfo[]
) {
super(
hostConfig.get("host.controller_url"),
hostConfig.get("host.max_reconnect_delay"),
tlsCa
);
}
@@ -228,10 +230,17 @@ async function startHost() {
return;
}
let hostConnector = new HostConnector(hostConfig, pluginInfos);
let tlsCa: string | undefined;
let tlsCaPath = hostConfig.get("host.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
host = new Host(
hostConnector,
hostConfig,
tlsCa,
pluginInfos,
Boolean(args.canRestart),
Boolean(args.recovery),
@@ -282,7 +291,6 @@ async function startHost() {
});
await hostConnector.connect();
host.startedAtMs = Date.now();
logger.info("Started host");
}
@@ -299,7 +307,7 @@ I version of clusterio. Expect things to break. I
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
} else if (err instanceof lib.StartupError || err.code === "InstallationError") {
} else if (err instanceof lib.StartupError) {
logger.fatal(`
+----------------------------------+
| Unable to to start clusteriohost |
+2 -1
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -26,6 +26,7 @@
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"phin": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
+110 -119
View File
@@ -3,6 +3,7 @@ import path from "path";
import events from "events";
import pidusage from "pidusage";
import setBlocking from "set-blocking";
import phin from "phin";
import stream from "stream";
import util from "util";
@@ -31,7 +32,7 @@ function checkRequestSaveName(name: string) {
*
* Looks through all sub-dirs of the provided directory for valid
* instance definitions and return a mapping of instance id to
* instance configs and the root instance paths.
* instanceInfo objects.
*
* @param instancesDir - Directory containing instances
* @returns
@@ -39,7 +40,7 @@ function checkRequestSaveName(name: string) {
* @internal
*/
async function discoverInstances(instancesDir: string) {
const instances = new Map<number, UnloadedInstance>();
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", Host.instanceConfigWarning);
@@ -59,25 +60,21 @@ async function discoverInstances(instancesDir: string) {
continue;
}
if (instances.has(instanceConfig.get("instance.id"))) {
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instances.set(instanceConfig.get("instance.id"), {
instanceInfos.set(instanceConfig.get("instance.id"), {
path: instancePath,
config: instanceConfig,
});
}
}
return instances;
return instanceInfos;
}
// Requests that wake up instances are responsible for making sure the
// instance status is correctly updated even if they fail with an error. This
// means they should typically use try catch on anything that can throw
// and call notifyExit upon any errors.
const instanceStartingMessages = new Set([
lib.InstanceStartRequest.name,
lib.InstanceLoadScenarioRequest.name,
@@ -117,10 +114,10 @@ export class HostRouter {
dst.type === lib.Address.controller
|| dst.type === lib.Address.control
|| dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id")
|| dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id)
|| dst.type === lib.Address.instance && !this.host.instanceInfos.has(dst.id)
) {
nextHop = this.host;
} else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) {
} else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) {
nextHop = this.host.instanceConnections.get(dst.id)!;
}
@@ -247,12 +244,6 @@ export class HostRouter {
}
}
// Instance information required to location and load an instance
type UnloadedInstance = {
path: string,
config: lib.InstanceConfig,
};
/**
* Handles running the host
*
@@ -262,18 +253,19 @@ export default class Host extends lib.Link {
declare "connector": HostConnector;
router = new HostRouter(this);
/**
* Certificate authority used to validate TLS connections to the controller.
*/
tlsCa?: string;
pluginInfos: lib.PluginNodeEnvInfo[];
config: lib.HostConfig;
/** Mapping of plugin name to loaded plugin */
plugins: Map<string, BaseHostPlugin> = new Map();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
/** A map of instances that exist in the instance directory of the host */
discoveredInstances = new Map<number, UnloadedInstance>();
/** A map of instances that are assigned to this host */
assignedInstances = new Map<number, UnloadedInstance>();
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
@@ -286,8 +278,6 @@ export default class Host extends lib.Link {
_disconnecting = false;
_shuttingDown = false;
startedAtMs = Date.now();
static instanceConfigWarning = {
"_warning": "Changes to this file will be overwritten by the controller's copy.",
};
@@ -303,6 +293,7 @@ export default class Host extends lib.Link {
constructor(
connector: HostConnector,
hostConfig: lib.HostConfig,
tlsCa: string | undefined,
pluginInfos: lib.PluginNodeEnvInfo[],
/**
* If true indicates that there is a process monitor present that
@@ -317,6 +308,7 @@ export default class Host extends lib.Link {
public modStore = new lib.ModStore(hostConfig.get("host.mods_directory"), new Map()),
) {
super(connector);
this.tlsCa = tlsCa;
this.pluginInfos = pluginInfos;
this.config = hostConfig;
@@ -444,9 +436,6 @@ export default class Host extends lib.Link {
this.plugins.set(pluginInfo.name, hostPlugin);
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new lib.PluginError(pluginInfo.name, err);
}
@@ -591,17 +580,18 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new Error(`Empty body downloading stream ${streamId} from controller`);
}
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
await events.once(writeStream, "open");
stream.Readable.fromWeb(response.body).pipe(writeStream);
response.pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
@@ -660,16 +650,16 @@ export default class Host extends lib.Link {
async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) {
let { instanceId, config } = request;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
instance.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance));
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
instanceInfo.config.update(config, true, "controller");
logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo));
} else {
instance = this.discoveredInstances.get(instanceId);
if (instance) {
await Instance.populate_folders(instance.path);
instance.config.update(config, true, "controller");
instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (instanceInfo) {
await Instance.populate_folders(instanceInfo.path);
instanceInfo.config.update(config, true, "controller");
} else {
let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning);
@@ -680,16 +670,16 @@ export default class Host extends lib.Link {
logger.info(`Creating ${instanceDir}`);
await Instance.populate_folders(instanceDir);
instance = {
instanceInfo = {
path: instanceDir,
config: instanceConfig,
};
this.discoveredInstances.set(instanceId, instance);
this.discoveredInstanceInfos.set(instanceId, instanceInfo);
}
this.assignedInstances.set(instanceId, instance);
logger.verbose(`assigned instance ${instance.config.get("instance.name")}`);
this.instanceInfos.set(instanceId, instanceInfo);
logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`);
}
// Somewhat hacky, but in the event of a lost session the status is
@@ -704,45 +694,45 @@ export default class Host extends lib.Link {
instanceConnection
? instanceConnection.instance.server.version
// TODO: factorio.version is not validated
: instance.config.get("factorio.version") as lib.TargetVersion,
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
)
);
// Send the new list of saves for this assigned instance to the controller.
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
// save a copy of the instance config
await instance.config.save();
await instanceInfo.config.save();
}
async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) {
let instanceId = request.instanceId;
let instance = this.assignedInstances.get(instanceId);
if (instance) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (instanceInfo) {
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) {
await instanceConnection.send(new lib.InstanceStopRequest());
}
this.assignedInstances.delete(instanceId);
logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`);
this.instanceInfos.delete(instanceId);
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
}
}
instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) {
instance = instance || this.assignedInstances.get(instanceId);
if (!instance) {
instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
instanceInfo = instanceInfo || this.instanceInfos.get(instanceId);
if (!instanceInfo) {
return { instance_id: instanceId, instance_name: String(instanceId) };
}
return { instance_id: instanceId, instance_name: instance.config.get("instance.name") };
return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") };
}
getRequestInstance(instanceId: number) {
let instance = this.assignedInstances.get(instanceId);
if (!instance) {
getRequestInstanceInfo(instanceId: number) {
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
return instanceInfo;
}
/**
@@ -751,7 +741,7 @@ export default class Host extends lib.Link {
* @returns Assigned game port or undefined if it does not exist.
*/
gamePort(instanceId: number) {
const instance = this.discoveredInstances.get(instanceId);
const instance = this.discoveredInstanceInfos.get(instanceId);
if (!instance) {
return undefined;
}
@@ -764,7 +754,7 @@ export default class Host extends lib.Link {
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const [id, instance] of this.assignedInstances) {
for (const [id, instance] of this.instanceInfos) {
if (id === instanceId) {
continue;
}
@@ -776,7 +766,7 @@ export default class Host extends lib.Link {
availablePorts.delete(port);
}
}
const instanceToAssign = this.assignedInstances.get(instanceId)!;
const instanceToAssign = this.instanceInfos.get(instanceId)!;
const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port");
if (assignedPort !== null && availablePorts.has(assignedPort)) {
return assignedPort;
@@ -796,7 +786,7 @@ export default class Host extends lib.Link {
* @returns connection to instance.
*/
async _connectInstance(instanceId: number) {
let unloadedInstance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
if (this.instanceConnections.has(instanceId)) {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
@@ -805,9 +795,7 @@ export default class Host extends lib.Link {
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
let instance = new Instance(
this, connectionClient,
unloadedInstance.path, this.config.get("host.factorio_directory"),
unloadedInstance.config
this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config
);
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
@@ -819,7 +807,7 @@ export default class Host extends lib.Link {
async handleSystemInfoRequest() {
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
}
async checkRestartRequired() {
@@ -906,19 +894,19 @@ export default class Host extends lib.Link {
src: lib.Address,
dst: lib.Address
) {
let instance = this.getRequestInstance(dst.id);
return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null);
let instanceInfo = this.getRequestInstanceInfo(dst.id);
return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null);
}
async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) {
let { instanceId, oldName, newName } = request;
checkRequestSaveName(oldName);
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.move(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
path.join(instanceInfo.path, "saves", oldName),
path.join(instanceInfo.path, "saves", newName),
{ overwrite: false },
);
} catch (err: any) {
@@ -927,18 +915,18 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) {
let { instanceId, source, destination } = request;
checkRequestSaveName(source);
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.copy(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
path.join(instanceInfo.path, "saves", source),
path.join(instanceInfo.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
);
} catch (err: any) {
@@ -947,33 +935,33 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
checkRequestSaveName(sourceName);
checkRequestSaveName(targetName);
let sourceInstance = this.getRequestInstance(sourceInstanceId);
let targetInstance = this.getRequestInstance(targetInstanceId);
let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId);
let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId);
// For consistency with remote transfer initiated through pullSave the
// target is renamed if it already exists.
targetName = await lib.findUnusedName(
path.join(targetInstance.path, "saves"), targetName, ".zip"
path.join(targetInstanceInfo.path, "saves"), targetName, ".zip"
);
try {
if (copy) {
await fs.copy(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
{ overwrite: true },
);
} else {
await fs.move(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
path.join(sourceInstanceInfo.path, "saves", sourceName),
path.join(targetInstanceInfo.path, "saves", targetName),
{ overwrite: true },
);
}
@@ -983,8 +971,8 @@ export default class Host extends lib.Link {
}
throw err;
}
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves"));
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves"));
return targetName;
}
@@ -1004,34 +992,38 @@ export default class Host extends lib.Link {
async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) {
let { instanceId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
try {
await fs.unlink(path.join(instance.path, "saves", name));
await fs.unlink(path.join(instanceInfo.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
}
async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa } as object,
stream: true,
});
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
if (response.statusCode !== 200) {
let content = await lib.readStream(response);
throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
}
let savesDir = path.join(instance.path, "saves");
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
@@ -1047,7 +1039,7 @@ export default class Host extends lib.Link {
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
response.pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
@@ -1060,13 +1052,12 @@ export default class Host extends lib.Link {
async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) {
let { instanceId, streamId, name } = request;
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let instanceInfo = this.getRequestInstanceInfo(instanceId);
let content = fs.createReadStream(path.join(instance.path, "saves", name));
let content: Buffer;
try {
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
// "open" with "ready" when fs-extra is replaced with node's fs module.
await events.once(content, "open");
// phin doesn't support streaming requests :(
content = await fs.readFile(path.join(instanceInfo.path, "saves", name));
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1076,10 +1067,10 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
fetch(url, {
method: "PUT",
body: content,
duplex: "half",
phin({
url, method: "PUT",
core: { ca: this.tlsCa } as object,
data: content,
}).catch(err => {
logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId));
});
@@ -1091,14 +1082,14 @@ export default class Host extends lib.Link {
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
}
let instance = this.discoveredInstances.get(instanceId);
if (!instance) {
let instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (!instanceInfo) {
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
}
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.remove(instance.path);
this.discoveredInstanceInfos.delete(instanceId);
this.instanceInfos.delete(instanceId);
await fs.remove(instanceInfo.path);
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
@@ -1148,12 +1139,12 @@ export default class Host extends lib.Link {
* the host and controller with the new list of instances.
*/
async updateInstances() {
this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory"));
this.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory"));
let list = [];
for (let [instanceId, instance] of this.discoveredInstances) {
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push(new lib.HostInstanceUpdate(
instance.config.toRemote("controller"),
instanceInfo.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
@@ -1165,10 +1156,10 @@ export default class Host extends lib.Link {
if (this._startup) {
this._startup = false;
for (let [instanceId, instance] of this.assignedInstances) {
if (instance.config.get("instance.auto_start")) {
for (let [instanceId, instanceInfo] of this.instanceInfos) {
if (instanceInfo.config.get("instance.auto_start")) {
if (this.recoveryMode) {
logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`);
logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`);
continue;
}
try {
@@ -1176,8 +1167,8 @@ export default class Host extends lib.Link {
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
} catch (err: any) {
logger.error(
`Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instance)
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
this.instanceLogMeta(instanceId, instanceInfo)
);
}
}
@@ -1225,7 +1216,7 @@ export default class Host extends lib.Link {
await this.config.save();
// Save host side in case host_assigned_game_port changed in the config.
await Promise.all(
[...this.assignedInstances.values()].map(instance => instance.config.save())
[...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save())
);
try {
+10 -24
View File
@@ -1,6 +1,7 @@
import fs from "fs-extra";
import path from "path";
import pidusage from "pidusage";
import phin from "phin";
import util from "util";
import type { Static } from "@sinclair/typebox";
import { exec } from "child_process";
@@ -637,12 +638,9 @@ end`.replace(/\r?\n/g, " ");
}
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
// After changing the status this function is responsible for calling notifyExit if it throws.
this.notifyStatus("starting");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
this.notifyExit();
@@ -845,7 +843,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
this.logger.verbose("Writing server-adminlist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._host.adminlist], null, "\t")
);
@@ -853,7 +851,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_banlist") !== "disabled") {
this.logger.verbose("Writing server-banlist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._host.banlist].map(
([username, reason]) => ({ username, reason })
@@ -863,21 +861,10 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
this.logger.verbose("Writing server-whitelist.json");
await lib.safeOutputFile(
lib.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._host.whitelist], null, "\t")
);
} else {
// Always create the whitelist even if unused
// This allows it to be watched for changes without a server restart
try {
// wx will throw an error if the file already exists to prevent overwrite
await fs.writeFile(this.server.writePath("server-whitelist.json"), "\n[]\n", { flag: "wx" });
} catch (err: any) {
if (err.code !== "EEXIST") {
throw err;
}
}
}
await this.syncMods();
@@ -1303,18 +1290,17 @@ end`.replace(/\r?\n/g, " ");
let url = new URL(this._host.config.get("host.controller_url"));
url.pathname += "api/upload-export";
url.searchParams.set("mod_pack_id", String(this.activeModPack.id));
let response = await fetch(url, {
method: "PUT",
body: content,
duplex: "half",
let response = await phin({
url, method: "PUT",
data: content,
core: { ca: this._host.tlsCa } as object,
headers: {
"Content-Type": "application/zip",
"x-access-token": this._host.config.get("host.controller_token"),
},
});
if (response.status !== 200) {
const body = Buffer.from(await response.arrayBuffer());
throw Error(`Upload failed: ${response.status} ${response.statusText}: ${body.toString()}`);
if (response.statusCode !== 200) {
throw Error(`Upload failed: ${response.statusCode} ${response.statusMessage}: ${response.body}`);
}
} finally {
+53 -155
View File
@@ -98,8 +98,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
server._logger.warn(`Skipping icon with bad mod path: ${JSON.stringify(modPath)}`);
return null;
throw new Error(`Bad mod path ${match}`);
}
let [, mod, filePath] = match;
@@ -152,6 +151,7 @@ async function loadIcon(
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
@@ -258,14 +258,6 @@ function fixIcons(item: ItemPrototype) {
return item;
}
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
if (item.icons) {
return (item as LayeredIconSpecification).icons[0]?.icon;
}
return (item as SimpleIconSpecification).icon;
}
const itemTypes = new Set([
"item",
"ammo",
@@ -288,153 +280,87 @@ const itemTypes = new Set([
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
// TODO consider adding virtual signals and recipes
]);
const recipeTypes = new Set(["recipe"]);
const signalTypes = new Set(["virtual-signal"]);
const technologyTypes = new Set(["technology"]);
const planetTypes = new Set(["planet", "space-location"]);
const qualityTypes = new Set(["quality"]);
// Entity types to export. Entities whose name matches an already-exported
const entityTypes = new Set([
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
"cargo-landing-pad", "asteroid",
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
"unit", "unit-spawner", "spider-unit", "segmented-unit",
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
"simple-entity-with-owner", "simple-entity-with-force",
"arithmetic-combinator", "decider-combinator", "constant-combinator",
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
"character",
"offshore-pump", "valve", "lightning-attractor",
"fusion-generator", "fusion-reactor", "burner-generator",
"agricultural-tower", "market", "lane-splitter",
]);
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
function filterItems(prototypes: Prototypes): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type]) => allowedTypes.has(type))
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons);
}
/** Shared state for building a unified spritesheet across all categories. */
interface SheetState {
sheet: Jimp;
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
iconCache: IconCache;
simpleIcons: Map<string, number>;
pos: number;
}
const SHEET_ICON_SIZE = 32;
const SHEET_WIDTH = 1024;
async function createSheetState(): Promise<SheetState> {
return {
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
metadata: new Map(),
iconCache: new Map(),
simpleIcons: new Map(),
pos: 0,
};
}
/** Grow the spritesheet vertically if needed to fit another row. */
async function growSheet(state: SheetState) {
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
if (neededHeight > state.sheet.bitmap.height) {
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
extended.composite(state.sheet, 0, 0);
(state.sheet as any).bitmap = (extended as any).bitmap;
}
.map(fixIcons)
;
}
/**
* Export item icons for a single category onto the shared spritesheet.
* Export item icons and data
*
* @param server - The server to load icons from.
* @param modVersions - Mapping of mod name to version.
* @param items - Array of item prototypes to export.
* @param category - Category name for metadata.
* @param state - Shared spritesheet state (mutated in place).
* @returns Number of icons exported for this category.
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param server -
* The server to generate the export mod for.
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param items - Array of item prototypes.
* @return Item spritesheet and metadata.
* @internal
*/
async function exportItems(
server: FactorioServer,
modVersions: Map<string, string>,
items: ItemPrototype[],
category: string,
state: SheetState,
) {
let count = 0;
for (let item of items) {
// Skip prototypes with no icon data at all
if (!item.icons && !item.icon) {
continue;
}
async function exportItems(server: FactorioServer, modVersions: Map<string, string>, items: ItemPrototype[]) {
// Size to render icons at
let size = 32;
// Width of spritesheet
let width = 1024;
let rows = Math.ceil(items.length / (width / size));
let iconSheet = await Jimp.create(width, rows * size);
let itemData = new Map();
let pos = 0;
let iconCache: IconCache = new Map();
let simpleIcons = new Map();
for (let item of items) {
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache);
iconPos = pos;
} else {
iconPos = state.simpleIcons.get(item.icon as string);
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(item.icon as string, state.pos);
iconPos = pos;
simpleIcons.set(item.icon, pos);
}
}
}
if (iconPos !== undefined) {
const iconPath = getPrimaryIconPath(item);
state.metadata.set(`${category}.${item.name}`, {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
category,
...(iconPath ? { path: iconPath } : {}),
itemData.set(item.name, {
x: iconPos * size % width,
y: Math.floor(iconPos / (width / size)) * size,
size,
localised_name: item.localised_name,
localised_description: item.localised_description,
});
}
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
count += 1;
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
pos += 1;
}
}
return count;
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
return { iconSheet, itemData };
}
/**
@@ -579,16 +505,7 @@ export async function exportData(server: FactorioServer) {
}
}
const categories: { name: string, types: Set<string> }[] = [
{ name: "item", types: itemTypes },
{ name: "recipe", types: recipeTypes },
{ name: "signal", types: signalTypes },
{ name: "technology", types: technologyTypes },
{ name: "planet", types: planetTypes },
{ name: "quality", types: qualityTypes },
{ name: "entity", types: entityTypes },
];
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
@@ -597,28 +514,9 @@ export async function exportData(server: FactorioServer) {
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
// Build a single unified spritesheet across all prototype categories.
const state = await createSheetState();
for (const category of categories) {
const categoryItems = filterPrototypes(prototypes, category.types);
if (categoryItems.length === 0) {
continue;
}
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
server._logger.info(`Exported ${count} ${category.name} icons`);
}
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
return zip;
}
+40 -237
View File
@@ -2,7 +2,6 @@
import fs from "fs-extra";
import child_process from "child_process";
import path from "path";
import JSZip from "jszip";
import events from "events";
import util from "util";
import crypto from "crypto";
@@ -10,33 +9,31 @@ import { Rcon } from "rcon-client";
import * as lib from "@clusterio/lib";
const execFileAsync = util.promisify(child_process.execFile);
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
*
* @param factorioDir - Path factorio dir containing data/changelog.txt.
* @param changelogPath - Path to changelog.txt.
* @internal
*/
async function getFactorioVersion(factorioDir: string) {
let changelog: string;
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
async function getVersion(changelogPath: string) {
let changelog;
try {
changelog = await fs.readFile(changelogPath, "utf-8");
} catch (err: any) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
for (const line of changelog.split(/[\r\n]+/)) {
const index = line.indexOf(":");
for (let line of changelog.split(/[\r\n]+/)) {
let index = line.indexOf(":");
if (index !== -1) {
const name = line.slice(0, index).trim();
let name = line.slice(0, index).trim();
if (name.toLowerCase() === "version") {
const version = line.slice(index + 1).trim();
return lib.isFullVersion(version) ? version : null;
return line.slice(index + 1).trim();
}
}
}
@@ -69,169 +66,13 @@ function versionOrder(a: string, b: string) {
return 0;
}
/**
* Find all installed factorio versions and return them as a set
*
* @param factorioDir - Path to Factorio installation dirs.
* @returns Set of installed factorio versions
*/
async function listFactorioVersions(factorioDir: string) {
const versions = new Set<lib.FullVersion>();
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
versions.add(directVersion);
return { direct: true, versions };
}
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
continue;
}
versions.add(version);
}
return { direct: false, versions };
}
/**
* Downloads a ZIP file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractZip(url: string, targetDir: string) {
const res = await fetchDownload(url);
// Load the ZIP with JSZip
const buffer = Buffer.from(await res.arrayBuffer());
const zip = await JSZip.loadAsync(buffer);
const tmpTargetDir = `${targetDir}.tmp-extract`;
const stripRootDir = hasSingleArchiveRoot(Object.values(zip.files));
await fs.remove(tmpTargetDir);
await fs.ensureDir(tmpTargetDir);
try {
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedParts = stripRootDir ? parts.slice(1) : parts;
if (!strippedParts.length) {
return;
}
const entryPath = validatedExtractPath(tmpTargetDir, strippedParts);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
await fs.remove(targetDir);
await fs.rename(tmpTargetDir, targetDir);
} catch (err) {
await fs.remove(tmpTargetDir);
throw err;
}
}
/**
* Downloads a tar.gz file from a URL using native fetch and extracts it
*
* @param url - The URL of the ZIP file
* @param targetDir - Directory to extract the ZIP into
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractTar(url: string, targetDir: string) {
const res = await fetchDownload(url);
// Write the buffer to a temp file
const tmpFilePath = `${targetDir}.tmp`;
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(tmpFilePath, buffer);
await fs.ensureDir(targetDir);
// Execute tar CLI to extract
try {
await execFileAsync("tar", [
"-xf", tarPath(tmpFilePath), "-C", tarPath(targetDir), "--strip-components", "1",
]);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error("error executing tar command, do you have 'xz-utils' installed?");
}
throw err;
} finally {
// Clean up temp file
await fs.remove(tmpFilePath);
}
}
async function fetchDownload(url: string) {
const res = await fetch(url);
if (res.status === 202) {
let detail = "";
try {
detail = await res.text();
} catch {
}
throw new Error(`Download is not ready yet for ${url}${detail ? `: ${detail}` : ""}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
return res;
}
function tarPath(filePath: string) {
if (process.platform === "win32") {
return filePath.replace(/\\/g, "/");
}
return filePath;
}
function hasSingleArchiveRoot(entries: JSZip.JSZipObject[]) {
const rootNames = new Set(
entries
.map(entry => entry.name.split("/").filter(Boolean)[0])
.filter((value): value is string => Boolean(value))
);
return rootNames.size === 1;
}
function validatedExtractPath(rootDir: string, parts: string[]) {
const entryPath = path.resolve(rootDir, ...parts);
const relative = path.relative(rootDir, entryPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to extract unsafe ZIP entry ${parts.join("/")}`);
}
return entryPath;
}
function getHeadlessDownload(version: lib.ExternalFactorioVersion) {
if (process.platform === "linux") {
return { url: version.headlessUrl, extract: downloadAndExtractTar };
}
if (process.platform === "win32" && version.windowsHeadlessUrl) {
return { url: version.windowsHeadlessUrl, extract: downloadAndExtractZip };
}
return null;
}
/**
* Find factorio data directory of the given version
*
* Searches the given factorio dir for an installation of Factorio of
* the target version.
*
* @param factorioDir - Path to Factorio installation dirs.
* @param factorioDir - Path to Factorio installation dir(s).
* @param targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
@@ -239,30 +80,40 @@ function getHeadlessDownload(version: lib.ExternalFactorioVersion) {
* @internal
*/
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
// Check if this is a direct installation dir
const directVersion = await getFactorioVersion(factorioDir);
if (directVersion !== null) {
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
// multiple install directories
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") {
if (lib.isFullVersion(simpleVersion)) {
return [path.join(factorioDir, "data"), simpleVersion];
}
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
throw new Error(
`Factorio version ${targetVersion} was requested, but install directory contains ${simpleVersion}`
);
}
// Otherwise assume it is a directory of multiple versions
const versions = new Map<lib.FullVersion, string>();
for (const name of await fs.readdir(factorioDir)) {
// Avoid any fancy directory detection in order to follow symlinks
const fullPath = path.join(factorioDir, name);
const version = await getFactorioVersion(fullPath);
if (version === null) {
let versions = new Map<lib.FullVersion, string>();
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version === null || !lib.isFullVersion(version)) {
continue;
}
if (version === targetVersion) {
return [path.join(factorioDir, name, "data"), version];
return [path.join(factorioDir, entry.name, "data"), version];
}
versions.set(version, name);
versions.set(version, entry.name);
}
if (!versions.size) {
@@ -805,7 +656,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
;
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content: unknown;
let content;
if (type === "j") {
try {
content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8"));
@@ -943,7 +794,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
try {
this._whitelistWatcher = fs.watch(filePath);
} catch (err: any) {
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
this._logger.error(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
}
@@ -961,7 +812,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
return;
}
let newWhitelistJson: unknown;
let newWhitelistJson;
try {
newWhitelistJson = await fs.readJSON(filePath);
} catch (err: any) {
@@ -1020,51 +871,6 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this._state = "init";
}
/**
* Check of updated versions to download
*
* Should be called before init for the new version to be detected during init
*/
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
const targetVersion = this._targetVersion;
const latestVersion = availableVersions
.filter(v => (
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
))
.sort((v1, v2) => versionOrder(v1.version, v2.version))
[0];
if (!latestVersion) {
return;
}
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
const download = getHeadlessDownload(latestVersion);
if (installedVersions.direct || !download) {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
return;
}
// Download the newer version
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
const dst = path.join(this._factorioDir, latestVersion.version);
try {
await download.extract(download.url, dst);
this._logger.info(`Downloaded factorio version ${v}`);
} catch (err: unknown) {
if (installedVersions.versions.size > 0) {
this._logger.warn(
`Failed to download factorio version ${v}, continuing with installed versions: ${err}`
);
return;
}
throw err;
}
}
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the constructor.
@@ -1117,7 +923,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
} else if (this._unexpected.length === 0) {
let msg: string;
let msg;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
@@ -1615,12 +1421,9 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// For testing only
export const _getFactorioVersion = getFactorioVersion;
export const _getVersion = getVersion;
export const _versionOrder = versionOrder;
export const _findVersion = findVersion;
export const _listFactorioVersions = listFactorioVersions;
export const _downloadAndExtractZip = downloadAndExtractZip;
export const _downloadAndExtractTar = downloadAndExtractTar;
export const _randomDynamicPort = randomDynamicPort;
export const _generatePassword = generatePassword;
export const _parseOutput = parseOutput;
-3
View File
@@ -16,6 +16,3 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(import.meta.url);
-3
View File
@@ -38,6 +38,3 @@ export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(__filename);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22b",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -1,21 +0,0 @@
import { InstallationError } from "./errors";
declare global {
// eslint-disable-next-line vars-on-top
var _clusterioLibImportedFrom: string;
}
export function checkSingletonImport(location: string) {
if (!global._clusterioLibImportedFrom) {
global._clusterioLibImportedFrom = location;
} else {
throw new InstallationError(`Attempt to import duplicate copy of @clusterio/lib
Importing more than one copy of @clusterio/lib into the same runtime
will break Clusterio. This typically happens when the package
manager has installed two different versions of @clusterio/lib
and assigned different ones to different components of Clusterio.
Previously imported @clusterio/lib was located at ${_clusterioLibImportedFrom}
This copy of @clusterio/lib is located at ${location}`);
}
}
+13
View File
@@ -259,6 +259,7 @@ export interface HostConfigFields {
"host.instances_directory": string;
"host.controller_url": string;
"host.controller_token": string;
"host.tls_ca": string | null;
"host.public_address": string;
"host.factorio_port_range": string;
"host.factorio_username": string | null,
@@ -326,6 +327,12 @@ export class HostConfig extends classes.Config<HostConfigFields> {
type: "string",
initialValue: "enter token here",
},
"host.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
restartRequired: true,
type: "string",
optional: true,
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
type: "string",
@@ -617,6 +624,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
export interface ControlConfigFields {
"control.controller_url": string | null;
"control.controller_token": string | null;
"control.tls_ca": string | null;
"control.max_reconnect_delay": number;
}
@@ -639,6 +647,11 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
type: "string",
optional: true,
},
"control.tls_ca": {
description: "Path to Certificate Authority to validate TLS connection to controller against.",
type: "string",
optional: true,
},
"control.max_reconnect_delay": {
title: "Max Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
+5 -10
View File
@@ -11,26 +11,21 @@ export default class ExportManifest {
/**
* Mapping between known assets and their file names.
*
* Currently exported assets are:
* Currently exported assets are
* - settings: JSON of mod settings prototypes.
* - prototypes: JSON of all game prototypes except settings.
* - item-spritesheet: PNG containing icons from the game.
* - item-metadata: JSON of Map entries describing icons in item-spritesheet
* - locale: JSON of flattened Map entries of en locale strings from the game.
* - spritesheet: Single PNG spritesheet containing all icon categories.
* - metadata: JSON array of [name, {x, y, size, category, path?}] entries
* describing sprite coordinates and category membership. Categories:
* item, recipe, signal, technology, planet, quality, entity, static.
*/
public assets: Record<string, string>,
/** ISO 8601 timestamp of when the export was uploaded. */
public exportedAt?: string,
public assets: Record<string, string>
) { }
static jsonSchema = Type.Object({
"assets": Type.Record(Type.String(), Type.String()),
"exportedAt": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof ExportManifest.jsonSchema>) {
return new this(json.assets, json.exportedAt);
return new this(json.assets);
}
}
-77
View File
@@ -1,77 +0,0 @@
import { Type, Static } from "@sinclair/typebox";
export default class HostDetails {
constructor(
/** Version of the host software. The controller may warn if there is a mismatch. */
public version: string,
/** Human-readable display name of the host. */
public name: string,
/** Unique numeric identifier for this host. */
public id: number,
/** Whether the host is currently connected to the controller. */
public connected: boolean,
/** Address from which the host has connected to the controller. */
public remoteAddress = "",
/** Public-facing IP address of the host, if available. */
public publicAddress: string = "",
/** Unix timestamp (sec) after which issued tokens are considered valid. */
public tokenValidAfter: number = 0,
/** Unix timestamp (ms) indicating when this record was last updated. */
public updatedAtMs = 0,
/** Flag indicating the host record has been removed on the controller. */
public isDeleted = false
) {}
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted
);
}
toJSON() {
const json = {
version: this.version,
name: this.name,
id: this.id,
connected: this.connected,
} as Static<typeof HostDetails.jsonSchema>;
if (this.remoteAddress !== "") {
json.remoteAddress = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.publicAddress = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.tokenValidAfter = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.isDeleted = this.isDeleted;
}
return json;
}
}
-108
View File
@@ -1,108 +0,0 @@
import { Static, Type } from "@sinclair/typebox";
import { StringEnum } from "./composites";
import { TargetVersion, TargetVersionSchema } from "./version";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export default class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: TargetVersion | undefined,
public startedAtMs = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false
) {}
get isDeleted() {
return this.status === "deleted";
}
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(TargetVersionSchema),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
json.excludeFromStartAll
);
}
toJSON() {
const json = {
name: this.name,
id: this.id,
status: this.status,
} as Static<typeof InstanceDetails.jsonSchema>;
if (this.assignedHost !== undefined) {
json.assignedHost = this.assignedHost;
}
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
if (this.excludeFromStartAll !== false) {
json.excludeFromStartAll = this.excludeFromStartAll;
}
return json;
}
}
+42 -26
View File
@@ -22,12 +22,11 @@ const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
};
const depSpecRegex = /^(?:(\?|\(\?\)|!|~|\+) *)?(.+?)(?: *([<>]=?|=) *([0-9.]+))?$/;
const lettersRegex = /^[a-zA-Z]+$/;
export class ModDependency {
public spec: string;
public name: string;
public type: ModDependencyType;
public name: string;
public version: ModVersionEquality | undefined;
static getTypeFromPrefix(prefix: string): ModDependencyType {
@@ -43,27 +42,50 @@ export class ModDependency {
case "":
return "required";
default:
throw new Error(`Invalid dependency prefix "${prefix}"`);
throw new Error(`Invalid dependency prefix: ${prefix}`);
}
}
constructor (
specification: string,
public specification: string,
) {
const match = depSpecRegex.exec(specification);
if (match === null) {
throw new Error(`Invalid dependency specification "${specification}"`);
const parts = specification.split(" ");
// Fix mods which include spaces in their name, joins consecutive words together
let index = 0;
while (index < parts.length - 1) {
const current = parts[index];
const next = parts[index + 1];
if (lettersRegex.test(current) && lettersRegex.test(next)) {
parts[index] = `${current} ${next}`;
parts.splice(index + 1, 1);
} else {
index += 1;
}
}
// Expand the match to the different parts
const [spec, type, name, equality, version] = match;
this.type = ModDependency.getTypeFromPrefix(type ?? "");
this.spec = spec;
this.name = name;
// Parse the equality and version
if ((equality || version) && !this.incompatible) {
this.version = ModVersionEquality.fromParts(equality, version);
// Parse the dependency specification
switch (parts.length) {
case 1:
this.type = "required";
this.name = parts[0];
break;
case 2:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
break;
case 3:
this.type = "required";
this.name = parts[0];
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
break;
case 4:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
break;
default:
throw new Error(`Invalid dependency specification: ${specification}`);
}
}
@@ -102,7 +124,7 @@ export class ModDependency {
}
toJSON() {
return this.spec;
return this.specification;
}
}
@@ -196,7 +218,7 @@ export default class ModInfo {
* As they would be represented in info.json.
*/
get dependencySpecifications() {
return this.dependencies.map(d => d.spec);
return this.dependencies.map(d => d.specification);
}
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
@@ -286,13 +308,7 @@ export default class ModInfo {
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
// Parse the dependencies
try {
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
} catch (err: any) {
throw new Error(`Failed for parse dependencies for ${json.name}: ${err}`);
}
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
// Additional data
if (json.size) { modInfo.size = json.size; }
+1 -27
View File
@@ -1,23 +1,15 @@
import { Static, Type } from "@sinclair/typebox";
import { permissions as globalPermissions } from "../permissions";
interface RoleCollection {
get(id: number): Role;
set(role: Role): void;
}
/**
* Represents a collection of granted permissions
*/
export default class Role {
static DefaultAdminRoleId = 0 as const;
static DefaultPlayerRoleId = 1 as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions = new Set<string>(),
public permissions: Set<string>,
public updatedAtMs = 0,
public isDeleted = false,
) { }
@@ -53,13 +45,6 @@ export default class Role {
};
}
static ensureDefaultPlayerRole(roles: RoleCollection) {
const playerRole = roles.get(this.DefaultPlayerRoleId)
?? new this(this.DefaultPlayerRoleId, "Player", "Default player role.");
playerRole.grantDefaultPermissions();
roles.set(playerRole);
}
grantDefaultPermissions() {
for (let permission of globalPermissions.values()) {
if (permission.grantByDefault) {
@@ -67,15 +52,4 @@ export default class Role {
}
}
}
static ensureDefaultAdminRole(roles: RoleCollection) {
const adminRole = roles.get(this.DefaultAdminRoleId)
?? new this(this.DefaultAdminRoleId, "Cluster Admin", "Cluster wide administrator.");
adminRole.grantAdminPermissions();
roles.set(adminRole);
}
grantAdminPermissions() {
this.permissions.add("core.admin");
}
}
@@ -2,10 +2,12 @@ import { Static, Type } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
/** Represents a user in the cluster */
export default class UserDetails {
public playerStats: PlayerStats;
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
*/
export default class User {
constructor(
/** Factorio user name. */
public name: string,
@@ -25,15 +27,11 @@ export default class UserDetails {
public updatedAtMs = 0,
/** True if this user object has been removed from the cluster. */
public isDeleted = false,
/** Combined statistics for the player this user account is tied to. */
public playerStats = new PlayerStats(),
/** Per instance statistics for the player this user account is tied to. */
public instanceStats = new Map<number, PlayerStats>(),
) {
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
static jsonSchema = Type.Object({
@@ -53,13 +51,17 @@ export default class UserDetails {
),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
static fromJSON(json: Static<typeof this.jsonSchema>, _hack?: any) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(([id, stats]) => [id, PlayerStats.fromJSON(stats)])
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
json.name,
new Set<number>(json.roles),
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
@@ -67,12 +69,13 @@ export default class UserDetails {
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON() {
let json: Static<typeof UserDetails.jsonSchema> = {
let json: Static<typeof User.jsonSchema> = {
name: this.name,
};
@@ -116,7 +119,7 @@ export default class UserDetails {
}
recalculatePlayerStats() {
this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats);
this.playerStats = User._calculatePlayerStats(this.instanceStats);
}
static _calculatePlayerStats(instanceStatsMap: Map<number, PlayerStats>) {
@@ -146,13 +149,44 @@ export default class UserDetails {
}
return playerStats;
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: User) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isBanned = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
this.recalculatePlayerStats();
}
/** Name of the user, used by event subscriptions and to ensure case insensitivity */
get id() {
return this.name.toLowerCase();
}
}
/**
* This interface is implemented by User on the controller.
* It is exposed here to allow message definitions to create permission methods.
*/
export interface IUser extends UserDetails {
export interface IControllerUser extends User {
/**
* Check if a given permission is granted
*
+2 -4
View File
@@ -12,10 +12,8 @@ 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 InstanceDetails, InstanceStatus } from "./InstanceDetails";
export { default as HostDetails } from "./HostDetails";
export { default as UserDetails } from "./UserDetails";
export type { IUser } from "./UserDetails";
export { default as User } from "./User";
export type { IControllerUser } from "./User";
export * from "./composites";
export * from "./messages_core";
export * from "./messages_controller";
+1 -7
View File
@@ -260,8 +260,6 @@ export class SystemInfo {
public diskAvailable: number,
public canRestart: boolean,
public restartRequired: boolean,
public systemStartedAtMs: number,
public processStartedAtMs: number,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs: number,
public isDeleted: boolean,
@@ -281,8 +279,6 @@ export class SystemInfo {
"diskAvailable": Type.Number(),
"canRestart": Type.Boolean(),
"restartRequired": Type.Boolean(),
"systemStartedAtMs": Type.Number(),
"processStartedAtMs": Type.Number(),
"updatedAtMs": Type.Number(),
"isDeleted": Type.Boolean(),
});
@@ -302,8 +298,6 @@ export class SystemInfo {
json.diskAvailable,
json.canRestart,
json.restartRequired,
json.systemStartedAtMs,
json.processStartedAtMs,
json.updatedAtMs,
json.isDeleted,
);
@@ -402,7 +396,7 @@ export class DebugWsMessageEvent {
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" as const;
+41 -1
View File
@@ -2,7 +2,6 @@ import { Type, Static } from "@sinclair/typebox";
import { jsonArray, plainJson, StringEnum } from "./composites";
import { CollectorResultSerialized } from "../prometheus";
import { HostConfig } from "../config/definitions";
import HostDetails from "./HostDetails";
export class HostStopRequest {
declare ["constructor"]: typeof HostStopRequest;
@@ -83,6 +82,47 @@ export class HostConfigSetPropRequest {
}
}
export class HostDetails {
constructor(
public version: string,
public name: string,
public id: number,
public connected: boolean,
public remoteAddress = "",
public publicAddress: string = "",
public tokenValidAfter: number = 0,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public isDeleted = false,
) { }
static jsonSchema = Type.Object({
"version": Type.String(),
"name": Type.String(),
"id": Type.Integer(),
"connected": Type.Boolean(),
"remoteAddress": Type.Optional(Type.String()),
"publicAddress": Type.Optional(Type.String()),
"tokenValidAfter": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"isDeleted": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.version,
json.name,
json.id,
json.connected,
json.remoteAddress,
json.publicAddress,
json.tokenValidAfter,
json.updatedAtMs,
json.isDeleted,
);
}
}
export class HostListRequest {
declare ["constructor"]: typeof HostListRequest;
static type = "request" as const;
+76 -3
View File
@@ -2,11 +2,84 @@ import { Type, Static } from "@sinclair/typebox";
import PlayerStats from "./PlayerStats";
import { JsonString, StringEnum, jsonArray, plainJson } from "./composites";
import { InstanceConfig } from "../config";
import type { IUser } from "./UserDetails";
import type { IControllerUser } from "./User";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
import InstanceDetails, { InstanceStatus } from "./InstanceDetails";
export const InstanceStatus = StringEnum([
"unknown",
"unassigned",
"stopped",
"starting",
"running",
"stopping",
"creating_save",
"exporting_data",
"deleted",
]);
/**
* Current status of the instance. One of:
* - `unknown`: Instance is assigned to a host but this host is currently
* not connected to the contreller.
* - `unassigned`: Instance is not assigned to a a host and exists only on
* the controller.
* - `stopped`: Instance is stopped.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance is running normally.
* - `stopping`: Instance is in the process of stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting game data.
* - `deleted`: Instance has been deleted.
*/
export type InstanceStatus = Static<typeof InstanceStatus>;
export class InstanceDetails {
constructor(
public name: string,
public id: number,
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: TargetVersion | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"id": Type.Integer(),
"assignedHost": Type.Optional(Type.Integer()),
"gamePort": Type.Optional(Type.Integer()),
"status": StringEnum([
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(TargetVersionSchema),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name,
json.id,
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.updatedAtMs,
json.excludeFromStartAll,
);
}
get isDeleted() {
return this.status === "deleted";
}
}
export class InstanceDetailsGetRequest {
declare ["constructor"]: typeof InstanceDetailsGetRequest;
@@ -463,7 +536,7 @@ export class InstanceTransferSaveRequest {
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = ["controller", "host"] as const;
static permission(user: IUser, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
user.checkPermission("core.instance.save.transfer");
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as { copy: boolean, sourceName: string, targetName: string };
+5 -17
View File
@@ -403,10 +403,6 @@ export class ModPortalDownloadRequest {
}
}
const ModDependencyResolveErrors = ["incompatible", "unsatisfiable", "invalidDependency", "notFound"] as const;
export const ModDependencyResolveErrorsSchema = Type.Union(ModDependencyResolveErrors.map(v => Type.Literal(v)));
export type ModDependencyResolveErrors = Static<typeof ModDependencyResolveErrorsSchema>;
/**
* Request mods dependencies to be resolved using the Factorio mod portal.
*
@@ -460,26 +456,18 @@ export class ModDependencyResolveRequest {
static Response = class ModDependencyResolveResponse {
constructor(
public dependencies: ModInfo[],
public errors: Map<string, ModDependencyResolveErrors>,
public incompatible: string[],
public missing: string[],
) {}
static jsonSchema = Type.Object({
"dependencies": Type.Array(ModInfo.jsonSchema),
"errors": Type.Record(Type.String(), ModDependencyResolveErrorsSchema),
"incompatible": Type.Array(Type.String()),
"missing": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.dependencies.map(d => ModInfo.fromJSON(d)),
new Map(Object.entries(json.errors))
);
}
toJSON(): Static<typeof ModDependencyResolveResponse.jsonSchema> {
return {
dependencies: this.dependencies.map(d => d.toJSON()),
errors: Object.fromEntries(this.errors),
};
return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
}
};
}
+7 -7
View File
@@ -1,5 +1,5 @@
import { Type, Static } from "@sinclair/typebox";
import UserDetails, { IUser } from "./UserDetails";
import User, { IControllerUser } from "./User";
import { StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
@@ -22,7 +22,7 @@ export class UserGetRequest {
return new this(json.name);
}
static Response = UserDetails;
static Response = User;
}
export class UserListRequest {
@@ -31,7 +31,7 @@ export class UserListRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.user.list" as const;
static Response = jsonArray(UserDetails);
static Response = jsonArray(User);
}
export class UserCreateRequest {
@@ -198,15 +198,15 @@ export class UserUpdatesEvent {
static permission = "core.user.subscribe" as const;
constructor(
public updates: UserDetails[],
public updates: User[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(UserDetails.jsonSchema),
"updates": Type.Array(User.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => UserDetails.fromJSON(update)));
return new this(json.updates.map(update => User.fromJSON(update)));
}
}
@@ -250,7 +250,7 @@ export class UserBulkImportRequest {
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
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
+2 -2
View File
@@ -151,10 +151,10 @@ export class ModVersionEquality {
static fromParts(equality: string, version: string) {
if (!isVersionEquality(equality)) {
throw new Error(`Invalid version equality "${equality}"`);
throw new Error(`Unknown version equality: ${equality}`);
}
if (!isPartialVersion(version)) {
throw new Error(`Invalid version string "${version}"`);
throw new Error(`Malformed version string: ${version}`);
}
return new this(equality, version);
}
+13 -77
View File
@@ -53,7 +53,7 @@ export class JsonDatastoreProvider<
private filePath: string,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
private finalise: (obj: V, map: Map<string, V>) => V | undefined = v => v,
private finalise: (obj: V) => V = v => v,
) {
super();
}
@@ -82,24 +82,7 @@ export class JsonDatastoreProvider<
const serialized = this.migrations(rawJson);
// Convert to data class objects
let serializedCount = 0;
const result = new Map<string, V>();
for (const [key, raw] of Object.entries(serialized)) {
const final = this.finalise(this.fromJson(raw), result);
if (final !== undefined) { result.set(key, final); }
serializedCount += 1;
}
// Save a backup if the map does not contain all values (happens where there are duplicates)
if (result.size < serializedCount) {
const backupPath = `${this.filePath}.${Date.now()}.bak`;
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
return new Map(Object.entries(serialized).map(([k, v]) => [k, this.finalise(this.fromJson(v))]));
}
}
@@ -112,7 +95,7 @@ export class JsonIdDatastoreProvider<
private filePath: string,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
private finalise: (obj: V, map: Map<V["id"], V>) => V | undefined = v => v,
private finalise: (obj: V) => V = v => v,
) {
super();
}
@@ -141,24 +124,10 @@ export class JsonIdDatastoreProvider<
const serialized = this.migrations(rawJson);
// Convert to data class objects
let serializedCount = 0;
const result = new Map<V["id"], V>();
for (const raw of serialized) {
const final = this.finalise(this.fromJson(raw), result);
if (final !== undefined) { result.set(final.id, final); }
serializedCount += 1;
}
// Save a backup if the map does not contain all values (happens where there are duplicates)
if (result.size < serializedCount) {
const backupPath = `${this.filePath}.${Date.now()}.bak`;
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
return new Map(serialized.map((e) => {
const v = this.finalise(this.fromJson(e));
return [v.id, v];
}));
}
}
@@ -290,50 +259,18 @@ export class KeyValueDatastore<
export class SubscribableDatastore<
V extends SubscribableValue,
> extends Datastore<V["id"], V> {
/** Last time an update occurred */
lastUpdatedAtMs: number = 0;
private static maxUpdatedAtMs(values: Iterable<SubscribableValue>) {
let max = 0;
for (const value of values) {
// Prevent one NaN, undefined, +Inf or similar from breaking the datastore.
if (Number.isFinite(value.updatedAtMs)) {
max = Math.max(max, value.updatedAtMs);
}
}
return max;
}
private incrementLastUpdatedMs() {
this.lastUpdatedAtMs = Math.max(Date.now(), this.lastUpdatedAtMs + 1);
}
constructor(
provider: DatastoreProvider<V["id"], V> = new MemoryDatastoreProvider(),
data = new Map<V["id"], V>(),
) {
super(provider, data);
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
}
async load() {
await super.load();
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
}
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
set(value: V) {
this.incrementLastUpdatedMs();
this.data.set(value.id, value);
value.updatedAtMs = this.lastUpdatedAtMs;
value.updatedAtMs = Date.now();
this.touch([value]);
}
// Set many values at once from an array of key value pairs
setMany(values: V[]) {
this.incrementLastUpdatedMs();
const nowMs = Date.now();
for (const value of values) {
value.updatedAtMs = this.lastUpdatedAtMs;
value.updatedAtMs = nowMs;
this.data.set(value.id, value);
}
this.touch(values);
@@ -341,19 +278,18 @@ export class SubscribableDatastore<
// Delete a value in the datastore
delete(value: V) {
this.incrementLastUpdatedMs();
this.data.delete(value.id);
value.updatedAtMs = this.lastUpdatedAtMs;
value.updatedAtMs = Date.now();
value.isDeleted = true;
this.touch([value]);
}
// Delete many values at once from an array of values
deleteMany(values: V[]) {
this.incrementLastUpdatedMs();
const nowMs = Date.now();
for (const value of values) {
value.isDeleted = true;
value.updatedAtMs = this.lastUpdatedAtMs;
value.updatedAtMs = nowMs;
this.data.delete(value.id);
}
this.touch(values);
-7
View File
@@ -10,13 +10,6 @@ export class CommandError extends Error {
code = "CommandError";
}
/**
* Thrown if the installation is broken, such as when multiple copies of lib is imported
*/
export class InstallationError extends Error {
code = "InstallationError";
}
/**
* Thrown from requests sent when an error occured handling it
*/
+7 -11
View File
@@ -1,8 +1,7 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive/";
const WINDOWS_HEADLESS_BASE_URL = "https://factorio-repackager.fly.dev/get-download";
const ARCHIVE_URL = "https://factorio.com/download/archive";
/**
* Represents the parsed result of a factorio version
@@ -11,10 +10,9 @@ export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
windowsHeadlessUrl: Type.Optional(Type.String()),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
/**
* Fetch all factorio versions that support headless
@@ -23,7 +21,7 @@ export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchem
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res: Response;
let res;
try {
res = await fetch(ARCHIVE_URL);
@@ -50,11 +48,11 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+\.\d+)"[^>]*>/g;
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
let match = anchorRegex.exec(html);
let match: RegExpExecArray | null;
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while (match !== null) {
while ((match = anchorRegex.exec(html)) !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
@@ -62,11 +60,9 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
versions.set(version, {
version: version,
stable: kind === "stable",
headlessUrl: `https://www.factorio.com/get-download/${version}/headless/linux64`,
windowsHeadlessUrl: `${WINDOWS_HEADLESS_BASE_URL}/${version}/headless/win64`,
headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`,
});
}
match = anchorRegex.exec(html);
}
return [...versions.values()];
+12 -1
View File
@@ -431,6 +431,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector<We
constructor(
protected _url: string,
maxReconnectDelay: number,
protected _tlsCa: string | undefined,
) {
super(undefined as any, new libData.Address(libData.Address.controller, 0));
this._backoff = new ExponentialBackoff({ max: maxReconnectDelay });
@@ -542,7 +543,17 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector<We
// Open WebSocket to controller
logger.verbose(`Connector | connecting to ${url}`);
this._socket = new WebSocket(url) as WebSocketClusterio;
if (process.env.APP_ENV === "browser") {
this._socket = new WebSocket(url) as WebSocketClusterio;
} else {
let options: { ca?: string } = {};
if (this._tlsCa) { options.ca = this._tlsCa; }
this._socket = new WebSocket(url, options) as WebSocketClusterio;
}
this._attachSocketHandlers();
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { BaseConnector, WebSocketBaseConnector } from "./connectors";
import { strict as assert } from "assert";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import type {
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser,
} from "../data";
export interface Request<Req, Res> {
@@ -18,7 +18,7 @@ export interface Request<Req, Res> {
type: "request";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: IUser, message: MessageRequest) => void);
permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void);
plugin?: string;
Response?: JSONDeserialisable<Res>,
}
@@ -32,7 +32,7 @@ export interface Event<T> {
type: "event";
src: AddressType | readonly AddressType[];
dst: AddressType | readonly AddressType[];
permission?: null | string | ((user: IUser, message: MessageEvent) => void);
permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void);
plugin?: string;
}
}
+51 -1
View File
@@ -6,7 +6,9 @@
* @author Hornwitser
* @module lib/users
*/
import { Permission } from "./data";
import { Permission, Role } from "./data";
import { SubscribableDatastore } from "./datastore";
export const permissions = new Map<string, Permission>();
@@ -56,6 +58,54 @@ export function definePermission({
permissions.set(name, new Permission(name, title, description, grantByDefault));
}
/**
* Ensure the default admin role has full access
*
* Ensures the role with id 0 exisits and has the core.admin permission.
*
* @param roles - role storage to modify.
* @returns the default admin role
*/
export function ensureDefaultAdminRole(roles: SubscribableDatastore<Role>) {
let admin = roles.get(0);
if (!admin) {
admin = Role.fromJSON({
id: 0,
name: "Cluster Admin",
description: "Cluster wide administrator.",
permissions: [],
});
}
admin.permissions.add("core.admin");
roles.set(admin);
return admin;
}
/**
* Ensure the default player role has default accesses
*
* Ensures the role with id 1 exists and has all the permissions that are
* assigned by default. Note that this may not be the same role as the one
* that is assigned to new users by default.
*
* @param roles - role storage to modify.
*/
export function ensureDefaultPlayerRole(roles: SubscribableDatastore<Role>) {
let player = roles.get(1);
if (!player) {
player = Role.fromJSON({
id: 1,
name: "Player",
description: "Default player role.",
permissions: [],
});
}
player.grantDefaultPermissions();
roles.set(player);
}
// Definitions for the built in permissions used in Clusterio.
// description should answer "this permission allows you to ___"
definePermission({
-3
View File
@@ -45,9 +45,6 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
pluginInfo = require(pluginPath).plugin;
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
} catch (err: any) {
if (err.code === "InstallationError") {
throw err;
}
throw new libErrors.PluginError(pluginName, err);
}
+6 -19
View File
@@ -272,31 +272,18 @@ function labelsToKey(labels: LabelValues, metricLabels: string[]) {
function keyToLabels(key: string) {
let labels = new Map<string, string>();
let pos = 0;
let matchLabel = /([^=]+)="(([^\\"]|\\["n\\])+)"/y;
while (pos < key.length) {
if (pos !== 0) {
/* istanbul ignore if */
if (key[pos] !== ",") {
// Should be unreachable
throw Error(`Expected , at pos ${pos} in "${key}"`);
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
if (!name || value === undefined) {
continue;
}
pos += 1;
}
matchLabel.lastIndex = pos;
const match = matchLabel.exec(key);
/* istanbul ignore else */
if (match) {
let [, name, value] = match;
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
);
pos += match[0].length;
} else {
// Should be unreachable
throw new Error(`Failed to parse label at pos ${pos} in "${key}"`);
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
import { Address, MessageRequest, IControllerUser, plainJson, JsonBoolean } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError, SessionLost } from "./errors";
import { logger } from "./logging";
@@ -18,7 +18,7 @@ export class SubscriptionRequest {
static type = "request" as const;
static src = ["control", "host", "instance"] as const;
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
@@ -195,7 +195,6 @@ export class SubscriptionController {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
// Unsubscribe logic
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
if (!subscriber) {
return false;
+2 -10
View File
@@ -134,12 +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,
restartRequired: boolean,
startedAtMs: number
) {
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);
@@ -157,7 +152,6 @@ export async function gatherSystemInfo(
diskAvailable = stats.bavail * stats.bsize;
}
const now = Date.now();
return new SystemInfo(
id,
os.hostname(),
@@ -172,9 +166,7 @@ export async function gatherSystemInfo(
diskAvailable,
canRestart,
restartRequired,
now - (os.uptime() * 1000),
startedAtMs,
now,
Date.now(),
false,
);
}
-1
View File
@@ -7,7 +7,6 @@
"./src/config/*.ts",
"./src/data/*.ts",
"./src/external/*.ts",
"./src/check_singleton_import.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/factorio/exchange_string.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "../../tsconfig.node.json",
"include": ["index.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"allowJs": true,
},
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.22b",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
+1 -1
View File
@@ -137,7 +137,7 @@ export default async function bootstrap() {
lib.addPluginConfigFields(pluginInfos);
let wsUrl = new URL(webRoot, document.location.href);
let controlConnector = new ControlConnector(wsUrl.href, 120);
let controlConnector = new ControlConnector(wsUrl.href, 120, undefined);
let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])));
control.plugins = await loadPlugins(pluginInfos, control);
control.inputComponents = inputComponentsFromPlugins(control.plugins);
@@ -8,7 +8,7 @@ import PluginExtra from "./PluginExtra";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
MetricDiskUsed, MetricDiskRatio,
} from "./system_metrics";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
@@ -101,12 +101,6 @@ export default function ControllerPage() {
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
@@ -1,17 +0,0 @@
import { Tag } from "antd";
import React from "react";
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
const statusOptions = {
connected: <Tag color="#389e0d" icon={<CheckCircleOutlined/>} >Connected</Tag>,
disconnected: <Tag color="#cf1322" icon={<CloseCircleOutlined/>} >Disconnected</Tag>,
};
type HostStatusTagProps = {
connected: boolean;
};
export default function HostStatusTag(props: HostStatusTagProps) {
return props.connected ? statusOptions.connected : statusOptions.disconnected;
}
+10 -12
View File
@@ -1,5 +1,5 @@
import React, { useContext, useState } from "react";
import { Descriptions, Spin, Typography, Button, Space, Popconfirm, Flex, Tooltip } from "antd";
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";
@@ -7,7 +7,6 @@ import * as lib from "@clusterio/lib";
import notify, { notifyErrorHandler } from "../util/notify";
import ControlContext from "./ControlContext";
import HostConfigTree from "./HostConfigTree";
import HostStatusTag from "./HostStatusTag";
import InstanceList from "./InstanceList";
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
import { useAccount } from "../model/account";
@@ -18,7 +17,7 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
MetricDiskUsed, MetricDiskRatio,
} from "./system_metrics";
import { formatTimestamp } from "../util/time_format";
import { useSystems } from "../model/system";
@@ -135,22 +134,21 @@ export default function HostViewPage() {
return <PageLayout nav={nav}>
<PageHeader
title={host.name || String(hostId)}
status={<HostStatusTag connected={host["connected"]}/>}
extra={hostButtons}
/>
<Descriptions bordered size="small" column={{ xs: 1, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="Name">{host["name"]}</Descriptions.Item>
<Descriptions.Item label="Connected">
<Tag color={host["connected"] ? "#389e0d" : "#cf1322"}>
{host["connected"] ? "Connected" : "Disconnected"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Version">{host.version}</Descriptions.Item>
<Descriptions.Item label="Node.js">{system?.node}</Descriptions.Item>
<Descriptions.Item label="OS Kernel">{system?.kernel}</Descriptions.Item>
<Descriptions.Item label="Machine">{system?.machine}</Descriptions.Item>
<Descriptions.Item label="System Uptime">
<MetricRelativeDate timeMs={system?.systemStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Process Uptime">
<MetricRelativeDate timeMs={system?.processStartedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Hostname">{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From">{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="Hostname" span={2}>{system?.hostname}</Descriptions.Item>
<Descriptions.Item label="Connected From" span={2}>{host.remoteAddress}</Descriptions.Item>
<Descriptions.Item label="CPU Model" span={2}>{system?.cpuModel}</Descriptions.Item>
<Descriptions.Item label="CPU Usage"><MetricCpuRatio system={system} /></Descriptions.Item>
<Descriptions.Item label="Cores"><MetricCpuUsed system={system} /></Descriptions.Item>
+16 -22
View File
@@ -1,6 +1,6 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
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";
@@ -11,7 +11,6 @@ import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import HostStatusTag from "./HostStatusTag";
import {
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
MetricDiskUsed, MetricDiskRatio,
@@ -57,7 +56,7 @@ function GenerateHostTokenButton() {
let newToken = await control.send(new lib.HostGenerateTokenRequest(id));
setToken(newToken);
setHostId(id ?? null);
setHostId(id??null);
}
// Generate a new random
@@ -90,24 +89,13 @@ function GenerateHostTokenButton() {
}} />
</Form.Item>
</Form>
<Typography.Paragraph>
Host IDs are randomly generated and unique, leave blank to use a new ID,
otherwise the ID of an existing host can be found using:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied get host id to clipboard"
text="npx clusteriohost config get host.id"
/>
npx clusteriohost config get host.id
</div>
{token !== null && <>
<Typography.Paragraph>
Host auth token:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied host token to clipboard"
message={"Copied auth token to clipboard"}
text={token}
/>
{token}
@@ -117,13 +105,15 @@ function GenerateHostTokenButton() {
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
message="Copied set host id to clipboard"
text={`npx clusteriohost config set host.id ${token}`}
message={"Copied configuration commands to clipboard"}
text={`npx clusteriohost config set host.controller_token ${token}
npx clusteriohost config set host.id ${hostId}`}
/>
npx clusteriohost config set host.id {token}
<p>npx clusteriohost config set host.controller_token &lt;token&gt;</p>
<p>npx clusteriohost config set host.id &lt;hostId&gt;</p>
</div>
<Typography.Paragraph>
A new host can be setup using the following commands:
Example host setup commands:
</Typography.Paragraph>
<div className="codeblock">
<CopyButton
@@ -132,7 +122,7 @@ function GenerateHostTokenButton() {
mkdir clusterio
cd clusterio
npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-headless \
--controller-url ${document.location.origin}/ --host-name "${hostId ? `Host ${hostId}` : "Host"}" \
--controller-url ${document.location.origin}/ --host-name "Host ${hostId || "?"}" \
--public-address localhost ${pluginString.length ? "--plugins" : ""} ${pluginString}`
}/>
<p>&gt; mkdir clusterio</p>
@@ -143,7 +133,7 @@ npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-he
--mode "host"
--download-headless
--controller-url <span className="highlight">{document.location.origin}/ </span>
--host-name <span className="highlight">{hostId ? `Host ${hostId}` : "Host"} </span>
--host-name <span className="highlight">"Host {hostId || "?"}" </span>
--public-address <span className="highlight">localhost </span>
{pluginString.length ? "--plugins" : ""} <span className="highlight">{pluginString}</span>
</p>
@@ -297,7 +287,11 @@ export default function HostsPage() {
title: "Connected",
key: "connected",
render: (_, host) => <>
<HostStatusTag connected={host["connected"]}/>
<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,248 +0,0 @@
import React, { useMemo, useState } from "react";
import { Card, Input, Space, Table, Tag, Typography } from "antd";
import {
useItemMetadata,
useRecipeMetadata,
useSignalMetadata,
useTechnologyMetadata,
usePlanetMetadata,
useQualityMetadata,
useEntityMetadata,
} from "../model/item_metadata";
import { useExportManifest } from "../model/export_manifest";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
const { Text } = Typography;
type IconEntry = {
key: string;
category: string;
name: string;
cssClass: string;
size: number;
path?: string;
};
const CATEGORIES = [
"item", "recipe", "signal", "technology", "planet", "quality", "entity",
] as const;
type Category = typeof CATEGORIES[number];
const CATEGORY_LABELS: Record<Category, string> = {
item: "Item",
recipe: "Recipe",
signal: "Signal",
technology: "Technology",
planet: "Planet",
quality: "Quality",
entity: "Entity",
};
function useAllIconEntries() {
const item = useItemMetadata();
const recipe = useRecipeMetadata();
const signal = useSignalMetadata();
const technology = useTechnologyMetadata();
const planet = usePlanetMetadata();
const quality = useQualityMetadata();
const entity = useEntityMetadata();
const byCategory: Record<Category, Map<string, { size: number; path?: string }>> = {
item, recipe, signal, technology, planet, quality, entity,
};
return useMemo(() => {
const entries: IconEntry[] = [];
for (const category of CATEGORIES) {
const names = [...byCategory[category].keys()].sort();
for (const name of names) {
const meta = byCategory[category].get(name)!;
entries.push({
key: `${category}:${name}`,
category,
name,
cssClass: `${category}-${CSS.escape(name)}`,
size: meta.size,
path: meta.path,
});
}
}
return entries;
}, [item, recipe, signal, technology, planet, quality, entity]);
}
export default function IconReferencePage() {
const [filter, setFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const allEntries = useAllIconEntries();
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) {
return allEntries;
}
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
);
}, [allEntries, filter]);
const groups = useMemo(() => {
const map = new Map<string, IconEntry[]>();
for (const cat of CATEGORIES) {
map.set(cat, []);
}
for (const entry of filtered) {
map.get(entry.category)!.push(entry);
}
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
.filter(g => g.entries.length > 0);
}, [filtered]);
const visibleCount = filtered.length;
const totalCount = allEntries.length;
const autoExpand = visibleCount <= 20;
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
const toggleGroup = (category: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const columns = [
{
title: "Icon",
dataIndex: "cssClass",
key: "icon",
width: 48,
render: (_: string, record: IconEntry) => (
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
),
},
{
title: "Name",
dataIndex: "name",
key: "name",
render: (name: string) => (
<Text style={{ fontSize: 12 }}>{name}</Text>
),
},
{
title: "Usage",
dataIndex: "cssClass",
key: "usage",
render: (cssClass: string) => (
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
{`<div class="${cssClass}" />`}
</Text>
),
},
{
title: "Path",
dataIndex: "path",
key: "path",
render: (p: string | undefined) => (p
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
: null),
},
];
const manifest = useExportManifest();
return (
<PageLayout nav={[{ name: "Icon Reference" }]}>
<PageHeader title="Icon Reference" />
<div style={{ width: "80%", margin: "0 auto" }}>
{!manifest && (
<Card>
<Text type="secondary">
No export data available. Run export-data on an instance to generate icons.
</Text>
</Card>
)}
{manifest && manifest.exportedAt && (
<div style={{ marginBottom: 12, fontSize: 12, lineHeight: 1.8 }}>
{manifest.exportedAt && (
<div>
<Text type="secondary">Exported: </Text>
<Text>{new Date(manifest.exportedAt).toLocaleString()}</Text>
</div>
)}
<div>
<Text type="secondary">Icons: </Text>
<Text>{totalCount}</Text>
</div>
</div>
)}
<Card>
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
<Input
placeholder="Filter by name or class…"
value={filter}
onChange={e => setFilter(e.target.value)}
allowClear
style={{ width: 320 }}
/>
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
{visibleCount === totalCount
? `${totalCount} icons`
: `${visibleCount} / ${totalCount} icons`}
</Text>
</Space>
{groups.map(({ category, entries }) => {
const label = CATEGORY_LABELS[category as Category] ?? category;
const expanded = isExpanded(category);
return (
<div key={category} style={{ marginBottom: 8 }}>
<div
onClick={() => toggleGroup(category)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "#1d1d1d",
border: "1px solid #333",
borderRadius: expanded ? "4px 4px 0 0" : 4,
cursor: "pointer",
userSelect: "none",
}}
>
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
{expanded ? "▾" : "►"}
</span>
<Tag style={{ margin: 0 }}>{label}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{entries.length}
</Text>
</div>
{expanded && (
<Table<IconEntry>
columns={columns}
dataSource={entries}
rowKey="key"
size="small"
showHeader={false}
pagination={false}
style={{ borderTop: "none" }}
/>
)}
</div>
);
})}
</Card>
</div>
</PageLayout>
);
}
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import type { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
@@ -18,15 +18,16 @@ export default function InputPartialVersion (
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
if (hasPermission) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
}, [control, hasPermission]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
@@ -42,18 +43,20 @@ export default function InputPartialVersion (
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => {
const groupKey = `group-${majorMinor}`;
return {
title: majorMinor,
value: majorMinor,
key: majorMinor,
key: groupKey,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
};
});
return <TreeSelect
showSearch
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import type { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
@@ -18,15 +18,16 @@ export default function InputTargetVersion (
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
if (hasPermission) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
}, [control, hasPermission]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
@@ -42,18 +43,20 @@ export default function InputTargetVersion (
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => {
const groupKey = `group-${majorMinor}`;
return {
title: majorMinor,
value: majorMinor,
key: majorMinor,
key: groupKey,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
};
});
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
@@ -1,4 +1,4 @@
import React, { useContext, useRef, useState } from "react";
import React, { useContext, useState } from "react";
import { Input, Typography } from "antd";
import * as lib from "@clusterio/lib";
@@ -17,7 +17,6 @@ export default function InstanceRcon(props: InstanceRconProps) {
let control = useContext(ControlContext);
let [output, setOutput] = useState<string|null>(null);
let [running, setRunning] = useState(false);
let resultRef = useRef<HTMLDivElement>(null);
async function sendCommand(command: string) {
if (!command) {
@@ -32,8 +31,6 @@ export default function InstanceRcon(props: InstanceRconProps) {
new lib.InstanceSendRconRequest(command),
);
setOutput(result);
const animation = resultRef.current?.children[0]?.getAnimations()[0];
if (animation) { animation.currentTime = 0; }
} finally {
setRunning(false);
}
@@ -42,7 +39,7 @@ export default function InstanceRcon(props: InstanceRconProps) {
return <>
{output && <>
<Title level={5}>Rcon result</Title>
<Paragraph ref={resultRef} code className="rcon-result">{output}</Paragraph>
<Paragraph code className="rcon-result">{output}</Paragraph>
</>}
<Input.Search
disabled={props.disabled}
@@ -1,27 +1,23 @@
import { Tag } from "antd";
import React from "react";
import {
QuestionCircleOutlined, ClockCircleOutlined, StopOutlined, LoadingOutlined,
CheckCircleOutlined, SaveOutlined, ExportOutlined, DeleteOutlined,
} from "@ant-design/icons";
export const statusOptions = {
unknown: <Tag color="#8c8c8c" icon={<QuestionCircleOutlined />}>Unknown</Tag>,
unassigned: <Tag color="#eb2f96" icon={<ClockCircleOutlined />}>Unassigned</Tag>,
stopped: <Tag color="#cf1322" icon={<StopOutlined />}>Stopped</Tag>,
starting: <Tag color="#ad8b00" icon={<LoadingOutlined />}>Starting</Tag>,
running: <Tag color="#389e0d" icon={<CheckCircleOutlined />}>Running</Tag>,
stopping: <Tag color="#d48806" icon={<LoadingOutlined />}>Stopping</Tag>,
creating_save: <Tag color="#096dd9" icon={<SaveOutlined />}>Creating Save</Tag>,
exporting_data: <Tag color="#08979c" icon={<ExportOutlined />}>Exporting Data</Tag>,
deleted: <Tag color="#262626" icon={<DeleteOutlined />}>Deleted</Tag>,
export const statusColors = {
unknown: "#8c8c8c",
unassigned: "#eb2f96",
stopped: "#cf1322",
starting: "#ad8b00",
running: "#389e0d",
stopping: "#d48806",
creating_save: "#096dd9",
exporting_data: "#08979c",
deleted: "#262626",
};
type InstanceStatusTag = {
status: keyof typeof statusOptions;
status: keyof typeof statusColors;
};
export default function InstanceStatusTag(props: InstanceStatusTag) {
return statusOptions[props.status];
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
}
@@ -25,7 +25,6 @@ import { useHost } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import { MetricRelativeDate } from "./system_metrics";
type MenuItem = Required<MenuProps>["items"][number];
const { Title } = Typography;
@@ -42,7 +41,6 @@ function InstanceDescription(props: InstanceDescriptionProps) {
return <Descriptions
bordered
size="small"
column={{ xs: 1, md: 3, lg: 3, xl: 3, xxl: 3 }}
>
<Descriptions.Item label="Host">
{!assigned
@@ -66,9 +64,7 @@ function InstanceDescription(props: InstanceDescriptionProps) {
<Descriptions.Item label="Version">
{instance.factorioVersion ?? "unknown"}
</Descriptions.Item>
<Descriptions.Item label="Last Start Time">
<MetricRelativeDate timeMs={instance.startedAtMs}/>
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance.status!} /></Descriptions.Item>
</Descriptions>;
}
@@ -101,7 +97,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
instanceButtonMenuItems.push({ type: "divider" });
}
instanceButtonMenuItems.push({
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status),
disabled: !["unknown", "unassigned", "stopped"].includes(instance.status!),
danger: true,
key: "delete",
icon: <DeleteOutlined />,
@@ -212,7 +208,6 @@ export default function InstanceViewPage() {
return <PageLayout nav={nav}>
<PageHeader
title={instance.name ?? ""}
status={<InstanceStatusTag status={instance.status} />}
extra={<InstanceButtons instance={instance} />}
/>
<InstanceDescription host={host} instance={instance} />
@@ -98,7 +98,7 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
{depWarnings.get(e.name) === "wrong_version" && <Tooltip title="Wrong version added.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{e.spec}
{e.specification}
</Space>
<br/>
</Fragment>)
@@ -268,28 +268,16 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
// State for the modal and dependencies
const [open, setOpen] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [missing, setMissing] = useState<string[]>([]);
const [builtins, setBuiltins] = useState<string[]>([]);
const [incompatible, setIncompatible] = useState<string[]>([]);
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // Missing mods and dependencies, excluding builtin
const [modErrors, setModErrors] = useState({
notFound: new Array<string>(), // Mods which were not found on the mod portal, can include builtins
incompatible: new Array<string>(), // Mods which are required, but are marked incompatible by another
unsatisfiable: new Array<string>(), // Mods which can not have their version range satisfied
invalidDependency: new Array<string>(), // Mods with invalid dependency specs
});
function resetModErrors() {
setModErrors({
notFound: [],
incompatible: [],
unsatisfiable: [],
invalidDependency: [],
});
}
// State for all mods fetched from backend
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [allMods, setAllMods] = useState<lib.ModInfo[]>([]);
const [mods, setMods] = useState<lib.ModInfo[]>([]);
// Get a valid factorio version
useEffect(() => {
@@ -306,8 +294,10 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
if (!open || !factorioVersion) {
// Clear if closed or no version
setMods([]);
setAllMods([]);
setMissing([]);
resetModErrors();
setBuiltins([]);
setIncompatible([]);
return;
}
@@ -319,29 +309,21 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates)
).then(response => {
if (canceled) { return; }
const newModErrors = [...response.errors.entries()]
.reduce<typeof modErrors>((acc, [modName, modError]) => {
acc[modError].push(modName);
return acc;
}, {
notFound: [],
incompatible: [],
unsatisfiable: [],
invalidDependency: [],
});
setModErrors(newModErrors);
setMods(response.dependencies);
setMissing(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`)));
setMods(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`)));
setIncompatible(response.incompatible);
setAllMods(response.dependencies);
setMissing(response.missing.filter(name => !props.builtInModNames.includes(name)));
setBuiltins(response.missing.filter(name => props.builtInModNames.includes(name)));
setLoading(false);
}).catch(err => {
if (canceled) { return; }
notifyErrorHandler("Error fetching mods dependencies")(err);
setError(err);
setMods([]);
setAllMods([]);
setMissing([]);
resetModErrors();
setBuiltins([]);
setIncompatible([]);
setLoading(false);
});
@@ -352,56 +334,51 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
// Re-fetch when modal opens or factorio version changes
}, [open, factorioVersion, control]);
// Fix any mod errors found after resolving dependencies
function fixModErrors() {
if (!factorioVersion) {
// Function to handle download from Mod Portal to Controller
function handleControllerDownload() {
if (!factorioVersion || !mods) {
return;
}
// Enable any required builtins, they will be within the not found list
for (const modName of modErrors.notFound) {
if (props.builtInModNames.includes(modName)) {
props.onChange({
type: "mods.set",
name: modName,
value: { ...props.modPack.mods.get(modName)!, enabled: true },
});
}
// Enable any required builtins
for (const name of builtins) {
props.onChange({
type: "mods.set",
name: name,
value: { ...props.modPack.mods.get(name)!, enabled: true },
});
}
// Disable any incompatible mods
for (const modName of modErrors.incompatible) {
const modRecord = props.modPack.mods.get(modName);
// Disable any incompatible mods present
for (const name of incompatible) {
const modRecord = props.modPack.mods.get(name);
if (modRecord) {
props.onChange({
type: "mods.set",
name: modName,
name: name,
value: { ...modRecord, enabled: false },
});
}
}
// Add all dependencies to the mod pack
for (const mod of mods) {
const existingMod = props.modPack.mods.get(mod.name);
if (!existingMod || existingMod.version !== mod.version) {
props.onChange({
type: "mods.set",
for (const mod of allMods) {
props.onChange({
type: "mods.set",
name: mod.name,
value: {
name: mod.name,
value: {
name: mod.name,
version: mod.version,
sha1: mod.sha1,
enabled: true,
},
});
}
version: mod.version,
sha1: mod.sha1,
enabled: true,
},
});
}
// Download any missing mods
// Download the missing dependencies
control.send(
new lib.ModPortalDownloadRequest(
missing.map(mod => ({
mods.map(mod => ({
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
})),
factorioVersion,
@@ -409,12 +386,12 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
).then(() => {
notification.success({
message: "Download complete",
description: `${missing.length} mods have been downloaded to the controller.`,
description: `${mods.length} mods have been downloaded to the controller.`,
});
}).catch(
notifyErrorHandler("Error starting mod download")
);
}
};
return <>
{props.checkForUpdates && <Button
@@ -438,11 +415,8 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
<Button
key="start"
type="primary"
disabled={loading || missing.length === 0}
onClick={() => {
setOpen(false);
fixModErrors();
}}
disabled={loading || mods.length === 0}
onClick={() => { setOpen(false); handleControllerDownload(); }}
>Start Download</Button>,
]}
>
@@ -455,26 +429,30 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
style={{ marginBottom: 16 }}
/>}
{/* Display warning message for mod errors */}
{([
["notFound", "Failed to find dependencies:"],
["incompatible", "Dependencies are incompatible:"],
["unsatisfiable", "Dependencies could not be satisfied:"],
["invalidDependency", "Dependencies has invalid specifications:"],
] as const).map(([prop, message]) => {
const modNames = modErrors[prop];
return modNames.length === 0 ? null : <Alert
showIcon
type="warning"
message={message}
description={<ul>{modNames.map(name => <li>{name}</li>)}</ul>}
style={{ marginBottom: 14 }}
/>;
})}
{/* Display warning message if dependencies could not be found */}
{!loading && missing.length > 0 && <Alert
message="Failed to Find Dependencies:"
description={<ul>
{missing.map(name => <li>{name}</li>)}
</ul>}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>}
{/* Display warning message if dependencies are incompatible */}
{!loading && incompatible.length > 0 && <Alert
message="Incompatible Dependencies Found:"
description={<ul>
{incompatible.map(name => <li>{name}</li>)}
</ul>}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>}
{/* Display a table of the missing mods that need to be downloaded */}
<Table
dataSource={missing}
dataSource={mods}
rowKey={record => record.name}
loading={loading}
pagination={{
+2 -7
View File
@@ -82,11 +82,7 @@ function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
</>;
}
export function NpmButton(props: {
target: "controller" | { hostId: number },
canRestart?: boolean,
disabled?: boolean
}) {
export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: boolean, disabled?: boolean }) {
const control = useContext(ControlContext);
const account = useAccount();
const [open, setOpen] = useState(false);
@@ -97,7 +93,6 @@ export function NpmButton(props: {
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
});
const hostId = props.target === "controller" ? undefined : props.target.hostId;
useEffect(() => {
if (props.target === "controller") {
if (!account.hasPermission("core.controller.get_config")) {
@@ -126,7 +121,7 @@ export function NpmButton(props: {
});
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
}
}, [hostId]);
}, [props.target]);
function onValuesChange({ action } : { action?: string }) {
if (action) {
@@ -5,7 +5,6 @@ const { Text } = Typography;
type PageHeaderProps = {
title: string | React.ReactElement;
subTitle?: string | React.ReactElement;
status?: React.JSX.Element;
extra?: React.JSX.Element;
};
@@ -14,7 +13,6 @@ export default function PageHeader(props: PageHeaderProps): React.JSX.Element {
<Space>
<h2 className="page-header-title">{props.title}</h2>
{props.subTitle && <Text type="secondary">{props.subTitle}</Text>}
{props.status ?? null}
</Space>
{props.extra && <div className="page-header-extra">{props.extra}</div>}
</div>;
@@ -9,7 +9,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
type PluginExtraProps = {
component: string;
instance?: lib.InstanceDetails;
user?: lib.UserDetails;
user?: lib.User;
host?: lib.HostDetails;
role?: lib.Role;
};
+20 -22
View File
@@ -44,7 +44,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
const data = [...users.values()];
// Determine online predicate based on instanceId
const isUserOnline = (user: lib.UserDetails) => {
const isUserOnline = (user: lib.User) => {
if (instanceId !== undefined) {
return user.instances && user.instances.has(instanceId);
}
@@ -58,7 +58,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "Name",
key: "name",
render: (_: any, user: lib.UserDetails) => (
render: (_: any, user: lib.User) => (
<Space>
{user.name}
<span>
@@ -69,11 +69,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
</Space>
),
defaultSortOrder: "ascend",
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name),
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name
onFilter: (value: string | number | boolean, record: lib.User) => record.name
.toLowerCase()
.includes((value as string).toLowerCase()),
filterDropdownProps: {
@@ -106,10 +106,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
key: "roles",
filters: roleFilters,
filterMultiple: true,
onFilter: (value: string | number | boolean, record: lib.UserDetails) => (
record.roleIds.has(value as number)
),
render: (_: any, user: lib.UserDetails) => (
onFilter: (value: string | number | boolean, record: lib.User) => record.roleIds.has(value as number),
render: (_: any, user: lib.User) => (
[...user.roleIds].map((id) => (
<Link key={id} to={`/roles/${id}/view`} onClick={(e) => e.stopPropagation()}>
<Tag>{(roles.get(id) || { name: id }).name}</Tag>
@@ -121,7 +119,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
// Helper to get last seen timestamp
function getLastSeenTimestamp(user: lib.UserDetails): number | undefined {
function getLastSeenTimestamp(user: lib.User): number | undefined {
const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats;
if (!stats) { return undefined; }
if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) {
@@ -138,11 +136,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "Play Time",
key: "playTime",
render: (_: any, user: lib.UserDetails) => {
render: (_: any, user: lib.User) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
sorter: (a: lib.User, b: lib.User) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
@@ -151,11 +149,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => {
render: (_: any, user: lib.User) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.joinCount ?? 0;
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
sorter: (a: lib.User, b: lib.User) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
@@ -164,8 +162,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "First Seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId),
sorter: (a: lib.User, b: lib.User) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0;
@@ -179,18 +177,18 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{
title: "Online time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => (user.playerStats?.onlineTimeMs
render: (_: any, user: lib.User) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs)
: null),
// eslint-disable-next-line max-len
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
responsive: ["lg"],
},
{
title: "First seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
render: (_: any, user: lib.User) => formatFirstSeen(user),
sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b),
},
);
}
@@ -206,7 +204,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
],
onFilter: (value: string | number | boolean, record: lib.UserDetails) => {
onFilter: (value: string | number | boolean, record: lib.User) => {
const online = isUserOnline(record);
if (value === "online") {
return online;
@@ -229,8 +227,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
return false;
}
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b, instanceId, instanceId),
render: (_: any, user: lib.User) => formatLastSeen(user, instanceId),
responsive: ["lg"],
},
);
@@ -63,52 +63,3 @@ export function RestartRequired(props: { system?: lib.SystemInfo }) {
<ReloadOutlined style={{ color: "yellow" }}/>
</Tooltip>;
}
/**
* Returns a human-readable duration between two dates.
*
* The output includes up to two most significant time units and omits
* leading zero units. Supported units are weeks, days, hours, minutes,
* and seconds.
*
* Examples: "1w 2d", "2d 3h", "1h 2m", "45s"
*
* @param date1 - The starting date.
* @param date2 - The ending date.
* @returns A duration string (e.g. "2d 3h").
*/
function humanAbsTimeDiff(date1: number, date2 = 0) {
let diffMs = Math.abs(date2 - date1);
const totalSeconds = Math.floor(diffMs / 1000);
const weeks = Math.floor(totalSeconds / 604800); // 7 * 24 * 60 * 60
const days = Math.floor((totalSeconds % 604800) / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (weeks > 0) {
return days > 0 ? `${weeks}w ${days}d` : `${weeks}w`;
}
if (days > 0) {
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
export function MetricRelativeDate(props: { timeMs?: number }) {
if (!props.timeMs || props.timeMs === 0) {
return "N/A";
}
return `${new Date(props.timeMs).toLocaleString()} (${humanAbsTimeDiff(props.timeMs, Date.now())})`;
}
+1 -1
View File
@@ -21,7 +21,7 @@ export { default as LogConsole } from "./components/LogConsole";
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
export { default as InstanceList } from "./components/InstanceList";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as InstanceStatusTag } from "./components/InstanceStatusTag";
export { default as InstanceStatusTag, statusColors } from "./components/InstanceStatusTag";
export { default as InstanceControlButton, InstanceControlButtonPermissions } from "./components/InstanceControlButton";
export { default as PageHeader } from "./components/PageHeader";
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more