station/crates/game_server/src/config.rs

375 lines
12 KiB
Rust
Raw Normal View History

//! 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<String>,
/// Only players in `whitelist` may join.
pub whitelist_enabled: bool,
/// Player names or ids.
pub whitelist: Vec<String>,
/// Banned player names or ids.
pub banned: Vec<String>,
}
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<String, ConfigError> {
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<ServerConfig, String> {
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<Option<&'static str>, ConfigError> {
let mut config = world.resource::<ServerConfig>().clone();
config.set(key, value)?;
if let Some(mut file) = world.get_resource_mut::<ConfigFile>() {
file.set(key, value)?;
}
let restart = matches!(key.split('.').next(), Some("port" | "map" | "database"));
if config.autosave_secs != world.resource::<ServerConfig>().autosave_secs
&& let Some(mut timer) = world.get_resource_mut::<crate::persistence::AutosaveTimer>()
{
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();
}
}