375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
//! 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();
|
|
}
|
|
}
|