diff --git a/docs/config-system.md b/docs/config-system.md index 058dba45..3b9c8278 100644 --- a/docs/config-system.md +++ b/docs/config-system.md @@ -28,6 +28,16 @@ after the groupName have been set and before the class has been finalized. The `define` method takes an object as argument with the following properties: +**access**: + The locations this config field can be read and modified from. This + is an array of strings which can contain the values `"master"`, + `"slave"` and `"control"`. If a party is missing in this array then + that party will not receive the value of this field when the config + is shared, and will be unable to modify the field. Takes the value + of `defaultAccess` set on the config group class if not passed. + Note that instance config groups should have both `"master"` and + `"slave"` for all of its fields or unexpected behaviour may occur. + **type**: The type of config value this field will support. The supported values are boolean, string, number, and object, and if an attept is diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 62b8c854..1f624fad 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -224,12 +224,13 @@ but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately. -Defining Configuration ----------------------- +Plugin Configuration +-------------------- Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you. You can -take advantage of it by subclassing `PluginConfigGroup`, setting the +take advantage of it by subclassing `PluginConfigGroup`, setting +`defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export. For example in info.js: @@ -237,6 +238,7 @@ the `info.js` export. For example in info.js: const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "foo_frobber"; MasterConfigGroup.define({ name: "level", @@ -261,7 +263,7 @@ for example in the MasterPluginClass: } The same applies for instance configs, replace "master" with "instance" -where appropriate. See [Configuration System](configuration-system.md) +where appropriate. See [Configuration System](config-system.md) for more details on how this system works. diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index beb4159a..a72ab6cd 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -104,13 +104,13 @@ instanceCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); if (args.id) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, })); @@ -792,7 +792,7 @@ async function startControl() { ; logger.verbose(`Loading config from ${args.config}`); - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); try { await controlConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/lib/config/classes.js b/packages/lib/config/classes.js index df5f2ae2..0394564a 100644 --- a/packages/lib/config/classes.js +++ b/packages/lib/config/classes.js @@ -7,6 +7,17 @@ const isDeepStrictEqual = require("../is_deep_strict_equal"); const { basicType } = require("@clusterio/lib/helpers"); +/** + * Invalid Access exception + * + * Exception class for when an access attempt is made to a field that + * does not permitt this access is done. + * + * @extends Error + * @memberof module:lib/config + */ +class InvalidAccess extends Error { }; + /** * Invalid Value exception * @@ -69,13 +80,24 @@ function splitOn(separator, string) { * @memberof module:lib/config */ class Config extends events.EventEmitter { - constructor() { + /** + * Create a new instance of the given config + * + * @param {string} location - + * Location to evaluate access for this instance from + */ + constructor(location) { super(); if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); } + if (typeof location !== "string") { + throw new Error("location must be a string"); + } + + this.location = location; this._groups = new Map(); this._unknownGroups = new Map(); } @@ -83,8 +105,8 @@ class Config extends events.EventEmitter { async init() { for (let [name, GroupClass] of this.constructor._groups) { if (!this._groups.has(name)) { - let group = new GroupClass(); - await group.init(this); + let group = new GroupClass(this); + await group.init(); this._groups.set(name, group); } } @@ -101,7 +123,13 @@ class Config extends events.EventEmitter { } } - async load(serializedConfig) { + /** + * Load config from a serialized object + * + * @param {object} serializedConfig - Serialized config to load. + * @param {string=} location - Location used for access control. + */ + async load(serializedConfig, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let GroupClass = this.constructor._groups.get(serializedGroup.name); @@ -110,8 +138,8 @@ class Config extends events.EventEmitter { continue; } - let group = new GroupClass(); - await group.load(this, serializedGroup); + let group = new GroupClass(this); + await group.load(serializedGroup, location); this._groups.set(group.name, group); } @@ -121,12 +149,13 @@ class Config extends events.EventEmitter { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the config. */ - serialize() { + serialize(location = this.location) { let groups = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { - groups.push(group.serialize()); + groups.push(group.serialize(location)); } return { groups }; @@ -140,28 +169,42 @@ class Config extends events.EventEmitter { * * @param {Object} serializedConfig - Output from .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedConfig, notify) { + update(serializedConfig, notify, location = this.location) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { - group.update(serializedGroup, notify); + group.update(serializedGroup, notify, location); } else { this._unknownGroups.set(serializedGroup.name, serializedGroup); } } } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of the field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.location) { + let [group, field] = splitOn(".", name); + return this.group(group).canAccess(field, location); + } + /** * Get the value for a config field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} the value for the field. */ - get(name) { + get(name, location = this.location) { let [group, field] = splitOn(".", name); - return this.group(group).get(field); + return this.group(group).get(field, location); } /** @@ -169,10 +212,11 @@ class Config extends events.EventEmitter { * * @param {string} name - Name of field to set. * @param {*} value - the value to set on field + * @param {string=} location - Location used for access control. */ - set(name, value) { + set(name, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).set(field, value); + this.group(group).set(field, value, location); } /** @@ -181,10 +225,11 @@ class Config extends events.EventEmitter { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.location) { let [group, field] = splitOn(".", name); - this.group(group).setProp(field, prop, value); + this.group(group).setProp(field, prop, value, location); } /** @@ -280,15 +325,42 @@ class ConfigGroup { * * After the creation of the new config group you have to call * either .init() or .load() on it in order to fully initialize it. + * + * @param {module:lib/config.Config} config - + * Parent config for this group instance. */ - constructor() { + constructor(config) { if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`); } this._fields = new Map(); this._unknownFields = new Map(); - this._config = null; + this.config = config; + } + + /** + * Check access for a field + * + * @param {object} def - definition for for the field to check. + * @param {string} remote - location of remote access to check for. + * @param {boolean} error - throw on failure to pass check. + * @throws {module:lib/config.InvalidAccess} + * if access is not granted and error is true. + * @returns {boolean} true if access is granted + * @private + */ + _checkAccess(def, remote, error) { + for (let loc of [remote, this.config.location]) { + if (!def.access.includes(loc)) { + if (error) { + throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`); + } else { + return false; + } + } + } + return true; } /** @@ -296,16 +368,15 @@ class ConfigGroup { * * Computes and assigns the initial values for all fields of the * config group. - * - * @param {module:lib/config.Config} config - Config group belongs to. */ - async init(config) { - this._config = config; - + async init() { for (let [name, def] of this.constructor._definitions) { if (this._fields.has(name)) { continue; } + if (!def.access.includes(this.config.location)) { + continue; + } let value = null; if (typeof def.initial_value === "function") { @@ -326,13 +397,13 @@ class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. * - * @param {module:lib/config.Config} config - Config group belongs to. * @param {Object} serializedGroup - * Result from a previous call to .serialize(). + * @param {string=} location - Location used for access control. */ - async load(config, serializedGroup) { - this.update(serializedGroup, false); - await this.init(config); + async load(serializedGroup, location = this.config.location) { + this.update(serializedGroup, false, location); + await this.init(); } /** @@ -344,16 +415,34 @@ class ConfigGroup { return this.constructor.groupName; } + /** + * Check if field can be accessed from the given location + * + * @param {string} name - Name of field to check. + * @param {string=} location - Location used for access control. + * @returns {boolean} true if field is accessible + */ + canAccess(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { + throw new InvalidField(`No field named '${name}'`); + } + return this._checkAccess(def, location, false); + } + /** * Get value for field * * @param {string} name - Name of field to get. + * @param {string=} location - Location used for access control. * @returns {*} Value stored for the field. */ - get(name) { - if (!this.constructor._definitions.has(name)) { + get(name, location = this.config.location) { + let def = this.constructor._definitions.get(name); + if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); return this._fields.get(name); } @@ -366,14 +455,16 @@ class ConfigGroup { * * @param {string} name - Name of field to set. * @param {*} value - Value to set for field. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if value is not allowed for the field. */ - set(name, value) { + set(name, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); // Empty strings are treates as null if (value === "") { @@ -418,8 +509,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -432,14 +523,16 @@ class ConfigGroup { * @param {string} name - Name of field to set property on. * @param {string} prop - Name of property to set on field. * @param {*} value - the value to set the property to. + * @param {string=} location - Location used for access control. * @throws {module:lib/config.InvalidField} if field is not defined. * @throws {module:lib/config.InvalidValue} if field is not an object. */ - setProp(name, prop, value) { + setProp(name, prop, value, location = this.config.location) { let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(def, location, true); if (def.type !== "object") { throw new InvalidValue(`Cannot set property on non-object field '${name}'`); @@ -454,8 +547,8 @@ class ConfigGroup { delete updated[prop]; } this._fields.set(name, updated); - if (this._config && !isDeepStrictEqual(updated, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (this.config && !isDeepStrictEqual(updated, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } @@ -468,8 +561,9 @@ class ConfigGroup { * @param {Object} serializedGroup - * Result from a previous call to .serialize(). * @param {boolean} notify - Invoke fieldChanged events if true. + * @param {string=} location - Location used for access control. */ - update(serializedGroup, notify) { + update(serializedGroup, notify, location = this.config.location) { if (basicType(serializedGroup) !== "object") { throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); } @@ -489,6 +583,10 @@ class ConfigGroup { continue; } + if (!this._checkAccess(def, location, false)) { + continue; + } + if (def.optional) { if (value !== null && basicType(value) !== def.type) { continue; @@ -504,8 +602,8 @@ class ConfigGroup { let prev = this._fields.get(name); this._fields.set(name, value); - if (notify && this._config && !isDeepStrictEqual(value, prev)) { - this._config.emit("fieldChanged", this, name, prev); + if (notify && this.config && !isDeepStrictEqual(value, prev)) { + this.config.emit("fieldChanged", this, name, prev); } } } @@ -513,11 +611,17 @@ class ConfigGroup { /** * Serialize the config to a plain JavaScript object * + * @param {string=} location - Location used for access control. * @returns {Object} JSON serializable representation of the group. */ - serialize() { + serialize(location = this.config.location) { let fields = {}; for (let [name, value] of this._fields) { + let def = this.constructor._definitions.get(name); + if (!this._checkAccess(def, location, false)) { + continue; + } + fields[name] = value; } @@ -557,6 +661,9 @@ class ConfigGroup { * Define a new configuration field * * @param {Object} item - Field definition. + * @param {Array=} item.access - + * Array of strings declaring where this config entry can be read + * and modified from. * @param {string} item.type - * string declaring the type of the config value, supports * boolean, string, number and object. @@ -609,6 +716,12 @@ class ConfigGroup { } break; + case "access": + if (valueType !== "array") { + throw new Error("access must be an array"); + } + break; + case "enum": if (valueType !== "array") { throw new Error("enum must be an array"); @@ -655,11 +768,16 @@ class ConfigGroup { } let computed = { + access: this.defaultAccess, optional: false, fullName: `${this.groupName}.${item.name}`, ...item, }; + if (!computed.access) { + throw new Error(`access not set and no defaultAccess defined for ${this.name}`); + } + if (!computed.optional && computed.initial_value === undefined) { throw new Error(`Non-optional field ${item.name} needs an initial_value`); } @@ -706,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup { module.exports = { + InvalidAccess, InvalidField, InvalidValue, diff --git a/packages/lib/config/definitions.js b/packages/lib/config/definitions.js index 5c6fff11..5b5aacc8 100644 --- a/packages/lib/config/definitions.js +++ b/packages/lib/config/definitions.js @@ -10,6 +10,7 @@ const classes = require("./classes"); * @memberof module:lib/config */ class MasterGroup extends classes.ConfigGroup { } +MasterGroup.defaultAccess = ["master", "slave", "control"]; MasterGroup.groupName = "master"; MasterGroup.define({ name: "name", @@ -69,6 +70,7 @@ MasterGroup.define({ optional: true, }); MasterGroup.define({ + access: ["master"], name: "auth_secret", title: "Master Authentication Secret", description: @@ -124,6 +126,7 @@ MasterConfig.registerGroup(MasterGroup); * @memberof module:lib/config */ class SlaveGroup extends classes.ConfigGroup {} +SlaveGroup.defaultAccess = ["master", "slave", "control"]; SlaveGroup.groupName = "slave"; SlaveGroup.define({ name: "name", @@ -197,6 +200,7 @@ SlaveConfig.registerGroup(SlaveGroup); * @memberof module:lib/config */ class InstanceGroup extends classes.ConfigGroup { } +InstanceGroup.defaultAccess = ["master", "slave", "control"]; InstanceGroup.groupName = "instance"; InstanceGroup.define({ name: "name", @@ -228,6 +232,7 @@ InstanceGroup.finalize(); * @memberof module:lib/config */ class FactorioGroup extends classes.ConfigGroup { } +FactorioGroup.defaultAccess = ["master", "slave", "control"]; FactorioGroup.groupName = "factorio"; FactorioGroup.define({ name: "version", @@ -325,6 +330,7 @@ InstanceConfig.registerGroup(FactorioGroup); * @memberof module:lib/config */ class ControlGroup extends classes.ConfigGroup {} +ControlGroup.defaultAccess = ["control"]; ControlGroup.groupName = "control"; ControlGroup.define({ name: "master_url", @@ -390,6 +396,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class MasterConfigGroup extends classes.PluginConfigGroup { } + MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = pluginInfo.name; MasterConfigGroup.finalize(); MasterConfig.registerGroup(MasterConfigGroup); @@ -402,6 +409,7 @@ function registerPluginConfigGroups(pluginInfos) { } else { class InstanceConfigGroup extends classes.PluginConfigGroup { } + InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = pluginInfo.name; InstanceConfigGroup.finalize(); InstanceConfig.registerGroup(InstanceConfigGroup); diff --git a/packages/master/master.js b/packages/master/master.js index e5a2a1aa..9bb49643 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -372,7 +372,7 @@ async function loadInstances(databaseDirectory, file) { try { let serialized = JSON.parse(await fs.readFile(filePath)); for (let serializedConfig of serialized) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(serializedConfig); let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown"; instances.set(instanceConfig.get("instance.id"), { config: instanceConfig, status }); @@ -707,7 +707,7 @@ class ControlConnection extends BaseConnection { } async createSlaveConfigRequestHandler(message) { - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("control"); await slaveConfig.init(); slaveConfig.set("slave.master_url", getMasterUrl()); @@ -739,7 +739,7 @@ class ControlConnection extends BaseConnection { // XXX should probably add a hook for slave reuqests? async createInstanceRequestHandler(message) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("master"); await instanceConfig.load(message.data.serialized_config); let instanceId = instanceConfig.get("instance.id"); @@ -795,7 +795,7 @@ class ControlConnection extends BaseConnection { } return { - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("control"), }; } @@ -806,7 +806,7 @@ class ControlConnection extends BaseConnection { if (connection) { await libLink.messages.assignInstance.send(connection, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -827,7 +827,7 @@ class ControlConnection extends BaseConnection { throw new libErrors.RequestError("Setting instance.id is not supported"); } - instance.config.set(message.data.field, message.data.value); + instance.config.set(message.data.field, message.data.value, "control"); await this.updateInstanceConfig(instance); } @@ -838,7 +838,7 @@ class ControlConnection extends BaseConnection { } let { field, prop, value } = message.data; - instance.config.setProp(field, prop, value); + instance.config.setProp(field, prop, value, "control"); await this.updateInstanceConfig(instance); } @@ -877,7 +877,7 @@ class ControlConnection extends BaseConnection { if (slave_id !== null) { await libLink.messages.assignInstance.send(newSlaveConnection, { instance_id, - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } @@ -1202,15 +1202,15 @@ class SlaveConnection extends BaseConnection { if (instance.config.get("instance.assigned_slave") === this._id) { await libLink.messages.assignInstance.send(this, { instance_id: instance.config.get("instance.id"), - serialized_config: instance.config.serialize(), + serialized_config: instance.config.serialize("slave"), }); } } // Assign instances the slave has but master does not for (let instance of message.data.instances) { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(instance.serialized_config); + let instanceConfig = new libConfig.InstanceConfig("master"); + await instanceConfig.load(instance.serialized_config, "slave"); let masterInstance = db.instances.get(instanceConfig.get("instance.id")); if (masterInstance) { @@ -1239,7 +1239,7 @@ class SlaveConnection extends BaseConnection { }); await libLink.messages.assignInstance.send(this, { instance_id: instanceConfig.get("instance.id"), - serialized_config: instanceConfig.serialize(), + serialized_config: instanceConfig.serialize("slave"), }); } @@ -1797,7 +1797,7 @@ async function handleBootstrapCommand(args) { process.exitCode = 1; return; } - let controlConfig = new libConfig.ControlConfig(); + let controlConfig = new libConfig.ControlConfig("control"); await controlConfig.init(); controlConfig.set("control.master_url", getMasterUrl()); @@ -1909,7 +1909,7 @@ async function initialize() { masterConfigPath = args.config; logger.info(`Loading config from ${masterConfigPath}`); - masterConfig = new libConfig.MasterConfig(); + masterConfig = new libConfig.MasterConfig("master"); try { await masterConfig.load(JSON.parse(await fs.readFile(masterConfigPath))); diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 4e2ad729..5461a2c3 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -799,7 +799,7 @@ async function discoverInstances(instancesDir) { let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); let configPath = path.join(instancesDir, entry.name, "instance.json"); try { @@ -1175,17 +1175,17 @@ class Slave extends libLink.Link { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); logger.verbose(`Updated config for ${instanceInfo.path}`); } else { instanceInfo = this.discoveredInstanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true); + instanceInfo.config.update(serialized_config, true, "master"); } else { - let instanceConfig = new libConfig.InstanceConfig(); - await instanceConfig.load(serialized_config); + let instanceConfig = new libConfig.InstanceConfig("slave"); + await instanceConfig.load(serialized_config, "master"); // XXX: race condition on multiple simultanious calls let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name")); @@ -1348,7 +1348,7 @@ class Slave extends libLink.Link { for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push({ - serialized_config: instanceInfo.config.serialize(), + serialized_config: instanceInfo.config.serialize("master"), status: instanceConnection ? instanceConnection.status : "stopped", }); } @@ -1561,7 +1561,7 @@ async function startSlave() { libConfig.finalizeConfigs(); logger.info(`Loading config from ${args.config}`); - let slaveConfig = new libConfig.SlaveConfig(); + let slaveConfig = new libConfig.SlaveConfig("slave"); try { await slaveConfig.load(JSON.parse(await fs.readFile(args.config))); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index af981afa..6b15a635 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -333,14 +333,14 @@ function InstanceConfigTree(props) { instance_id: props.id, }); - let newConfig = new libConfig.InstanceConfig(); + let newConfig = new libConfig.InstanceConfig("control"); await newConfig.load(result.serialized_config); setConfig(newConfig); } useEffect(() => { updateConfig(); }, [props.id]); - function renderInput(group, def) { + function renderInput(def) { if (def.type === "boolean") { return ; } @@ -387,6 +387,10 @@ function InstanceConfigTree(props) { }; for (let def of GroupClass.definitions.values()) { + if (!group.canAccess(def.name)) { + continue; + } + let value = group.get(def.name); let fieldName = `${group.name}.${def.name}`; let childNode = { @@ -445,7 +449,7 @@ function InstanceConfigTree(props) { let propName = form.getFieldValue(`${newPropPath}.name`); let propValue = form.getFieldValue(`${newPropPath}.value`); if (!Object.prototype.hasOwnProperty.call(value, propName)) { - let newConfig = new libConfig.InstanceConfig(); + let newConfig = new libConfig.InstanceConfig("control"); await newConfig.load(config.serialize()); newConfig.setProp(fieldName, propName, null); setConfig(newConfig); @@ -475,7 +479,7 @@ function InstanceConfigTree(props) { tooltip={def.description} valuePropName={def.type === "boolean" ? "checked" : "value"} > - {renderInput(group, def)} + {renderInput(def)} ; initialValues[fieldName] = value; } diff --git a/packages/web_ui/src/components/InstancesPage.jsx b/packages/web_ui/src/components/InstancesPage.jsx index 39315a10..0d4f9031 100644 --- a/packages/web_ui/src/components/InstancesPage.jsx +++ b/packages/web_ui/src/components/InstancesPage.jsx @@ -33,10 +33,10 @@ export default function InstancesPage() { title: "Name", }], insert: async args => { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("control"); await instanceConfig.init(); instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize(); + let serialized_config = instanceConfig.serialize("master"); let response = await libLink.messages.createInstance.send(control, { serialized_config }); }, }} diff --git a/plugins/statistics_exporter/info.js b/plugins/statistics_exporter/info.js index c89f5c1e..001f039e 100644 --- a/plugins/statistics_exporter/info.js +++ b/plugins/statistics_exporter/info.js @@ -2,6 +2,7 @@ const libConfig = require("@clusterio/lib/config"); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "statistics_exporter"; InstanceConfigGroup.define({ name: "command_timeout", diff --git a/plugins/subspace_storage/info.js b/plugins/subspace_storage/info.js index b60fe71d..52534a65 100644 --- a/plugins/subspace_storage/info.js +++ b/plugins/subspace_storage/info.js @@ -3,6 +3,7 @@ const libLink = require("@clusterio/lib/link"); const libConfig = require("@clusterio/lib/config"); class MasterConfigGroup extends libConfig.PluginConfigGroup {} +MasterConfigGroup.defaultAccess = ["master", "slave", "control"]; MasterConfigGroup.groupName = "subspace_storage"; MasterConfigGroup.define({ name: "autosave_interval", @@ -29,6 +30,7 @@ MasterConfigGroup.define({ MasterConfigGroup.finalize(); class InstanceConfigGroup extends libConfig.PluginConfigGroup {} +InstanceConfigGroup.defaultAccess = ["master", "slave", "control"]; InstanceConfigGroup.groupName = "subspace_storage"; InstanceConfigGroup.define({ name: "log_item_transfers", diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index e566713e..49d5d224 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -1,11 +1,15 @@ "use strict"; const classes = require("@clusterio/lib/config/classes"); const assert = require("assert").strict; +const events = require("events"); describe("lib/config/classes", function() { describe("ConfigGroup", function() { + let mockConfig = new events.EventEmitter(); + mockConfig.location = "local"; class TestGroup extends classes.ConfigGroup { } + TestGroup.defaultAccess = ["local", "remote"]; TestGroup.groupName = "test_group"; describe(".define()", function() { it("should throw if groupName is not set", function() { @@ -23,6 +27,10 @@ describe("lib/config/classes", function() { }); it("should throw on invalid properties", function() { for (let [def, error] of [ + [ + { access: "local", name: "a", type: "string", optional: true }, + "access must be an array", + ], [ { name: "a", type: "invalid", optional: true }, "invalid is not a valid type", @@ -59,6 +67,14 @@ describe("lib/config/classes", function() { assert.throws(() => TestGroup.define(def), new Error(error)); } }); + it("should throw on defaultAccess not set", function() { + class NoAccessGroup extends classes.ConfigGroup { } + NoAccessGroup.groupName = "no_access"; + assert.throws( + () => NoAccessGroup.define({ name: "a", type: "string", optional: true }), + new Error("access not set and no defaultAccess defined for NoAccessGroup") + ); + }); it("should throw on missing properties", function() { assert.throws( () => TestGroup.define({ type: "string", optional: true }), @@ -74,11 +90,13 @@ describe("lib/config/classes", function() { TestGroup.define({ name: "func", type: "number", initial_value: () => 42 }); TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true }); TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true }); + TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true }); assert(TestGroup._definitions.has("test"), "field test was not defined"); assert(TestGroup._definitions.has("func"), "field func was not defined"); assert(TestGroup._definitions.has("bool"), "field bool was not defined"); assert(TestGroup._definitions.has("json"), "field json was not defined"); + assert(TestGroup._definitions.has("priv"), "field priv was not defined"); }); it("should throw on already defined entries", function() { @@ -90,6 +108,7 @@ describe("lib/config/classes", function() { it("should set the entries on the class", function() { let field = { + access: ["local", "remote"], type: "string", name: "enum", title: "Enum", @@ -122,7 +141,7 @@ describe("lib/config/classes", function() { }); it("should throw when creating an instance before finalize", function() { assert.throws( - () => new TestGroup(), + () => new TestGroup(mockConfig), new Error("Cannot instantiate incomplete ConfigGroup class TestGroup") ); }); @@ -141,24 +160,31 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should create an instance", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); }); }); describe(".init()", function() { it("should initialize all fields with defaults", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.get("enum"), "b"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); + assert.equal(testInstance.get("priv"), null); + }); + it("should not initalize inaccessible fields", async function() { + let testInstance = new TestGroup({ location: "remote" }); + await testInstance.init(); + assert.equal(testInstance._fields.get("enum"), "b"); + assert.equal(testInstance._fields.get("priv"), undefined); }); }); describe("get name", function() { it("should give the name of the group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.equal(testInstance.name, "test_group"); }); @@ -166,10 +192,26 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic group", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { + name: "test_group", + fields: { + enum: "b", + test: null, + func: 42, + bool: false, + json: {}, + priv: null, + }, + }); + }); + it("should leave out inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + + assert.deepEqual(testInstance.serialize("remote"), { name: "test_group", fields: { enum: "b", @@ -184,29 +226,36 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should load a basic group", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "c", test: "blah", func: 22, bool: null, json: { valid: true }, + priv: "bar", }}); assert.equal(testInstance.get("enum"), "c"); assert.equal(testInstance.get("test"), "blah"); assert.equal(testInstance.get("func"), 22); assert.equal(testInstance.get("bool"), null); + assert.equal(testInstance.get("priv"), "bar"); assert.deepEqual(testInstance.get("json"), { valid: true }); }); it("should load defaults for missing serialized fields", async function() { - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { enum: "a" } }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a" } }); assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should ignore inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { priv: "bad" } }, "remote"); + assert.equal(testInstance.get("priv"), null); + }); it("should preserve unknown fields when serialized back", async function() { let testFields = { @@ -215,13 +264,14 @@ describe("lib/config/classes", function() { func: 50, bool: false, json: {}, + priv: null, alpha: null, beta: "decay", gamma: 99, }; - let testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: testFields }); + let testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: testFields }); assert.deepEqual(testInstance.serialize().fields, testFields); }); @@ -229,7 +279,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should skip updating invalid values", async function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); await testInstance.init(); testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false); @@ -237,9 +287,19 @@ describe("lib/config/classes", function() { assert.equal(testInstance.get("test"), null); assert.equal(testInstance.get("func"), 42); }); + it("should skip updating inaccessible fields", async function() { + let testInstance = new TestGroup(mockConfig); + await testInstance.init(); + testInstance.update( + { name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote" + ); + + assert.equal(testInstance.get("test"), "a"); + assert.equal(testInstance.get("priv"), null); + }); it("should throw on invalid input", function() { - let testInstance = new TestGroup(); + let testInstance = new TestGroup(mockConfig); assert.throws( () => testInstance.update(), new Error("Expected object, not undefined for ConfigGroup") @@ -263,13 +323,52 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should return true for fields that are accessible", function() { + for (let field of ["enum", "test", "func", "bool", "json"]) { + assert.equal(testInstance.canAccess(field), true); + assert.equal(testInstance.canAccess(field, "local"), true); + assert.equal(testInstance.canAccess(field, "remote"), true); + } + assert.equal(testInstance.canAccess("priv"), true); + assert.equal(testInstance.canAccess("priv", "local"), true); + assert.equal(testInstance.canAccess("priv", "remote"), false); + }); + }); + + describe(".get()", function() { + let testInstance; + before(async function() { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { + enum: "a", priv: "blah", func: 27, + }}); + }); + it("should retreive value of field", function() { + assert.equal(testInstance.get("enum"), "a"); + assert.equal(testInstance.get("priv"), "blah"); + assert.equal(testInstance.get("func"), 27); + }); + it("should throw if field is inaccessible", function() { + assert.throws( + () => testInstance.get("priv", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + }); + describe(".set()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { - enum: "a", test: "blah", func: 27, - }}); + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}); }); it("should throw if field does not exist", function() { @@ -279,6 +378,13 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.set("priv", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not in enum", function() { assert.throws( () => testInstance.set("enum", "bar"), @@ -366,8 +472,8 @@ describe("lib/config/classes", function() { describe(".setProp()", function() { let testInstance; before(async function() { - testInstance = new TestGroup(); - await testInstance.load(null, { name: "test_group", fields: { + testInstance = new TestGroup(mockConfig); + await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27, }}); }); @@ -379,9 +485,16 @@ describe("lib/config/classes", function() { ); }); + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.setProp("priv", "prop", "bad", "remote"), + new classes.InvalidAccess("Field 'priv' is not accessible from remote") + ); + }); + it("should throw if field is not an object", function() { assert.throws( - () => testInstance.setProp("enum", "a"), + () => testInstance.setProp("enum", "prop", "a"), new classes.InvalidField("Cannot set property on non-object field 'enum'") ); }); @@ -408,8 +521,10 @@ describe("lib/config/classes", function() { describe("Config", function() { class AlphaGroup extends classes.ConfigGroup { } + AlphaGroup.defaultAccess = ["local", "remote"]; AlphaGroup.groupName = "alpha"; class BetaGroup extends classes.ConfigGroup { } + BetaGroup.defaultAccess = ["local", "remote"]; BetaGroup.groupName = "beta"; class TestConfig extends classes.Config { } @@ -454,20 +569,26 @@ describe("lib/config/classes", function() { describe("constructor", function() { it("should throw if config is not finalized", function() { assert.throws( - () => new TestConfig(), + () => new TestConfig("local"), new Error("Cannot instantiate incomplete Config class TestConfig") ); }); it("should construct a finalized class", function() { TestConfig.finalize(); - let config = new TestConfig(); + let config = new TestConfig("local"); assert(config); }); + it("should throw if location is not passed", function() { + assert.throws( + () => new TestConfig(), + new Error("location must be a string") + ); + }); }); describe(".init()", function() { it("should initialize all fields", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.equal(testInstance.get("alpha.foo"), null); assert.deepEqual(testInstance.get("beta.bar"), {}); @@ -476,7 +597,7 @@ describe("lib/config/classes", function() { describe(".serialize()", function() { it("should serialize a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.deepEqual(testInstance.serialize(), { groups: [ @@ -495,7 +616,7 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should reject on incorrect input passed", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await assert.rejects( testInstance.load(null), new Error("Expected object, not null for config") @@ -511,7 +632,7 @@ describe("lib/config/classes", function() { }); it("should load defaults for missing serialized groups", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: [{ name: "alpha", fields: { foo: "a" }}] }); assert.equal(testInstance.get("alpha.foo"), "a"); @@ -534,7 +655,7 @@ describe("lib/config/classes", function() { }, ]; - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.load({ groups: testGroups }); assert.deepEqual(testInstance.serialize().groups, testGroups); @@ -543,7 +664,7 @@ describe("lib/config/classes", function() { describe(".update()", function() { it("should update a basic config", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.update({ groups: [ { @@ -577,7 +698,7 @@ describe("lib/config/classes", function() { describe(".group()", function() { it("should throw if instance is not initialized", function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); assert.throws( () => testInstance.group("test"), new Error("TestConfig instance is uninitialized") @@ -585,16 +706,39 @@ describe("lib/config/classes", function() { }); }); + describe(".canAccess()", function() { + it("should throw if group does not exist", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'")); + }); + + it("should throw if no field is specified", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''")); + }); + + it("should return true for an accessible field", async function() { + let testInstance = new TestConfig("local"); + await testInstance.init(); + + assert.equal(testInstance.canAccess("alpha.foo"), true); + }); + }); + describe(".get()", function() { it("should throw if group does not exist", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'")); }); it("should throw if no field is specified", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); assert.throws(() => testInstance.get("alpha"), new Error("No field named ''")); @@ -603,7 +747,7 @@ describe("lib/config/classes", function() { describe(".set()", function() { it("should set the value", async function() { - let testInstance = new TestConfig(); + let testInstance = new TestConfig("local"); await testInstance.init(); testInstance.set("alpha.foo", "new value"); @@ -621,7 +765,7 @@ describe("lib/config/classes", function() { let testInstance; let called; beforeEach(async function() { - testInstance = new TestConfig(); + testInstance = new TestConfig("local"); await testInstance.init(); called = false; diff --git a/test/slave.js b/test/slave.js index f436b312..e54e5a0b 100644 --- a/test/slave.js +++ b/test/slave.js @@ -15,7 +15,7 @@ describe("Slave testing", function() { describe("class Instance", function() { let instance; before(async function() { - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "foo"); instance = new slave._Instance({}, new libLink.VirtualConnector(), "dir", "factorioDir", instanceConfig); @@ -96,7 +96,7 @@ describe("Slave testing", function() { await fs.outputFile(path.join(testDir, "shared", "mod_b.zip"), "b"); await fs.outputFile(path.join(testDir, "shared", "mod.dat"), "c"); - let instanceConfig = new libConfig.InstanceConfig(); + let instanceConfig = new libConfig.InstanceConfig("slave"); await instanceConfig.init(); instanceConfig.set("instance.name", "test"); instance = new slave._Instance( @@ -149,7 +149,7 @@ describe("Slave testing", function() { let instancePath = path.join("test", "file", "instances"); let instanceInfos = await slave._discoverInstances(instancePath); - let referenceConfig = new libConfig.InstanceConfig(); + let referenceConfig = new libConfig.InstanceConfig("slave"); await referenceConfig.init(); referenceConfig.set("instance.id", 1); referenceConfig.set("instance.name", "test");