Refactor user and instance manager (#837)

* Refactor UserManager to use Datastore

* Rename lib.User to UserDetails also adds to/fromUserDetails

* Fix existing tests

* Add user class tests

* Add tests for finaliseUsers

* Move HostDetails to own file with tests

* Move InstanceDetails to its own file with tests

* Rename HostInfo and add tests

* Rename InstanceInfo and add tests

- Also changes the names of the host properties related to instances
- Also removes the need to specify the location of InstanceInfo

* Make json round trip test validate schema

* Add InstanceManager

* Fix lint errors

* Extra tests

* Add final test coverage

* Add one last test to InstanceManager

* Response to comments

* Use generator rather than return anonumous generator

Co-authored-by: Hornwitser <github@hornwitser.no>

* Fix lint issue with generator

---------

Co-authored-by: Hornwitser <github@hornwitser.no>
This commit is contained in:
Cooldude2606andHornwitser authored and GitHub committed 2026-03-01 23:04:37 +00:00
1 parent 5bea930d05
commit 2235294c59
60 files changed
+3371 -1227

No files matched your search

@@ -3,7 +3,7 @@ import type {
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceInfo from "./InstanceInfo";
import type InstanceRecord from "./InstanceRecord";
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: InstanceInfo, prev?: InstanceStatus) { }
async onInstanceStatusChanged(instance: InstanceRecord, 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: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
async onInstanceConfigFieldChanged(instance: InstanceRecord, 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: InstanceInfo, event: PlayerEvent) { }
async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
+49 -56
View File
@@ -6,7 +6,8 @@ import * as lib from "@clusterio/lib";
const { logFilter, logger } = lib;
import BaseConnection from "./BaseConnection";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
import * as routes from "./routes";
import Controller from "./Controller";
@@ -40,7 +41,7 @@ export default class ControlConnection extends BaseConnection {
registerData: { version: string },
connector: WsServerConnector,
controller: Controller,
public user: ControllerUser, // The user making this connection.
public user: User, // The user making this connection.
public id: number
) {
super(connector, controller);
@@ -250,7 +251,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) {
return this._controller.getRequestInstance(request.instanceId).toInstanceDetails();
return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails();
}
async handleInstanceDetailsListRequest() {
@@ -268,20 +269,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.instanceCreate(instanceConfig);
await this._controller.instances.createInstance(instanceConfig);
}
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
await this._controller.instanceDelete(request.instanceId);
await this._controller.instances.deleteInstance(request.instanceId);
}
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(request.instanceId);
return instance.config.toRemote("control");
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(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 +297,14 @@ export default class ControlConnection extends BaseConnection {
}
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.getRequestInstance(request.instanceId);
let instance = this._controller.instances.getForRequest(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.instanceAssign(request.instanceId, request.hostId);
await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null);
}
async handleInstanceSaveDetailsListRequest() {
@@ -337,8 +338,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.getRequestInstance(request.sourceInstanceId);
let targetInstance = this._controller.getRequestInstance(request.targetInstanceId);
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
let targetHostId = targetInstance.config.get("instance.assigned_host");
if (sourceHostId === null) {
@@ -870,35 +871,33 @@ export default class ControlConnection extends BaseConnection {
}
this._controller.roles.delete(role);
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
for (const user of this._controller.users.values()) {
if (user.removeRole(id)) {
this._controller.userPermissionsUpdated(user);
}
}
}
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
let name = request.name;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
return user;
return user.toUserDetails();
}
async handleUserListRequest(): Promise<lib.User[]> {
return [...this._controller.userManager.users.values()];
async handleUserListRequest(): Promise<lib.UserDetails[]> {
return [...this._controller.users.records.values()].map(user => user.toUserDetails());
}
async handleUserCreateRequest(request: lib.UserCreateRequest) {
let user = this._controller.userManager.createUser(request.name);
this._controller.usersUpdated([user]);
this._controller.users.createUser(request.name);
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.userManager.getByName(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -912,11 +911,10 @@ export default class ControlConnection extends BaseConnection {
controlConnection.connector.terminate();
}
}
this._controller.usersUpdated([user]);
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.userManager.getByName(request.name);
let user = this._controller.users.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -927,35 +925,33 @@ export default class ControlConnection extends BaseConnection {
}
}
user.roleIds = new Set(request.roles);
user.set("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.userManager.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this._controller.usersUpdated([user]);
user.set("isAdmin", admin);
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin));
}
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByNameMutable(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
@@ -963,37 +959,34 @@ export default class ControlConnection extends BaseConnection {
user.isBanned = banned;
user.banReason = reason;
this._controller.usersUpdated([user]);
user.saveRecord();
this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason));
}
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._controller.userManager.createUser(name);
user = this._controller.users.createUser(name);
} else {
throw new lib.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this._controller.usersUpdated([user]);
user.set("isWhitelisted", whitelisted);
this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted));
}
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.userManager.getByName(name);
let user = this._controller.users.getByName(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
user.isDeleted = true;
this._controller.userManager.users.delete(name);
this._controller.usersUpdated([user]);
this._controller.users.deleteUser(user);
if (user.isAdmin) {
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
@@ -1006,14 +999,15 @@ export default class ControlConnection extends BaseConnection {
}
}
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, ControllerUser>) {
/** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */
async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map<string, UserRecord>) {
if (request.importType === "users") {
// Update all fields for all users
const users = request.users as Extract<typeof request.users, { username: string }[]>;
const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase()));
const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase()));
const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase()));
for (const user of this._controller.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user.isAdmin && !admin.has(user.name)) {
user.isAdmin = false;
updated.set(user.id, user);
@@ -1034,7 +1028,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.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user.isBanned && !expected.has(user.name)) {
user.isBanned = false;
user.banReason = "";
@@ -1046,7 +1040,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.userManager.users.values()) {
for (const user of this._controller.users.valuesMutable()) {
if (user[prop] && !expected.has(user.name)) {
user[prop] = false;
updated.set(user.id, user);
@@ -1055,11 +1049,9 @@ 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<ControllerUser["id"], ControllerUser>();
const updated = new Map<User["id"], User>();
if (request.restore) {
// Unban / Demote / Unwhitelist players not on the list
backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType));
@@ -1067,11 +1059,12 @@ 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.userManager.getByName(username);
if (user) { return user; }
const user = this._controller.users.getByNameMutable(username);
if (user) { return updated.get(user.id) ?? user; }
this.user.checkPermission("core.user.create");
return this._controller.userManager.createUser(username);
return this._controller.users.createUser(username);
};
// Merge the imported data with existing data, this is strictly additive
@@ -1129,14 +1122,14 @@ export default class ControlConnection extends BaseConnection {
// Send the necessary update events
const updatedUsers = [...updated.values()];
this._controller.usersUpdated(updatedUsers);
this._controller.users.records.setMany(updatedUsers);
// Resync the host user lists
const adminlist: Set<string> = new Set();
const banlist: Map<string, string> = new Map();
const whitelist: Set<string> = new Set();
for (let user of this._controller.userManager.users.values()) {
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -1160,7 +1153,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.userManager.users.values()) {
for (const user of this._controller.users.values()) {
let send = false;
const userToSend = { username: user.name } as typeof usersToSend[0];
if (user.isBanned) {
@@ -1186,7 +1179,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.userManager.users.values()) {
for (const user of this._controller.users.values()) {
if (user.isBanned) {
if (user.banReason && user.banReason !== "") {
usersToSend.push({ username: user.name, reason: user.banReason });
@@ -1201,7 +1194,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.userManager.users.values()) {
for (const user of this._controller.users.values()) {
if (user[prop]) {
usersToSend.push(user.name);
}
+65 -200
View File
@@ -18,16 +18,17 @@ import * as lib from "@clusterio/lib";
const { logger, Summary, Gauge } = lib;
import HttpCloser from "./HttpCloser";
import InstanceInfo from "./InstanceInfo";
import InstanceRecord from "./InstanceRecord";
import * as metrics from "./metrics";
import * as routes from "./routes";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
import UserManager from "./UserManager";
import WsServer from "./WsServer";
import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import HostRecord from "./HostRecord";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
import InstanceManager from "./InstanceManager";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -79,6 +80,10 @@ 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";
@@ -113,6 +118,12 @@ export default class Controller {
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),
@@ -121,13 +132,13 @@ export default class Controller {
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostInfo.fromJSON.bind(HostInfo),
HostRecord.fromJSON.bind(HostRecord),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
json => InstanceInfo.fromJSON(json, "controller"),
InstanceRecord.fromJSON.bind(InstanceRecord),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
@@ -148,8 +159,11 @@ export default class Controller {
this.migrateRoles,
).bootstrap());
const userManager = new UserManager(config, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
undefined, this.finaliseUsers,
).bootstrap());
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
@@ -172,7 +186,7 @@ export default class Controller {
modPacks,
modStore,
roles,
userManager,
users,
] as const;
}
@@ -192,10 +206,10 @@ export default class Controller {
*/
public recoveryMode: boolean = false,
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** 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 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 save id to save details */
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
/** Mapping of mod pack id to mod pack */
@@ -204,8 +218,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>(),
/** User and roles manager for the cluster */
public userManager = new UserManager(config, roles),
/** Mapping of user id to user record */
users = new lib.SubscribableDatastore<UserRecord>(),
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
@@ -218,6 +232,9 @@ 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]);
});
@@ -237,10 +254,11 @@ 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.on("update", this.instanceDetailsUpdated.bind(this));
this.instances.records.on("update", this.instanceUpdated.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) {
@@ -305,9 +323,6 @@ 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");
@@ -643,16 +658,13 @@ export default class Controller {
this.config.save(),
this.systems.save(),
this.hosts.save(),
this.instances.save(),
this.instances.records.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");
}
@@ -675,7 +687,7 @@ export default class Controller {
});
}
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
static migrateHosts(rawJson: unknown[]): Static<typeof HostRecord.jsonSchema>[] {
let serialized = rawJson as any;
// New format 2.0.0.alpha.19
@@ -686,7 +698,7 @@ export default class Controller {
return serialized;
}
static finaliseHosts(host: HostInfo): HostInfo {
static finaliseHosts(host: HostRecord): HostRecord {
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
@@ -694,8 +706,8 @@ export default class Controller {
return host;
}
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceRecord.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceRecord.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
@@ -704,7 +716,7 @@ export default class Controller {
});
}
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
static finaliseInstances(instance: InstanceRecord): InstanceRecord {
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
@@ -738,6 +750,20 @@ 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 {
@@ -856,7 +882,7 @@ export default class Controller {
);
}
hostsUpdated(hosts: HostInfo[]) {
hostsUpdated(hosts: HostRecord[]) {
let updates = hosts.map(host => host.toHostDetails());
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
@@ -868,75 +894,6 @@ 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.
@@ -946,84 +903,6 @@ 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
*
@@ -1033,7 +912,7 @@ export default class Controller {
* @param instance -
* Instance to nodify the config updated.
*/
async instanceConfigUpdated(instance: InstanceInfo) {
async instanceConfigUpdated(instance: InstanceRecord) {
let hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
let connection = this.wsServer.hostConnections.get(hostId);
@@ -1045,16 +924,7 @@ export default class Controller {
}
}
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[]) {
instanceUpdated(instances: InstanceRecord[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
@@ -1106,17 +976,12 @@ export default class Controller {
return mods.length ? new lib.ModUpdatesEvent(mods) : null;
}
usersUpdated(users: ControllerUser[]) {
const now = Date.now();
for (const user of users) {
user.updatedAtMs = now;
}
this.userManager.dirty = true;
usersUpdated(users: UserRecord[]) {
this.subscriptions.broadcast(new lib.UserUpdatesEvent(users));
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
const users = [...this.userManager.users.values()].filter(
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
return users.length ? new lib.UserUpdatesEvent(users) : null;
@@ -1127,7 +992,7 @@ export default class Controller {
* permissions for this user may have changed.
* @param user - User permisions updated for.
*/
userPermissionsUpdated(user: ControllerUser) {
userPermissionsUpdated(user: Readonly<User>) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user === user) {
controlConnection.send(
@@ -1386,7 +1251,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
let instance = this.getRequestInstance(dst.id);
const instance = this.instances.getForRequest(dst.id);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1421,7 +1286,7 @@ export default class Controller {
}
} else if (dst.type === lib.Address.instance) {
let instance = this.instances.get(dst.id);
const instance = this.instances.get(dst.id);
if (!instance) {
throw new Error(`Instance with ID ${dst.id} does not exist`);
}
@@ -1480,7 +1345,7 @@ export default class Controller {
}
async sendRequestToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>) {
let instance = this.getRequestInstance(request.instanceId);
let instance = this.instances.getForRequest(request.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
throw new lib.RequestError("Instance is not assigned to a host");
@@ -1493,7 +1358,7 @@ export default class Controller {
}
sendEventToHostByInstanceId<T extends { instanceId: number }>(event: T & lib.Event<T>) {
let instance = this.getRequestInstance(event.instanceId);
let instance = this.instances.getForRequest(event.instanceId);
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
return;
-96
View File
@@ -1,96 +0,0 @@
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");
}
}
+34 -50
View File
@@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
import InstanceRecord from "./InstanceRecord";
import HostRecord from "./HostRecord";
/**
@@ -16,7 +16,7 @@ import HostInfo from "./HostInfo";
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostInfo;
private info: HostRecord;
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 HostInfo(
this.info = new HostRecord(
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: InstanceInfo[] = [];
const instances: InstanceRecord[] = [];
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.setMany(instances);
this._controller.instances.records.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
@@ -215,7 +215,7 @@ export default class HostConnection extends BaseConnection {
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.set(instance);
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -231,7 +231,7 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceInfo[] = [];
const instanceUpdates: InstanceRecord[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
@@ -265,32 +265,30 @@ export default class HostConnection extends BaseConnection {
}
instanceConfig.set("instance.assigned_host", this.id);
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
0,
Date.now(),
);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
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);
}
this._controller.instances.setMany(instanceUpdates);
// 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")
)
),
]));
// 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.userManager.users.values()) {
for (let user of this._controller.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -339,21 +337,18 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
if (event.type === "join") {
this._controller.userManager.notifyJoin(user, instanceId);
user.notifyJoin(instanceId);
} else if (event.type === "leave") {
this._controller.userManager.notifyLeave(user, instanceId);
user.notifyLeave(instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
this._controller.usersUpdated([user]);
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
@@ -373,13 +368,9 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
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"],
@@ -393,14 +384,11 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.isBanned = event.banned;
user.banReason = event.reason;
this._controller.usersUpdated([user]);
user.saveRecord();
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"],
@@ -415,13 +403,9 @@ export default class HostConnection extends BaseConnection {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
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,10 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
/** Underlying data class for hosts on the controller */
export default class HostRecord {
constructor(
/** Id of this host */
public id: number,
@@ -33,12 +31,12 @@ export default class HostInfo {
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"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(),
"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()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -56,19 +54,50 @@ export default class HostInfo {
);
}
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
toJSON() {
const json = {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
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,
};
} 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,
);
}
toHostDetails() {
@@ -85,4 +114,3 @@ export default class HostInfo {
);
}
}
+274
View File
@@ -0,0 +1,274 @@
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);
}
}
@@ -1,12 +1,8 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
@@ -19,6 +15,21 @@ export default class InstanceInfo {
this.status = status;
}
get id(): number {
return this.config.get("instance.id");
}
get isDeleted() {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
@@ -28,9 +39,9 @@ export default class InstanceInfo {
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
@@ -39,6 +50,28 @@ export default class InstanceInfo {
);
}
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"),
@@ -53,19 +86,14 @@ export default class InstanceInfo {
);
}
get isDeleted() {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
}
}
+146
View File
@@ -0,0 +1,146 @@
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");
}
}
+105 -75
View File
@@ -3,65 +3,45 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import ControllerUser from "./ControllerUser";
import User from "./User";
import UserRecord from "./UserRecord";
/**
* Manages users and roles
* 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.
*
* @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(
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
) {
}
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
/**
* 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) {
try {
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;
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
}
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"));
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
}
// 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;
@@ -69,66 +49,116 @@ export default class UserManager {
}
}
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")
/* ------------------------------------------------------------------------- */
/* 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,
);
}
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): ControllerUser {
createUser(name: string): User {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
const user = new User(this.records, this._controllerRoles, 0, name, roles);
this.records.set(user);
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: ControllerUser): string {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: user.id },
Buffer.from(this._config.get("controller.auth_secret"), "base64")
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
);
}
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);
}
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
}
this.dirty = true;
}
}
+102
View File
@@ -0,0 +1,102 @@
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.userManager.getByName(tokenPayload.user);
user = this.controller.users.getByNameMutable(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
+1 -1
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.userManager.getByName(tokenPayload.user);
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}