Commit Graph
654 Commits
Author SHA1 Message Date
Danielv123 6e36c02ade Load plugins installed from npm by package name
Plugins installed from npm are listed by package name, but the plugin
loader joined that name onto the main and entrypoint paths and imported
the result as a file, which resolves relative to the working directory.
Every npm installed plugin then failed to load. Resolve these paths from
the directory of the plugin's package.json instead.
2026-10-11 13:22:14 +02:00
BastiaanandClaude Opus 5.5 ae6e25d1b9 Add regression tests for #704 and #705 (#1060)
Covers the icon_size default of 64 in the export icon loaders and the
host recreating saves and script-output when an instance is assigned.
Both tests fail when the original fix is reverted.

Closes #710

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:57:00 +01:00
BastiaanandClaude Opus 5.5 839f9eb56c Use the config access API in the ctl config editor (#1059)
serializedConfigToString now asks a control-located config instance
whether each field is readable and writable instead of relying on a
hard-coded list per command. Hidden and unknown fields are skipped, and
field descriptions are written as comments above each value.

Object fields such as factorio.settings are now included, so they are
written as JSON, and configToKeyVal splits on the first = only so those
values survive the round trip.

Fixes #1051

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:53:06 +01:00
bbassieandClaude Opus 5.5 74aa1323d8 Name the handleAccountEvent tests after the method
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:40:02 +00:00
bbassieandClaude Opus 5.5 2fc5a67dce Resend subscriptions when the account permissions change
EventSubscriber registers with the control's onAccountUpdate when it
has one and resends its subscription through handleAccountEvent. A lost
permission makes the request fail, a granted one syncs it again.

The web Control also reports the account on connect, so resends are
queued and sent once per tick. A failed subscription request now sets
synced to false and notifies callbacks, before the error never reached
the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:31:19 +00:00
Cooldude2606 8a170eb2ac Merge pull request #1057 from bbassie/fix/bounded-send-buffer
Limit unacknowledged messages buffered for a connection
2026-10-08 17:25:57 +01:00
Cooldude2606 208ecb826e Merge pull request #1056 from bbassie/fix/subscription-permission-revoke
Drop subscriptions when a user loses permission for them
2026-10-08 17:23:43 +01:00
Cooldude2606 ec1ce79be9 Merge pull request #1055 from bbassie/fix/subscription-limits
Limit the number of subscription filters per subscriber
2026-10-08 17:02:33 +01:00
bbassieandClaude Opus 5.5 d10da0f5fc Rename to checkLinkPermissions and stop log following on revoke
The old name read as an unconditional unsubscribe. Log following has
its own subscription outside SubscriptionController, so check
core.log.follow on permission changes too. Move the per-connection
work into one helper and split the tests into userPermissionsUpdated
and rolesUpdated, checking the account update event is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:21:02 +00:00
bbassieandClaude Opus 5.5 ad4595a257 Make the send buffer limit a config and start a new session on overflow
Add controller.max_send_buffer_size in MiB, default 64 and 0 for no
limit. Close with 1013 instead of 1008 on overflow: clients treat 1008
as fatal, so a host would shut down and the web UI would log out. With
1013 the client resumes, the controller answers with invalidate since
the session has ended, and the client registers a new session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:18:02 +00:00
bbassieandClaude Opus 5.5 d2163cf6a0 Make the filter limit a restart setting with 0 for no limit
Lowering the limit at runtime would leave connections holding more
filters than allowed, so read it once when the controller starts.
Move the limit tests into the subscribe and replace groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:36 +00:00
BastiaanandClaude Opus 5.5 fe1391903a Redact credentials from logged HTTP request headers (#1054)
The controller HTTP access log and the express error log wrote the full
request headers at the http level, which both default log transports
keep. Clients send their access token in X-Access-Token or
Authorization: Bearer, so user and host tokens ended up in plain text
in controller and cluster logs, and the cluster log can be read back
over the API with core.log.query.

Mask the authorization, x-access-token, cookie and proxy-authorization
headers before logging.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:45:43 +01:00
Bastiaan 069f537bde Merge pull request #1053 from bbassie/fix/instance-save-name-traversal
Check save names in instance start and create save
2026-10-08 15:43:26 +01:00
bbassieandClaude Opus 5.5 235a15b9cc Limit unacknowledged messages buffered for a connection
Every message sent over a link is kept in the send buffer until the
other side acknowledges it with a heartbeat, so it can be resent if
the session resumes. Nothing forced a peer to acknowledge, so a client
that kept heartbeating without a sequence number while subscribed to a
busy event made the controller buffer messages until it ran out of
memory.

Store buffered messages serialised, track their total size, and close
the connection once it goes over maxSendBufferSize. The controller
sets this to 64 MiB for the connections it accepts. A client that far
behind could not resume in a reasonable time anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:15:27 +00:00
bbassieandClaude Opus 5.5 beec2f523a Drop subscriptions when a user loses permission for them
The permission for a subscription was only checked when subscribing,
so a control connection kept receiving events after the user's roles
or role permissions changed to no longer allow it.

Add SubscriptionController.unsubscribeUnpermitted which rechecks every
subscription of a link and removes the ones the user is no longer
permitted, and call it for the affected control connections from
userPermissionsUpdated and rolesUpdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:11:28 +00:00
bbassieandClaude Opus 5.5 690b79d2b7 Limit the number of subscription filters per subscriber
A subscriber could keep sending subscribe requests with new filters and
the controller would add every one of them to the subscriber's filter
set without bound, growing memory until it ran out.

Add controller.max_subscription_filters (default 10000) and reject
subscribe and replace requests that would leave a subscriber holding
more filters than that for one event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:10:03 +00:00
bbassieandClaude Opus 5.5 dab4dd4a4e Add test helpers for plugin entrypoint functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:11:02 +00:00
Zachary S 7ee09ac790 Fix CLI config editor field names 2026-10-07 00:31:52 +00:00
Cooldude2606 b07c25aa7e Merge pull request #1014 from bbassie/feature/patched-scenario
Add create-scenario command to clusteriohost
2026-10-06 16:37:16 +01:00
ILLISISandClaude Opus 5.5 b2a426bb30 Throw on a missing file when loadJsonObject is asked to
Controller.loadJsonObject(path, true) returned {} for a missing file, so
loadPlugins' catch never ran and a plugin whose web build was expected but
missing was not reported (the second half of #1046; #1047 fixed the path).
With throwOnMissing set, every error now throws; without it only a missing
file is ignored, so a malformed JSON file throws again as before #1047.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGUULoiHNbAegwMK4PSjHJ
2026-10-07 01:21:11 +11:00
Cooldude2606andClaude Fable 5.1 2efb53aab1 Add a request to mark hosts and the controller as needing a restart (#1043)
* Add a request to mark hosts and the controller as needing a restart

Not every plugin arrives through PluginInstallRequest. Plugins checked
out into external_plugins or installed by hand on the machine never
flag a restart, so the web UI shows nothing and tooling that acts on
the flag never fires.

HostMarkRestartRequiredRequest and ControllerMarkRestartRequiredRequest
set config.restartRequired, guarded by the existing restart
permissions. They are exposed as "Mark Restart Required" in the control
dropdown of the host and controller pages, hidden once the flag is set,
and as mark-restart-required ctl commands. The mark shows up on the
next system metrics poll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cover the mark-restart-required ctl commands

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 21:14:23 +01:00
Bastiaan 80d6bc8da6 Merge pull request #1042 from bbassie/fix/plugin-install-restart-required
Mark restart required after installing a new plugin
2026-09-26 21:10:15 +01:00
bbassieandClaude Fable 5.1 1299ea80b8 Cover the mark-restart-required ctl commands
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:31:20 +00:00
bbassieandClaude Fable 5.1 bca681b5ea Add a request to mark hosts and the controller as needing a restart
Not every plugin arrives through PluginInstallRequest. Plugins checked
out into external_plugins or installed by hand on the machine never
flag a restart, so the web UI shows nothing and tooling that acts on
the flag never fires.

HostMarkRestartRequiredRequest and ControllerMarkRestartRequiredRequest
set config.restartRequired, guarded by the existing restart
permissions. They are exposed as "Mark Restart Required" in the control
dropdown of the host and controller pages, hidden once the flag is set,
and as mark-restart-required ctl commands. The mark shows up on the
next system metrics poll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 19:52:03 +00:00
Hornwitser 9fbff1e8e5 Ignore errors in concurrent safeOutput test
On Windows this test throws "EPERM: operation not permitted, rename
<path>" presumably because Windows prevents concurrent renames.  This
does not affect the test outcome, so ignore the error(s).
2026-09-15 22:18:16 +02:00
Hornwitser 0c4fac37b0 Move test/host.js into the test/host folder
This is clearly part of the host tests. It should be inside of the host
test folder.
2026-09-15 22:18:16 +02:00
Hornwitser 92d2662078 Move slowTest and externalTest to test/common.js
These are used outside of the integration tests, but including the
integration index file causes the very slow setup of the contorller
and host.  Avoid the integration setup costs when running commands
such as `pnpm mocha test/lib` by moving these utility functions to
the common test functions.
2026-09-15 22:18:16 +02:00
Hornwitser ea1cb67502 Setup logging transport in tests
Without a transport Winston will print annoying "Attempt to write logs
with no transports" warnings all over the place.  Setup a transport in
tests that cause logging to suppress this.
2026-09-15 22:18:16 +02:00
Hornwitser 2c52c265cf Require config files to be explicitly created (#1040)
The behaviour of creating the config file if it's missing while
convenient causes confusion and problems when the tools are run
in the wrong folder.  They end up creating a shadow installation
and misdirect the user with nonsensical error messages, instead
pointing to the real problem which is that the tool can't find
the configuration needed to actually run.

Require a config file to be present at startup and refuse to run if this
file is missing.  To support the odd use-case of creating the config
when missing a --create-config flag is added. To conveniently generate
a config file to edit out from the "config create" command is added.
2026-09-15 22:16:54 +02:00
Hornwitser 5df5af7ca8 Rename Ctl's usage of "control" to "ctl" (#1039)
Ctl ended up calling its main class for "Control" for historical
reasons, along with its config file and plugin entrypoint.

The plugin entrypoint was renamed to ctl in 808eda0513.  This
finishes that renaming by also renaming the main class, default config
file, config-control command, and main entrypoint.

The config-control command is renamed to config to be consistent with
the host and controller's config commands.

The term "control" is still kept in the config and message locations as
a common specifier for both ctl and web_ui.
2026-09-14 20:18:41 +02:00
Cooldude2606 1d1d9bb1c4 Merge pull request #1031 from bbassie/fix/stop-before-rcon-ready
Resolve instance stop when Factorio exits before RCON is ready
2026-09-13 18:35:14 +01:00
Cooldude2606 a6f2c2829e Merge pull request #1036 from bbassie/fix/host-save-details-ownership
Reject save detail updates for instances not on the sending host
2026-09-13 18:29:56 +01:00
bbassieandClaude Opus 5 2a967e84c5 Reject the RCON wait instead of resolving with an error
_waitForRconReadyOrExit resolved with an Error to signal the process
exited before RCON came up, which is an odd shape for a promise.  It now
resolves when RCON is ready and rejects otherwise, with the error emitted
with the exit, or "RCON connection lost" when there was none.

Observable behaviour is unchanged.  sendRcon rejects with the same
messages as before and stop ignores the rejection, as stopping is done
once the process is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:29:41 +00:00
bbassie 0273558edc Merge remote-tracking branch 'upstream/master' into fix/stop-before-rcon-ready 2026-09-13 17:10:02 +00:00
bbassie 97bab72eb9 Merge remote-tracking branch 'upstream/master' into fix/host-save-details-ownership 2026-09-13 17:10:00 +00:00
bbassieandClaude Opus 5 70c823ae9f Correct misaligned instance names on forwarded log messages
The instance name on a log line is whatever the host sent, so a host can
attribute a line for one of its own instances to the wrong name. Compare
it with the name the controller has on record and replace it when they
differ.

Host connections can live for days, so the warning suppression expires
after five minutes instead of latching for the life of the connection,
and the bogus instance and misaligned name warnings share it so the two
together cannot spam the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:09:53 +00:00
bbassieandClaude Opus 5 229bbf8eb8 Report why the server exited from sendRcon
Waiting for exit alongside rcon-ready made sendRcon reject with the flat
"RCON connection lost" for every case, including the unexpected exits
that used to reject with the EnvironmentError saying why the server went
away, as events.once rejects on error events.

Have the paired wait capture the error emitted with the exit and hand it
back, so sendRcon throws that when there is one and falls through to the
old message when there is not, which is the kill path where no error is
emitted. stop() ignores the error as its job is done once the process is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:09:51 +00:00
bbassie 767f9e7fe3 Merge remote-tracking branch 'upstream/master' into fix/instance-config-change-crash 2026-09-13 17:09:02 +00:00
bbassieandClaude Opus 5 a76b742294 Invoke the config changed hook for every field
The fieldChanged handler repeated the hook invoke in each branch, and
factorio.shutdown_timeout had no invoke at all, so plugins were never
told about a change to it. Do the field specific side effect, then
invoke the hook once for every field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:08:53 +00:00
bbassie daa39fd7ec Merge remote-tracking branch 'upstream/master' into fix/instance-assign-failure 2026-09-13 14:43:43 +00:00
Cooldude2606 c355fc6888 Merge branch 'master' into fix/terminate-deleted-user-sessions 2026-09-13 15:18:25 +01:00
d32d857eb3 Re-apply debug dump ignore flag on control connection resume (#1035)
handleDebugDumpWsRequest sets clusterio_ignore_dump on the current
socket so the WsServer send wrapper does not echo the dumper's own
frames back into debugEvents. The flag was only set on the connector
"connect" event, but WsServerConnector.continue() installs a fresh
socket and emits "resume", not "connect", so a resumed session lost
the flag.

After a resume the next outgoing frame was echoed to debugEvents,
ws_dumper sent a DebugWsMessageEvent, the wrapper echoed that too,
and so on: infinite synchronous recursion ending in a RangeError:
Invalid string length from JSON.stringify in link.ts sendEvent. That
uncaught error made handleUnhandledErrors exit the controller.

Repro: admin sends DebugDumpWsRequest, the control connection drops
and resumes (e.g. a network blip), controller crashes on resume.

Set the flag on "resume" as well as "connect".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-09-13 15:13:24 +01:00
bbassieandClaude Opus 5 cf4e85c6ee Guard forwarded log messages by instance assignment
Log messages share the root cause with the save details updates fixed in
the previous commit: the controller trusted host supplied instance
fields. LogMessageEvent only declares level and message in its schema,
but the Ajv instance is built without removeAdditional and the schema
does not set additionalProperties, so instance_id and instance_name sent
by a host survive validation and pass through fromJSON untouched. A host
could tag a log line with an instance assigned to another host and have
the controller store and serve it as that instance's output.

Lift the assignment check out of handleInstanceSaveDetailsUpdatesEvent
into a private method and use it from both handlers.

Strip the instance fields instead of dropping the line. A host racing a
reassignment does emit instance log lines for an instance the controller
has already moved, and dropping those loses real output exactly when a
migration is going wrong. The line is still genuine output from the host,
so it is recorded as a host level message, which grants the host nothing
it could not already log directly. Warn once per connection so a
misbehaving host cannot flood the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:11:23 +00:00
BastiaanandClaude Opus 4.8 fc73433ffb Stop host crash on malformed action log lines (#1034)
_watchServerLogActions dereferenced two regex results with ! and
threw on null. The name regex /^([^ ]+)/ returns null when a line
classified as an action starts with a space, and the BAN reason
regex /\. Reason: (.+)\.$/ returns null for a [BAN] line with no
". Reason: X." tail. The output listener runs synchronously off the
stdout LineSplitter, so the throw becomes an uncaughtException and
takes the whole host down with every instance on it.

Repro: instance with enable_save_patching off and enable_script_commands
on; a control client sends /sc print("2000-01-01 00:00:00 [BAN] evil").
The raw print reaches stdout, parses as a dated BAN action, and the
reason deref throws.

Skip the line when the name regex misses and default the ban reason
to empty when the tail is absent. Apply the same name guard to
_watchPlayerPromote. The autosave ![1] in server.ts is safe because
its filter regex already guarantees the capture.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:53:49 +01:00
BastiaanandClaude Opus 4.8 8239a96509 Deny instead of crashing when a control permission check throws (#1033)
A permission function runs during ingress permission checking, before
the request body is validated. UserBulkImportRequest.permission read the
raw, unvalidated message.data and threw a bare Error for an unknown
importType. That error is not a PermissionError, so _processMessage
rethrew it to the connector "message" listener, which emitted "error" on
the connector. Controller-side connectors have no "error" listener, so
Node rethrew and handleUnhandledErrors exited the process.

Repro: a control client with client-side validateSent disabled sends
UserBulkImportRequest with importType "foo"; the client gets a normal
RequestError response and the controller then logs a fatal uncaught
exception and exits.

Fix both layers: _processMessage now logs and drops an unexpected error
from the permission check (control connections only, where a response
was already sent) instead of rethrowing, and UserBulkImportRequest
throws a PermissionError for an unknown importType so it is denied
cleanly. A genuine PermissionError still denies silently as before.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 14:51:16 +01:00
bbassieandClaude Opus 5 49e8d6ecc5 Fix sendRcon hanging if Factorio exits before RCON connects
sendRcon waits for the rcon-ready event when RCON has not connected yet,
which is the same root cause as the stop() hang. events.once rejects on
error events, but _watchExit skips emitting error while the state is
stopping, and kill() sets that state before sending SIGKILL. exportData
does exactly that, so an RCON call racing the export never settles and
the request stays pending until the session closes.

Wait for exit as well, which leaves the existing RCON client check to
reject the call with "RCON connection lost" instead. Both call sites now
share a helper as the paired wait is identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:49:58 +00:00
bbassieandClaude Opus 5 1da7c1da67 Terminate deleted user sessions immediately
Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:31:09 +00:00
bbassieandClaude Opus 5 fb71ee7282 Terminate control connections of deleted users
ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:30:45 +00:00
bbassieandClaude Opus 5 96362a2835 Wait for transitory instance statuses to settle
The config changed handler ran updateFactorioSettings and
updateFactorioWhitelist as soon as the field changed. While the instance
is starting, stopping, creating_save or exporting_data the server is not
ready for RCON, so the update threw and was only logged. The three list
update events pushed from the controller had the same problem, except
Link._processEvent swallowed the rejection and the update was lost
without a trace.

Add _waitForSettledStatus, which resolves once notifyStatus lands on
running or stopped. notifyStatus is the only place _status changes and
notifyExit always ends on stopped, so a waiter cannot hang. The settings,
whitelist and list updates now go through it.

Nothing is applied when the instance settles on stopped, because there is
no server to apply it to. factorio.settings is written to
server-settings.json and the lists to the list files on the next start,
and enableWhitelist is read from the config when the next Instance is
constructed.

The plugin hook now runs immediately for the two config fields instead of
being chained onto the update. Every other field already invokes it right
away, and waiting for the instance to settle would hold the notification
back for the whole startup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:20:52 +00:00
Cooldude2606 3c692c4321 Merge branch 'master' into fix/instance-assign-failure 2026-09-13 14:04:45 +01:00