Files
factorio-clusterio/packages/controller/src/HostConnection.ts
T
Danielv123 87d4f5c64e Replace host tokens with host pairing
Hosts join the cluster by running the installer, which shows a code and
waits until the request is approved under Hosts > Add host in the web
interface or with clusterioctl host approve. An admin can pass their
user token to the installer to approve the host right away. The token
is only used for that and is not saved.

The controller only accepts hosts that have a record, and only
approving a host creates one. Revoking a host deletes its record, so
its token can no longer be used to reconnect. Hosts that have connected
before keep working.

Removes host generate-token, revoke-token and create-config from
clusterioctl, bootstrap generate-host-token, the host token messages and
the core.host.generate_token, revoke_token and create_config
permissions, which are also stripped from stored roles. Adds
clusterioctl host pending, approve, reject and revoke, and the
core.host.approve and core.host.revoke permissions.

Standalone installs add the host to the controller database directly,
since the controller is not running during the install.
2026-10-30 06:11:32 +01:00

469 lines
16 KiB
TypeScript

import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import BaseConnection from "./BaseConnection.js";
import InstanceRecord from "./InstanceRecord.js";
import HostRecord from "./HostRecord.js";
// How long log message warnings are suppressed for after one is logged.
const logMessageWarnIntervalMs = 5 * 60 * 1000;
/**
* Represents the connection to a host
*
* @extends module:controller/src/BaseConnection
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostRecord;
plugins: Map<string, string>;
// Shared by all log message warnings, a misbehaving host could log in a loop.
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
constructor(
registerData: lib.RegisterHostData,
connector: WsServerConnector,
controller: Controller,
remoteAddress: string,
) {
super(connector, controller);
this.plugins = new Map(Object.entries(registerData.plugins));
// Hosts are only created by approving them, WsServer rejects hosts without a record.
const record = this._controller.hosts.getMutable(registerData.id);
if (!record) {
throw new Error(`Host ${registerData.id} is not registered`);
}
record.version = registerData.version;
record.plugins = this.plugins;
record.connected = true;
record.remoteAddress = remoteAddress;
this.info = record;
this._controller.hosts.set(this.info);
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
}
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
const instances: InstanceRecord[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
}
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
this._controller.instances.records.setMany(instances);
this.info.connected = false;
this._saveInfo();
});
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
}
validateIngress(message: lib.MessageRoutable) {
let origin = this.connector.dst;
switch (message.src.type) {
case lib.Address.control:
case lib.Address.controller:
throw new lib.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`);
case lib.Address.host:
if (message.src.id !== origin.id) {
throw new lib.InvalidMessage(
`Received message with invalid src ${message.src} from ${origin}`
);
}
break;
case lib.Address.instance:
let instance = this._controller.instances.get(message.src.id);
if (!instance || instance.config.get("instance.assigned_host") !== this.id) {
throw new lib.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.info.name} (${this.id}) has plugin ${name} ${version} which the ` +
"controller does not have",
);
continue;
}
if (info.version !== version) {
logger.warn(
`Host ${this.info.name} (${this.id}) 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.info.name} (${this.id}) is missing plugin ${name} ${info.version}`);
}
}
}
async prepareDisconnect() {
await this._controller.hooks.prepareHostDisconnect.invoke(this);
return await super.prepareDisconnect();
}
/**
* ID of the host this connection is connected to
*
* @returns {number} host ID.
*/
get id() {
return this.info.id;
}
/**
* Remote address of the host this connection is connected to
*
* @returns {string} host remote address.
*/
get remoteAddress() {
return this.info.remoteAddress;
}
async handleGetFactorioCredentialsRequest() {
if (this._controller.config.get("controller.share_factorio_credentials_with_hosts")) {
return {
username: this._controller.config.get("controller.factorio_username") ?? undefined,
token: this._controller.config.get("controller.factorio_token") ?? undefined,
};
}
return {};
}
/** Store changes to the host record, unless the host was revoked */
private _saveInfo() {
if (this._controller.hosts.getMutable(this.id) === this.info) {
this._controller.hosts.set(this.info);
}
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._saveInfo();
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
let instance = this._controller.instances.getMutable(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
&& instance.gamePort === request.gamePort
) {
return;
}
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);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
// 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 lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host"))
);
}
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceRecord[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
let controllerInstance = this._controller.instances.getMutable(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 lib.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;
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 this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
}
continue;
}
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;
instanceUpdates.push(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 => [
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.id);
}
}
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
}
// A host may only report on instances assigned to it. Otherwise any host
// could speak for an instance on a different host. See
// handleInstanceStatusChangedEvent.
private _assignedInstance(instanceId: number) {
const instance = this._controller.instances.get(instanceId);
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
}
// Host connections live for days, so the suppression expires instead of
// latching for the life of the connection.
private _warnLogMessage(message: string) {
const now = Date.now();
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
return;
}
this._lastLogMessageWarnMs = now;
logger.warn(message);
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
// Otherwise any host could inject save records for, or wipe the save
// list of, an instance on a different host.
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
return;
}
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
logger.warn(`Got bogus save updates from host id ${this.id}`);
return;
}
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
const existingSave = this._controller.saves.get(save.id);
if (existingSave && save.equals(existingSave)) {
continue;
}
updates.push(save);
}
if (event.instanceId !== undefined) {
const updatedSaves = new Set(event.updates.map(s => s.id));
for (const save of this._controller.saves.values()) {
if (save.instanceId === event.instanceId && !updatedSaves.has(save.id)) {
deletes.push(save);
}
}
}
// Hosts eagerly send updates, which means we may get an update
// where nothing actually changed.
this._controller.saves.setMany(updates);
this._controller.saves.deleteMany(deletes);
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
// Fields beyond level and message survive schema validation, so the
// instance a log line claims to come from is whatever the host sent.
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
if (info.instance_id !== undefined) {
const instance = this._assignedInstance(info.instance_id);
if (!instance) {
// Demote to a host level line instead of dropping it. The host did
// log this, it just does not get to pin it on someone else's
// instance, and a host racing a reassignment still gets heard.
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
delete info.instance_id;
delete info.instance_name;
} else {
// The name the host sent may be stale or made up, ours is authoritative.
const name = instance.config.get("instance.name");
if (info.instance_name !== name) {
this._warnLogMessage(
`Got log message for instance id ${info.instance_id} named ` +
`${info.instance_name} which is named ${name} on the controller`
);
info.instance_name = name;
}
}
}
this._controller.clusterLogger.log({
...info,
host_id: this.id,
host_name: this.info.name,
});
}
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
const user = this._controller.users.getOrCreateUser(event.name);
if (event.type === "join") {
user.notifyJoin(instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.type,
name: event.name,
reason: event.reason,
stats: event.stats,
});
}
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
async snoopInstanceBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.isBanned = event.banned;
user.banReason = event.reason;
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
reason: event.reason,
});
}
async snoopInstanceWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
}