Files
factorio-clusterio/packages/controller/src/HostConnection.js
T
Hornwitser 87172dfb8d Implement basic ingress filtering on controller
Validate messages received on the controller from hosts and control
nodes before handling or forwarding them.  This should prevent
performing actions that are not allowed by the role system and block
simple attacks such as spoofing the source address of a message.
2023-07-25 17:21:44 +02:00

273 lines
8.9 KiB
JavaScript

"use strict";
const libData = require("@clusterio/lib/data");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const PlayerStats = require("@clusterio/lib/PlayerStats");
const BaseConnection = require("./BaseConnection");
const InstanceInfo = require("./InstanceInfo");
/**
* Represents the connection to a host
*
* @extends module:controller/src/BaseConnection
* @alias module:controller/src/HostConnection
*/
class HostConnection extends BaseConnection {
constructor(registerData, connector, controller) {
super(connector, controller);
this._agent = registerData.agent;
this._id = registerData.id;
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._checkPluginVersions();
this._controller.hosts.set(this._id, {
agent: this._agent,
id: this._id,
name: this._name,
version: this._version,
public_address: registerData.publicAddress,
plugins: registerData.plugins,
});
for (let event of ["connect", "drop", "resume", "close"]) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
}
});
}
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") !== this._id) {
continue;
}
let prev = instance.status;
instance.status = "unknown";
this._controller.instanceUpdated(instance);
libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
});
this.handle(libData.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(libData.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(libData.InstanceSaveListUpdateEvent, this.handleInstanceSaveListUpdateEvent.bind(this));
this.handle(libData.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(libData.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
}
validateIngress(message) {
let origin = this.connector.dst;
switch (message.src.type) {
case libData.Address.control:
case libData.Address.controller:
throw new libErrors.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
case libData.Address.host:
if (message.src.id !== origin.id) {
throw new libErrors.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
}
break;
case libData.Address.instance:
let instance = this._controller.instances.get(message.src.id);
if (!instance || instance.config.get("instance.assigned_host") !== this._id) {
throw new libErrors.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
}
break;
default:
throw new Error("Should be unreachable");
}
}
_checkPluginVersions() {
let pluginInfos = new Map(this._controller.pluginInfos.map(i => [i.name, i]));
for (let [name, version] of this.plugins) {
let info = pluginInfos.get(name);
if (!info) {
logger.warn(`Host ${this._name} has plugin ${name} ${version} which the controller does not have`);
continue;
}
if (info.version !== version) {
logger.warn(
`Host ${this._name} has plugin ${name} ${version} which does not match the version of this ` +
`plugin on the controller (${info.version})`
);
}
}
for (let [name, info] of pluginInfos) {
if (!this.plugins.has(name)) {
logger.warn(`Host ${this._name} is missing plugin ${name} ${info.version}`);
}
}
}
async prepareDisconnect() {
await libPlugin.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
return await super.prepareDisconnect();
}
/**
* ID of the host this connection is connected to
*
* @returns {number} host ID.
*/
get id() {
return this._id;
}
async handleInstanceStatusChangedEvent(request) {
let instance = this._controller.instances.get(request.instanceId);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the host it originated from if it was
// reassigned or deleted while the connection to the host it was
// originally on was down at the time.
if (!instance || instance.config.get("instance.assigned_host") !== this._id) {
logger.warn(`Got bogus update for instance id ${request.instanceId}`);
return;
}
// We may receive status changed where the status hasn't changed
// from our perspective if the connection was down at the time it
// changed. Hosts also send status updates on assignInstance which
// for hacky reason is also used to push config changes and
// restablish status after a connection loss.
if (instance.status === request.status) {
return;
}
let prev = instance.status;
instance.status = request.status;
instance.game_port = request.gamePort;
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._controller.instanceUpdated(instance);
await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
async handleInstancesUpdateRequest(request) {
// Push updated instance configs
for (let instance of this._controller.instances.values()) {
if (instance.config.get("instance.assigned_host") === this._id) {
await this.send(
new libData.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host"))
);
}
}
// Assign instances the host has but controller does not
for (let instanceData of request.instances) {
let instanceConfig = new libConfig.InstanceConfig("controller");
await instanceConfig.load(instanceData.config, "host");
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
if (controllerInstance) {
// Check if this instance is assigned somewhere else.
if (controllerInstance.config.get("instance.assigned_host") !== this._id) {
await this.send(
new libData.InstanceUnassignInternalRequest(instanceConfig.get("instance.id"))
);
continue;
}
// Already have this instance, update state instead
if (controllerInstance.status !== instanceData.status) {
let prev = controllerInstance.status;
controllerInstance.status = instanceData.status;
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
this._controller.instanceUpdated(controllerInstance);
await libPlugin.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
}
continue;
}
instanceConfig.set("instance.assigned_host", this._id);
let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status });
this._controller.instances.set(instanceConfig.get("instance.id"), newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new libData.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.serialize("host")
)
);
await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance, null);
}
// Push lists to make sure they are in sync.
let adminlist = [];
let banlist = [];
let whitelist = [];
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.push(user.name);
}
if (user.isBanned) {
banlist.push([user.name, user.banReason]);
}
if (user.isWhitelisted) {
whitelist.push(user.name);
}
}
await this.send(new libData.SyncUserListsEvent(adminlist, banlist, whitelist));
}
async handleInstanceSaveListUpdateEvent(event) {
this._controller.saveListUpdate(event.instanceId, event.saves);
}
async handleLogMessageEvent(event) {
this._controller.clusterLogger.log({
...event.info,
host_id: this._id,
host_name: this._name,
});
}
async handleInstancePlayerUpdateEvent(event, src) {
let instanceId = src.id;
let user = this._controller.userManager.users.get(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
if (event.type === "join") {
user.notifyJoin(instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
}
user.instanceStats.set(instanceId, new PlayerStats(event.stats));
user.recalculatePlayerStats();
this._controller.userUpdated(user);
let instance = this._controller.instances.get(instanceId);
await libPlugin.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.type,
name: event.name,
reason: event.reason,
});
}
}
module.exports = HostConnection;