diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index cbd15a73..2fc93982 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -530,12 +530,13 @@ export class Config< if (!def) { throw new InvalidField(`No field named '${name}'`); } + this._checkAccess(name, def, remote, ConfigAccess.write, true); if (remote !== this.location) { this._checkAccess(name, def, this.location, ConfigAccess.write, true); } - // Empty strings are treates as null + // Empty strings are treat as null if (value === "") { value = null; } @@ -576,6 +577,14 @@ export class Config< throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`); } + if (def.extendedValidation) { + try { + def.extendedValidation(value, this); + } catch (err: any) { + throw new InvalidValue(`Failed validation of ${name}: ${err.message}`); + } + } + this._set(name, value); } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 21eea55c..798f90b3 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -1,7 +1,9 @@ // Core definitions for the configuration system import * as classes from "./classes"; +import * as validate from "./validators"; import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; import { Static } from "@sinclair/typebox"; +import { isTargetVersion } from "../data"; type configFromJSON = (...args: Parameters) => T; type configFromFile = (...args: Parameters) => Promise; @@ -73,18 +75,21 @@ export class ControllerConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "mods", + extendedValidation: validate.filePath, }, "controller.database_directory": { title: "Database directory", description: "Directory where item and configuration data is stored.", type: "string", initialValue: "database", + extendedValidation: validate.filePath, }, "controller.autosave_interval": { title: "Autosave Interval", description: "Interval in seconds to autosave data in memory to disk.", type: "number", initialValue: 60, + extendedValidation: validate.greaterThanZero, }, "controller.http_port": { title: "HTTP Port", @@ -93,6 +98,10 @@ export class ControllerConfig extends classes.Config { type: "number", optional: true, initialValue: 8080, + extendedValidation: validate.optional(validate.all( + validate.integer, + validate.greaterThanZero + )), }, "controller.https_port": { title: "HTTPS Port", @@ -100,6 +109,10 @@ export class ControllerConfig extends classes.Config { restartRequired: true, type: "number", optional: true, + extendedValidation: validate.optional(validate.all( + validate.integer, + validate.greaterThanZero + )), }, "controller.bind_address": { title: "Bind Address", @@ -107,6 +120,7 @@ export class ControllerConfig extends classes.Config { restartRequired: true, type: "string", optional: true, + // TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost }, "controller.trusted_proxies": { title: "Trusted Proxies", @@ -114,12 +128,14 @@ export class ControllerConfig extends classes.Config { "Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on", type: "string", optional: true, + // TODO extendedValidation, see parseTrustedProxies }, "controller.public_url": { title: "Public URL", description: "Public facing URL the controller is hosted on, including the protocol.", type: "string", optional: true, + // TODO extendedValidation, valid url including the protocol }, "controller.tls_certificate": { title: "TLS Certificate", @@ -127,6 +143,7 @@ export class ControllerConfig extends classes.Config { restartRequired: true, type: "string", optional: true, + extendedValidation: validate.optional(validate.filePath), }, "controller.tls_private_key": { title: "TLS Private Key", @@ -134,6 +151,7 @@ export class ControllerConfig extends classes.Config { restartRequired: true, type: "string", optional: true, + extendedValidation: validate.optional(validate.filePath), }, "controller.auth_secret": { access: ["controller"], @@ -151,30 +169,35 @@ export class ControllerConfig extends classes.Config { description: "Interval heartbeats are sent out on WebSocket connections.", type: "number", initialValue: 15, + extendedValidation: validate.greaterThanZero, }, "controller.session_timeout": { title: "Session Timeout", description: "Time in seconds before giving up resuming a dropped WebSocket session.", type: "number", initialValue: 60, + extendedValidation: validate.greaterThanZero, }, "controller.metrics_timeout": { title: "Metrics Timeout", description: "Timeout in seconds for metrics gathering from hosts.", type: "number", initialValue: 8, + extendedValidation: validate.greaterThanZero, }, "controller.system_metrics_interval": { title: "System Metrics Interval", description: "Interval in seconds to collect and update system metrics for the Web UI", type: "number", initialValue: 10, + extendedValidation: validate.greaterThanZero, }, "controller.proxy_stream_timeout": { title: "Proxy Stream Timeout", description: "Timeout in seconds for proxy streams to start flowing.", type: "number", initialValue: 15, + extendedValidation: validate.greaterThanZero, }, "controller.factorio_username": { title: "Factorio Username", @@ -190,6 +213,7 @@ export class ControllerConfig extends classes.Config { type: "string", credential: ["controller"], optional: true, + // TODO extendedValidation, lowercase hex characters, maybe length? cannot be set without username }, "controller.share_factorio_credentials_with_hosts": { title: "Share Factorio credentials with Hosts", @@ -219,12 +243,14 @@ export class ControllerConfig extends classes.Config { description: "Duration in minutes to cache mod portal API responses.", type: "number", initialValue: 30, + extendedValidation: validate.greaterThanEqualZero, // Zero is allowed here to disable cache }, "controller.mod_portal_page_size": { title: "Mod Portal Page Size", description: "Maximum number of results per page when querying the Factorio mod portal API.", type: "number", initialValue: 1000, + extendedValidation: validate.greaterThanZero, }, "controller.allow_remote_updates": { description: "When true, allows a remote event to trigger a clusterio update via npm", @@ -300,6 +326,7 @@ export class HostConfig extends classes.Config { description: "Path to directory to look for factorio installs", type: "string", initialValue: "factorio", + extendedValidation: validate.filePath, }, "host.mods_directory": { title: "Mods Directory", @@ -307,18 +334,21 @@ export class HostConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "mods", + extendedValidation: validate.filePath, }, "host.instances_directory": { description: "Path to directory to store instances in.", restartRequired: true, type: "string", initialValue: "instances", + extendedValidation: validate.filePath, }, "host.controller_url": { description: "URL to connect to the controller at", restartRequired: true, type: "string", initialValue: "http://localhost:8080/", + // TODO extendedValidation, valid url including the protocol }, "host.controller_token": { access: ["host"], @@ -326,12 +356,14 @@ export class HostConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "enter token here", + // TODO extendedValidation, valid JWT with matching host id }, "host.tls_ca": { description: "Path to Certificate Authority to validate TLS connection to controller against.", restartRequired: true, type: "string", optional: true, + extendedValidation: validate.optional(validate.filePath), }, "host.public_address": { description: "Public facing address players should connect to in order to join instances on this host", @@ -345,6 +377,7 @@ export class HostConfig extends classes.Config { "ranges separated with a dash.", type: "string", initialValue: "34100-34199", + // TODO extendedValidation, port range }, "host.factorio_username": { title: "Factorio Username", @@ -364,12 +397,14 @@ export class HostConfig extends classes.Config { type: "string", credential: ["host"], optional: true, + // TODO extendedValidation, lowercase hex characters, maybe length? cannot be set without username }, "host.max_reconnect_delay": { title: "Max Reconnect Delay", description: "Maximum delay to wait before attempting to reconnect WebSocket", type: "number", initialValue: 60, + extendedValidation: validate.greaterThanZero, }, "host.allow_remote_updates": { description: "When true, allows a remote event to trigger a clusterio update via npm", @@ -463,6 +498,7 @@ export class InstanceConfig extends classes.Config { type: "number", optional: true, hidden: true, + // Internal value, extendedValidation not used }, "instance.auto_start": { description: "Automatically start this instance when the host hosting it is started up", @@ -480,6 +516,11 @@ export class InstanceConfig extends classes.Config { type: "string", initialValue: "latest", inputComponent: "target_version", + extendedValidation: function(value) { + if (!isTargetVersion(value)) { + throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z"); + } + }, }, "factorio.executable_path": { description: @@ -488,6 +529,7 @@ export class InstanceConfig extends classes.Config { restartRequired: true, type: "string", optional: true, + extendedValidation: validate.optional(validate.filePath), }, "factorio.shutdown_timeout": { description: @@ -495,23 +537,27 @@ export class InstanceConfig extends classes.Config { "the process. Set to 0 to disable.", type: "number", initialValue: 300, + extendedValidation: validate.greaterThanEqualZero, }, "factorio.game_port": { description: "UDP port to run game on, uses a port in host.factorio_port_range if null", restartRequired: true, type: "number", optional: true, + extendedValidation: validate.optional(validate.greaterThanZero), }, "factorio.host_assigned_game_port": { access: ["host"], type: "number", optional: true, + // Internal value, extendedValidation not used }, "factorio.rcon_port": { description: "TCP port to run RCON on, uses a random port if null", restartRequired: true, type: "number", optional: true, + extendedValidation: validate.optional(validate.greaterThanZero), }, "factorio.rcon_password": { credential: ["host", "controller"], @@ -526,6 +572,13 @@ export class InstanceConfig extends classes.Config { "autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.", type: "number", initialValue: 5, + extendedValidation: function(value, config) { + const factorioSettings = config.get("factorio.settings"); + const autosaveSlots = factorioSettings.autosave_slot; + if (typeof autosaveSlots === "number" && value < autosaveSlots) { + throw new Error("Value cannot be less than the number of autosave slots"); + } + }, }, "factorio.mod_pack_id": { title: "Mod Pack", @@ -574,6 +627,7 @@ export class InstanceConfig extends classes.Config { ], type: "object", initialValue: {}, // See create instance handler in controller. + // TODO extendedValidation, we could check keys and types here if we wanted }, "factorio.verbose_logging": { description: "Enable verbose logging on the Factorio server", @@ -616,6 +670,7 @@ export class InstanceConfig extends classes.Config { restartRequired: true, type: "number", initialValue: 5, + extendedValidation: validate.greaterThanZero, }, }; } @@ -650,18 +705,20 @@ export class ControlConfig extends classes.Config { description: "Path to Certificate Authority to validate TLS connection to controller against.", type: "string", optional: true, + extendedValidation: validate.optional(validate.filePath), }, "control.max_reconnect_delay": { title: "Max Reconnect Delay", description: "Maximum delay to wait before attempting to reconnect WebSocket", type: "number", initialValue: 60, + extendedValidation: validate.greaterThanEqualZero, }, }; } -function validateFields( +function validateFieldNames( pluginName: string, fields: Record, ) { @@ -694,7 +751,7 @@ export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginW const fields = pluginInfo[kind]; if (fields) { - validateFields(pluginInfo.name, fields); + validateFieldNames(pluginInfo.name, fields); Object.assign(Config.fieldDefinitions, fields); } } diff --git a/packages/lib/src/config/validators.ts b/packages/lib/src/config/validators.ts new file mode 100644 index 00000000..d4f1dabd --- /dev/null +++ b/packages/lib/src/config/validators.ts @@ -0,0 +1,62 @@ +import path from "node:path"; + +/** Combine multiple validators into a single one. */ +export function all ( + ...validators: ((value: V, config: C) => void)[] +) { + return function (value: V, config: C) { + for (const validator of validators) { + validator(value, config); + } + }; +} + +/** Skip validation of optional fields when null */ +export function optional ( + validator: (value: V, config: C) => void +) { + return function (value: V | null, config: C) { + if (value === null) { return; } + validator(value, config); + }; +} + +/** Value must be greater than the minimum value */ +export function greaterThan(min: number) { + return function (value: number) { + if (value <= min) { + throw new Error(`Value must be greater than ${min}`); + } + }; +} + +/** Value must be greater than 0 */ +export const greaterThanZero = greaterThan(0); + +/** Value must be greater than or equal to the minimum value */ +export function greaterThanEqual(min: number) { + return function (value: number) { + if (value < min) { + throw new Error(`Value must be greater than ${min}`); + } + }; +} + +/** Value must be greater than or equal to 0 */ +export const greaterThanEqualZero = greaterThanEqual(0); + +/** Value must be an integer value */ +export function integer(value: number) { + if (!Number.isInteger(value)) { + throw new Error("Value must be an integer"); + } +} + +/** Value must be a valid file path, it does not need to exist */ +export function filePath(value: string) { + try { + path.resolve(value); + } catch (err: any) { + throw new Error(`Value must be a valid path: ${err.message}`); + } +}