//! Server configuration: a TOML file, overridable from the command line and //! editable at runtime by admins (`config set`), which also updates the file //! while preserving its comments and layout. use std::{ path::{Path, PathBuf}, time::Duration, }; use bevy::prelude::*; use serde::{Deserialize, Serialize}; use toml_edit::DocumentMut; use crate::{ ServerSettings, persistence::{StorageSettings, WorldSelector}, }; /// Everything a server operator can configure. #[derive(Resource, Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ServerConfig { /// Shown to players when they join. pub name: String, pub motd: String, /// UDP port. pub port: u16, pub max_players: usize, /// Required to join when not empty. pub password: String, /// Tiled map used to create new worlds. pub map: String, /// Seconds between autosaves. pub autosave_secs: u64, pub database: DatabaseConfig, pub access: AccessConfig, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct DatabaseConfig { /// Persist the world. When false, the world is reset on every start. pub save: bool, /// sqlx URL: `sqlite://path` or `postgres://user:password@host/db`. pub url: String, /// World name (created if missing) or UUID. pub world: String, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AccessConfig { /// Player ids with admin rights. pub admins: Vec, /// Only players in `whitelist` may join. pub whitelist_enabled: bool, /// Player names or ids. pub whitelist: Vec, /// Banned player names or ids. pub banned: Vec, } pub const DEFAULT_CONFIG_PATH: &str = "server.toml"; impl Default for ServerConfig { fn default() -> Self { Self { name: "Station".into(), motd: String::new(), port: game_core::DEFAULT_PORT, max_players: 32, password: String::new(), map: game_core::DEFAULT_MAP.into(), autosave_secs: 60, database: DatabaseConfig::default(), access: AccessConfig::default(), } } } impl Default for DatabaseConfig { fn default() -> Self { Self { save: true, url: "sqlite://saves/server.sqlite".into(), world: "main".into(), } } } /// Written when the configuration file does not exist yet. Must match [`ServerConfig::default`]. pub const DEFAULT_CONFIG: &str = r#"# Station server configuration. # Command line options override these values for a single run. # Admins can change values at runtime with `config set KEY VALUE`, which also updates this file. # Server name and message of the day, shown to players when they join. name = "Station" motd = "" # UDP port to listen on. port = 5000 max_players = 32 # Players must enter this password to join. Empty = no password. # Note: connections are not encrypted. password = "" # Tiled map (relative to the assets directory) used to create new worlds. map = "maps/station.tmx" # Seconds between autosaves. autosave_secs = 60 [database] # Persist the world. When false, the world is reset on every start. save = true # sqlx URL: "sqlite://path/to/file.sqlite" or "postgres://user:password@host/database". url = "sqlite://saves/server.sqlite" # World name (created if missing) or world UUID. Many worlds can share one database. world = "main" [access] # Player ids (shown in the server log when a player joins) with admin rights. admins = [] # When enabled, only whitelisted player names or ids may join. whitelist_enabled = false whitelist = [] # Banned player names or ids. banned = [] "#; #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("{0}: {1}")] Io(PathBuf, std::io::Error), #[error("{0}: {1}")] Parse(PathBuf, String), #[error("unknown setting `{0}`")] UnknownKey(String), #[error("invalid value for `{0}`: {1}")] InvalidValue(String, String), } impl ServerConfig { /// Simulation settings derived from this configuration. pub fn settings(&self) -> ServerSettings { ServerSettings { map: self.map.clone(), storage: self.database.save.then(|| StorageSettings { database_url: self.database.url.clone(), world: WorldSelector::parse(&self.database.world), }), autosave_interval: Duration::from_secs(self.autosave_secs.max(1)), } } fn to_document(&self) -> DocumentMut { toml::to_string(self) .expect("config serializes") .parse() .expect("serialized config parses") } /// Current value of a dotted key (e.g. `database.world`) in TOML syntax. pub fn get(&self, key: &str) -> Result { let doc = self.to_document(); let mut item = doc.as_item(); for part in key.split('.') { item = item .get(part) .ok_or_else(|| ConfigError::UnknownKey(key.into()))?; } Ok(match item { toml_edit::Item::Value(value) => value.to_string().trim().to_owned(), other => other.to_string().trim().to_owned(), }) } /// Sets a dotted key. `value` is TOML (`42`, `true`, `["a"]`); anything that /// isn't valid TOML is taken as a string. pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> { let value = parse_value(value); let mut doc = self.to_document(); set_in_document(&mut doc, key, value)?; *self = from_document(&doc).map_err(|e| invalid(key, e))?; Ok(()) } } fn invalid(key: &str, e: String) -> ConfigError { if e.contains("unknown field") { ConfigError::UnknownKey(key.into()) } else { ConfigError::InvalidValue(key.into(), e) } } fn parse_value(value: &str) -> toml_edit::Value { value .trim() .parse() .unwrap_or_else(|_| toml_edit::Value::from(value)) } fn from_document(doc: &DocumentMut) -> Result { toml::from_str(&doc.to_string()).map_err(|e| e.message().to_owned()) } fn set_in_document( doc: &mut DocumentMut, key: &str, value: toml_edit::Value, ) -> Result<(), ConfigError> { let parts: Vec<&str> = key.split('.').collect(); let (last, tables) = parts.split_last().expect("split yields one part"); let mut table = doc.as_table_mut(); for part in tables { table = table .entry(part) .or_insert_with(toml_edit::table) .as_table_mut() .ok_or_else(|| ConfigError::UnknownKey(key.into()))?; } match table.get_mut(last) { // Keeps the comments around the existing value. Some(toml_edit::Item::Value(old)) => { let decor = old.decor().clone(); *old = value; *old.decor_mut() = decor; } Some(_) => return Err(ConfigError::UnknownKey(key.into())), None => { table.insert(last, toml_edit::value(value)); } } Ok(()) } /// The configuration file backing a [`ServerConfig`]. #[derive(Resource, Debug)] pub struct ConfigFile { pub path: PathBuf, doc: DocumentMut, } impl ConfigFile { /// Loads the file, creating it with [`DEFAULT_CONFIG`] if it does not exist. pub fn load_or_create(path: &Path) -> Result<(Self, ServerConfig, bool), ConfigError> { let io = |e| ConfigError::Io(path.to_owned(), e); let (text, created) = match std::fs::read_to_string(path) { Ok(text) => (text, false), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if let Some(dir) = path.parent() && !dir.as_os_str().is_empty() { std::fs::create_dir_all(dir).map_err(io)?; } std::fs::write(path, DEFAULT_CONFIG).map_err(io)?; (DEFAULT_CONFIG.to_owned(), true) } Err(e) => return Err(io(e)), }; let parse = |e: String| ConfigError::Parse(path.to_owned(), e); let doc: DocumentMut = text .parse() .map_err(|e: toml_edit::TomlError| parse(e.to_string()))?; let config = toml::from_str(&text).map_err(|e: toml::de::Error| parse(e.to_string()))?; Ok(( Self { path: path.to_owned(), doc, }, config, created, )) } /// Sets a key in the file and writes it. pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> { let mut doc = self.doc.clone(); set_in_document(&mut doc, key, parse_value(value))?; from_document(&doc).map_err(|e| invalid(key, e))?; std::fs::write(&self.path, doc.to_string()) .map_err(|e| ConfigError::Io(self.path.clone(), e))?; self.doc = doc; Ok(()) } } /// Changes a setting at runtime (and in the config file, if there is one). /// Returns a note if the change only takes effect after a restart. pub fn set_runtime( world: &mut World, key: &str, value: &str, ) -> Result, ConfigError> { let mut config = world.resource::().clone(); config.set(key, value)?; if let Some(mut file) = world.get_resource_mut::() { file.set(key, value)?; } let restart = matches!(key.split('.').next(), Some("port" | "map" | "database")); if config.autosave_secs != world.resource::().autosave_secs && let Some(mut timer) = world.get_resource_mut::() { timer .0 .set_duration(Duration::from_secs(config.autosave_secs.max(1))); } world.insert_resource(config); Ok(restart.then_some("takes effect after a restart")) } #[cfg(test)] mod tests { use super::*; #[test] fn default_file_matches_defaults() { let config: ServerConfig = toml::from_str(DEFAULT_CONFIG).unwrap(); assert_eq!(config, ServerConfig::default()); } #[test] fn get_and_set() { let mut config = ServerConfig::default(); config.set("max_players", "4").unwrap(); config.set("database.world", "europa").unwrap(); config.set("motd", "hello there").unwrap(); config.set("access.admins", r#"["abc"]"#).unwrap(); assert_eq!(config.max_players, 4); assert_eq!(config.database.world, "europa"); assert_eq!(config.motd, "hello there"); assert_eq!(config.access.admins, ["abc"]); assert_eq!(config.get("database.world").unwrap(), "\"europa\""); assert_eq!(config.get("max_players").unwrap(), "4"); assert!(matches!( config.set("nope", "1"), Err(ConfigError::UnknownKey(_)) )); assert!(matches!( config.set("database.nope", "1"), Err(ConfigError::UnknownKey(_)) )); assert!(matches!( config.get("nope"), Err(ConfigError::UnknownKey(_)) )); assert!(matches!( config.set("port", "-1"), Err(ConfigError::InvalidValue(..)) )); assert_eq!(config.port, game_core::DEFAULT_PORT); } #[test] fn file_edits_keep_comments() { let dir = std::env::temp_dir().join(format!("station_config_test_{}", std::process::id())); let path = dir.join("server.toml"); let (mut file, config, created) = ConfigFile::load_or_create(&path).unwrap(); assert!(created); assert_eq!(config, ServerConfig::default()); file.set("password", "secret").unwrap(); file.set("access.banned", r#"["griefer"]"#).unwrap(); assert!(file.set("port", "\"x\"").is_err()); let (_, config, created) = ConfigFile::load_or_create(&path).unwrap(); assert!(!created); assert_eq!(config.password, "secret"); assert_eq!(config.access.banned, ["griefer"]); let text = std::fs::read_to_string(&path).unwrap(); assert!(text.contains("# Players must enter this password to join.")); std::fs::remove_dir_all(&dir).unwrap(); } }