From e7587c4d9d6fd648071f056e6ff53cfacbe066ce Mon Sep 17 00:00:00 2001 From: Joshua Yanovski Date: Sat, 8 May 2021 11:22:21 -0700 Subject: [PATCH] Added non-admin moderators and timed bans. The security model has been updated to reflect this change (for example, moderators cannot revert a ban by an administrator). Ban history is also now recorded in the ban file, and much more information about the ban is stored (whitelists and administrators also have extra information). To support the new information without losing important information, this commit also introduces a new migration path for editable settings (both from legacy to the new format, and between versions). Examples of how to do this correctly, and migrate to new versions of a settings file, are in the settings/ subdirectory. As part of this effort, editable settings have been revamped to guarantee atomic saves (due to the increased amount of information in each file), some latent bugs in networking were fixed, and server-cli has been updated to go through StructOpt for both calls through TUI and argv, greatly simplifying parsing logic. --- CHANGELOG.md | 2 + Cargo.lock | 22 + client/src/lib.rs | 14 +- common/Cargo.toml | 1 + common/frontend/src/lib.rs | 1 + common/net/src/msg/server.rs | 4 +- common/src/cmd.rs | 187 +++--- common/src/comp/admin.rs | 18 +- common/src/comp/mod.rs | 2 +- network/src/api.rs | 15 +- network/src/participant.rs | 15 +- network/src/scheduler.rs | 11 +- server-cli/Cargo.toml | 1 + server-cli/src/admin.rs | 34 -- server-cli/src/cli.rs | 131 +++++ server-cli/src/cmd.rs | 177 ------ server-cli/src/main.rs | 168 +++--- server-cli/src/settings.rs | 2 +- server-cli/src/tui_runner.rs | 12 +- server/Cargo.toml | 4 +- server/src/cmd.rs | 609 +++++++++++++++----- server/src/events/interaction.rs | 2 +- server/src/lib.rs | 127 +++-- server/src/login_provider.rs | 59 +- server/src/persistence/mod.rs | 32 ++ server/src/settings.rs | 132 ++--- server/src/settings/admin.rs | 257 +++++++++ server/src/settings/banlist.rs | 662 ++++++++++++++++++++++ server/src/settings/editable.rs | 222 +++++++- server/src/settings/server_description.rs | 182 ++++++ server/src/settings/whitelist.rs | 269 +++++++++ server/src/sys/msg/register.rs | 26 +- voxygen/Cargo.toml | 2 +- voxygen/src/session/mod.rs | 2 +- 34 files changed, 2648 insertions(+), 756 deletions(-) delete mode 100644 server-cli/src/admin.rs create mode 100644 server-cli/src/cli.rs delete mode 100644 server-cli/src/cmd.rs create mode 100644 server/src/settings/admin.rs create mode 100644 server/src/settings/banlist.rs create mode 100644 server/src/settings/server_description.rs create mode 100644 server/src/settings/whitelist.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f65a340d3a..ee4812a7b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - /buff command which allows you to cast a buff on player - Warn the user with an animated red text in the second phase of a trade in which a party is offering nothing. - /skill_preset command which allows you to apply skill presets +- Added timed bans and ban history. +- Added non-admin moderators with limit privileges and updated the security model to reflect this. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 70a3ba6848..50e3df519a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,17 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9ff149ed9780025acfdb36862d35b28856bb693ceb451259a7164442f22fdc3" +[[package]] +name = "atomicwrites" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4830ac690261d0b47f06e86d18c47eaa65d0184e576cf9b62c3a49b28cb876b" +dependencies = [ + "nix 0.20.0", + "tempfile", + "winapi 0.3.9", +] + [[package]] name = "atty" version = "0.2.14" @@ -517,6 +528,7 @@ dependencies = [ "libc", "num-integer", "num-traits", + "serde", "time", "winapi 0.3.9", ] @@ -2249,6 +2261,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.5" @@ -5484,6 +5502,7 @@ version = "0.9.0" dependencies = [ "approx 0.4.0", "bitflags", + "clap", "criterion", "crossbeam-channel", "crossbeam-utils 0.8.3", @@ -5713,11 +5732,13 @@ dependencies = [ name = "veloren-server" version = "0.9.0" dependencies = [ + "atomicwrites", "authc", "chrono", "crossbeam-channel", "futures-util", "hashbrown", + "humantime", "itertools 0.10.0", "lazy_static", "num_cpus", @@ -5762,6 +5783,7 @@ dependencies = [ "ron", "serde", "signal-hook 0.3.8", + "structopt", "tokio", "tracing", "tui", diff --git a/client/src/lib.rs b/client/src/lib.rs index 0fa74ad641..0d04801bb5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1659,9 +1659,9 @@ impl Client { ); } }, - ServerGeneral::PlayerListUpdate(PlayerListUpdate::Admin(uid, admin)) => { + ServerGeneral::PlayerListUpdate(PlayerListUpdate::Moderator(uid, moderator)) => { if let Some(player_info) = self.player_list.get_mut(&uid) { - player_info.is_admin = admin; + player_info.is_moderator = moderator; } else { warn!( "Received msg to update admin status of uid {}, but they were not in the \ @@ -2166,8 +2166,8 @@ impl Client { .collect() } - /// Return true if this client is an admin on the server - pub fn is_admin(&self) -> bool { + /// Return true if this client is a moderator on the server + pub fn is_moderator(&self) -> bool { let client_uid = self .state .read_component_copied::(self.entity()) @@ -2175,7 +2175,7 @@ impl Client { self.player_list .get(&client_uid) - .map_or(false, |info| info.is_admin) + .map_or(false, |info| info.is_moderator) } /// Clean client ECS state @@ -2220,9 +2220,9 @@ impl Client { self.player_list .get(uid) .map_or("".to_string(), |player_info| { - if player_info.is_admin { + if player_info.is_moderator { format!( - "ADMIN - {}", + "MOD - {}", self.personalize_alias(*uid, player_info.player_alias.clone()) ) } else { diff --git a/common/Cargo.toml b/common/Cargo.toml index 30c25accd0..aaee090b99 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -22,6 +22,7 @@ serde = { version = "1.0.110", features = ["derive", "rc"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] approx = "0.4.0" +clap = "2.33" crossbeam-utils = "0.8.1" bitflags = "1.2" crossbeam-channel = "0.5" diff --git a/common/frontend/src/lib.rs b/common/frontend/src/lib.rs index 79ac61bef3..522c34a118 100644 --- a/common/frontend/src/lib.rs +++ b/common/frontend/src/lib.rs @@ -64,6 +64,7 @@ where .parse() .unwrap(), ) + .add_directive("veloren_server::settings=info".parse().unwrap()) .add_directive(LevelFilter::INFO.into()) }; diff --git a/common/net/src/msg/server.rs b/common/net/src/msg/server.rs index a35cee86fc..818f49c38f 100644 --- a/common/net/src/msg/server.rs +++ b/common/net/src/msg/server.rs @@ -201,14 +201,14 @@ pub enum PlayerListUpdate { Add(Uid, PlayerInfo), SelectedCharacter(Uid, CharacterInfo), LevelChange(Uid, u32), - Admin(Uid, bool), + Moderator(Uid, bool), Remove(Uid), Alias(Uid, String), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlayerInfo { - pub is_admin: bool, + pub is_moderator: bool, pub is_online: bool, pub player_alias: String, pub character: Option, diff --git a/common/src/cmd.rs b/common/src/cmd.rs index cf9033494b..361a20ba55 100644 --- a/common/src/cmd.rs +++ b/common/src/cmd.rs @@ -1,6 +1,6 @@ use crate::{ assets, - comp::{self, buff::BuffKind, Skill}, + comp::{self, buff::BuffKind, AdminRole as Role, Skill}, npc, terrain, }; use assets::AssetExt; @@ -22,19 +22,19 @@ pub struct ChatCommandData { /// A one-line message that explains what the command does pub description: &'static str, /// Whether the command requires administrator permissions. - pub needs_admin: IsAdminOnly, + pub needs_role: Option, } impl ChatCommandData { pub fn new( args: Vec, description: &'static str, - needs_admin: IsAdminOnly, + needs_role: Option, ) -> Self { Self { args, description, - needs_admin, + needs_role, } } } @@ -300,6 +300,8 @@ lazy_static! { .cloned() .collect(); + static ref ROLES: Vec = ["admin", "moderator"].iter().copied().map(Into::into).collect(); + /// List of item specifiers. Useful for tab completing static ref ITEM_SPECS: Vec = { let path = assets::ASSETS_PATH.join("common").join("items"); @@ -351,21 +353,22 @@ lazy_static! { impl ChatCommand { pub fn data(&self) -> ChatCommandData { use ArgumentSpec::*; - use IsAdminOnly::*; use Requirement::*; + use Role::*; let cmd = ChatCommandData::new; match self { ChatCommand::Adminify => cmd( - vec![PlayerName(Required)], - "Temporarily gives a player admin permissions or removes them", - Admin, + vec![PlayerName(Required), Enum("role", ROLES.clone(), Optional)], + "Temporarily gives a player a restricted admin role or removes the current one \ + (if not given)", + Some(Admin), ), ChatCommand::Airship => cmd( vec![Float("destination_degrees_ccw_of_east", 90.0, Optional)], "Spawns an airship", - Admin, + Some(Admin), ), - ChatCommand::Alias => cmd(vec![Any("name", Required)], "Change your alias", NoAdmin), + ChatCommand::Alias => cmd(vec![Any("name", Required)], "Change your alias", None), ChatCommand::ApplyBuff => cmd( vec![ Enum("buff", BUFFS.clone(), Required), @@ -373,14 +376,20 @@ impl ChatCommand { Float("duration", 10.0, Optional), ], "Cast a buff on player", - Admin, + Some(Admin), ), ChatCommand::Ban => cmd( - vec![Any("username", Required), Message(Optional)], - "Ban a player with a given username", - Admin, + vec![ + Any("username", Required), + Boolean("overwrite", "true".to_string(), Optional), + Any("ban duration", Optional), + Message(Optional), + ], + "Ban a player with a given username, for a given duration (if provided). Pass \ + true for overwrite to alter an existing ban..", + Some(Moderator), ), - ChatCommand::Build => cmd(vec![], "Toggles build mode on and off", NoAdmin), + ChatCommand::Build => cmd(vec![], "Toggles build mode on and off", None), ChatCommand::BuildAreaAdd => cmd( vec![ Any("name", Required), @@ -392,36 +401,40 @@ impl ChatCommand { Integer("zhi", 10, Required), ], "Adds a new build area", - Admin, + Some(Admin), ), - ChatCommand::BuildAreaList => cmd(vec![], "List all build areas", Admin), + ChatCommand::BuildAreaList => cmd(vec![], "List all build areas", Some(Admin)), ChatCommand::BuildAreaRemove => cmd( vec![Any("name", Required)], "Removes specified build area", - Admin, + Some(Admin), ), - ChatCommand::Campfire => cmd(vec![], "Spawns a campfire", Admin), + ChatCommand::Campfire => cmd(vec![], "Spawns a campfire", Some(Admin)), ChatCommand::DebugColumn => cmd( vec![Integer("x", 15000, Required), Integer("y", 15000, Required)], "Prints some debug information about a column", - NoAdmin, + Some(Moderator), ), ChatCommand::DisconnectAllPlayers => cmd( vec![Any("confirm", Required)], "Disconnects all players from the server", - Admin, + Some(Admin), ), - ChatCommand::DropAll => cmd(vec![], "Drops all your items on the ground", Admin), - ChatCommand::Dummy => cmd(vec![], "Spawns a training dummy", Admin), + ChatCommand::DropAll => cmd( + vec![], + "Drops all your items on the ground", + Some(Moderator), + ), + ChatCommand::Dummy => cmd(vec![], "Spawns a training dummy", Some(Admin)), ChatCommand::Explosion => cmd( vec![Float("radius", 5.0, Required)], "Explodes the ground around you", - Admin, + Some(Admin), ), ChatCommand::Faction => cmd( vec![Message(Optional)], "Send messages to your faction", - NoAdmin, + None, ), ChatCommand::GiveItem => cmd( vec![ @@ -429,7 +442,7 @@ impl ChatCommand { Integer("num", 1, Optional), ], "Give yourself some items", - Admin, + Some(Admin), ), ChatCommand::Goto => cmd( vec![ @@ -438,44 +451,40 @@ impl ChatCommand { Float("z", 0.0, Required), ], "Teleport to a position", - Admin, - ), - ChatCommand::Group => cmd( - vec![Message(Optional)], - "Send messages to your group", - NoAdmin, + Some(Admin), ), + ChatCommand::Group => cmd(vec![Message(Optional)], "Send messages to your group", None), ChatCommand::GroupInvite => cmd( vec![PlayerName(Required)], "Invite a player to join a group", - NoAdmin, + None, ), ChatCommand::GroupKick => cmd( vec![PlayerName(Required)], "Remove a player from a group", - NoAdmin, + None, ), - ChatCommand::GroupLeave => cmd(vec![], "Leave the current group", NoAdmin), + ChatCommand::GroupLeave => cmd(vec![], "Leave the current group", None), ChatCommand::GroupPromote => cmd( vec![PlayerName(Required)], "Promote a player to group leader", - NoAdmin, + None, ), ChatCommand::Health => cmd( vec![Integer("hp", 100, Required)], "Set your current health", - Admin, + Some(Admin), ), ChatCommand::Help => ChatCommandData::new( vec![Command(Optional)], "Display information about commands", - NoAdmin, + None, ), - ChatCommand::Home => cmd(vec![], "Return to the home town", NoAdmin), + ChatCommand::Home => cmd(vec![], "Return to the home town", None), ChatCommand::JoinFaction => ChatCommandData::new( vec![Any("faction", Optional)], "Join/leave the specified faction", - NoAdmin, + None, ), ChatCommand::Jump => cmd( vec![ @@ -484,19 +493,19 @@ impl ChatCommand { Float("z", 0.0, Required), ], "Offset your current position", - Admin, + Some(Admin), ), ChatCommand::Kick => cmd( vec![Any("username", Required), Message(Optional)], "Kick a player with a given username", - Admin, + Some(Moderator), ), - ChatCommand::Kill => cmd(vec![], "Kill yourself", NoAdmin), - ChatCommand::KillNpcs => cmd(vec![], "Kill the NPCs", Admin), + ChatCommand::Kill => cmd(vec![], "Kill yourself", None), + ChatCommand::KillNpcs => cmd(vec![], "Kill the NPCs", Some(Admin)), ChatCommand::Kit => cmd( vec![Enum("kit_name", KITS.to_vec(), Required)], "Place a set of items into your inventory.", - Admin, + Some(Admin), ), ChatCommand::Lantern => cmd( vec![ @@ -506,7 +515,7 @@ impl ChatCommand { Float("b", 1.0, Optional), ], "Change your lantern's strength and color", - Admin, + Some(Admin), ), ChatCommand::Light => cmd( vec![ @@ -519,63 +528,59 @@ impl ChatCommand { Float("strength", 5.0, Optional), ], "Spawn entity with light", - Admin, + Some(Admin), ), ChatCommand::MakeBlock => cmd( vec![Enum("block", BLOCK_KINDS.clone(), Required)], "Make a block at your location", - Admin, + Some(Admin), ), ChatCommand::MakeSprite => cmd( vec![Enum("sprite", SPRITE_KINDS.clone(), Required)], "Make a sprite at your location", - Admin, - ), - ChatCommand::Motd => cmd( - vec![Message(Optional)], - "View the server description", - NoAdmin, + Some(Admin), ), + ChatCommand::Motd => cmd(vec![Message(Optional)], "View the server description", None), ChatCommand::Object => cmd( vec![Enum("object", OBJECTS.clone(), Required)], "Spawn an object", - Admin, + Some(Admin), ), ChatCommand::PermitBuild => cmd( vec![Any("area_name", Required)], "Grants player a bounded box they can build in", - Admin, + Some(Admin), ), - ChatCommand::Players => cmd(vec![], "Lists players currently online", NoAdmin), + ChatCommand::Players => cmd(vec![], "Lists players currently online", None), ChatCommand::RemoveLights => cmd( vec![Float("radius", 20.0, Optional)], "Removes all lights spawned by players", - Admin, + Some(Admin), ), ChatCommand::RevokeBuild => cmd( vec![Any("area_name", Required)], "Revokes build area permission for player", - Admin, + Some(Admin), ), ChatCommand::RevokeBuildAll => cmd( vec![], "Revokes all build area permissions for player", - Admin, + Some(Admin), ), ChatCommand::Region => cmd( vec![Message(Optional)], "Send messages to everyone in your region of the world", - NoAdmin, + None, ), ChatCommand::Safezone => cmd( vec![Float("range", 100.0, Optional)], "Creates a safezone", - Admin, + Some(Moderator), ), ChatCommand::Say => cmd( vec![Message(Optional)], "Send messages to everyone within shouting distance", - NoAdmin, + None, ), ChatCommand::ServerPhysics => cmd( vec![ @@ -583,26 +588,32 @@ impl ChatCommand { Boolean("enabled", "true".to_string(), Optional), ], "Set/unset server-authoritative physics for an account", - Admin, + Some(Moderator), + ), + ChatCommand::SetMotd => cmd( + vec![Message(Optional)], + "Set the server description", + Some(Admin), ), - ChatCommand::SetMotd => { - cmd(vec![Message(Optional)], "Set the server description", Admin) - }, // Uses Message because site names can contain spaces, which would be assumed to be // separators otherwise - ChatCommand::Site => cmd(vec![Message(Required)], "Teleport to a site", Admin), + ChatCommand::Site => cmd( + vec![Message(Required)], + "Teleport to a site", + Some(Moderator), + ), ChatCommand::SkillPoint => cmd( vec![ Enum("skill tree", SKILL_TREES.clone(), Required), Integer("amount", 1, Optional), ], "Give yourself skill points for a particular skill tree", - Admin, + Some(Admin), ), ChatCommand::SkillPreset => cmd( vec![Enum("preset_name", PRESET_LIST.to_vec(), Required)], "Gives your character desired skills.", - Admin, + Some(Admin), ), ChatCommand::Spawn => cmd( vec![ @@ -612,47 +623,49 @@ impl ChatCommand { Boolean("ai", "true".to_string(), Optional), ], "Spawn a test entity", - Admin, + Some(Admin), ), ChatCommand::Sudo => cmd( vec![PlayerName(Required), SubCommand], "Run command as if you were another player", - Admin, + Some(Moderator), ), ChatCommand::Tell => cmd( vec![PlayerName(Required), Message(Optional)], "Send a message to another player", - NoAdmin, + None, ), ChatCommand::Time => cmd( vec![Enum("time", TIMES.clone(), Optional)], "Set the time of day", - Admin, + Some(Admin), ), ChatCommand::Tp => cmd( vec![PlayerName(Optional)], "Teleport to another player", - Admin, + Some(Moderator), ), ChatCommand::Unban => cmd( vec![Any("username", Required)], "Remove the ban for the given username", - Admin, + Some(Moderator), ), - ChatCommand::Version => cmd(vec![], "Prints server version", NoAdmin), - ChatCommand::Waypoint => { - cmd(vec![], "Set your waypoint to your current position", Admin) - }, - ChatCommand::Wiring => cmd(vec![], "Create wiring element", Admin), + ChatCommand::Version => cmd(vec![], "Prints server version", None), + ChatCommand::Waypoint => cmd( + vec![], + "Set your waypoint to your current position", + Some(Admin), + ), + ChatCommand::Wiring => cmd(vec![], "Create wiring element", Some(Admin)), ChatCommand::Whitelist => cmd( vec![Any("add/remove", Required), Any("username", Required)], "Adds/removes username to whitelist", - Admin, + Some(Moderator), ), ChatCommand::World => cmd( vec![Message(Optional)], "Send messages to everyone on the server", - NoAdmin, + None, ), } } @@ -737,7 +750,7 @@ impl ChatCommand { /// A boolean that is used to check whether the command requires /// administrator permissions or not. - pub fn needs_admin(&self) -> bool { IsAdminOnly::Admin == self.data().needs_admin } + pub fn needs_role(&self) -> Option { self.data().needs_role } /// Returns a format string for parsing arguments with scan_fmt pub fn arg_fmt(&self) -> String { @@ -795,12 +808,6 @@ impl FromStr for ChatCommand { } } -#[derive(Eq, PartialEq, Debug)] -pub enum IsAdminOnly { - Admin, - NoAdmin, -} - #[derive(Eq, PartialEq, Debug)] pub enum Requirement { Required, diff --git a/common/src/comp/admin.rs b/common/src/comp/admin.rs index 5ce3c59327..b92e070501 100644 --- a/common/src/comp/admin.rs +++ b/common/src/comp/admin.rs @@ -1,8 +1,18 @@ -use specs::{Component, NullStorage}; +use clap::arg_enum; +use specs::Component; +use specs_idvs::IdvStorage; -#[derive(Clone, Copy, Default)] -pub struct Admin; +arg_enum! { + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] + pub enum AdminRole { + Moderator = 0, + Admin = 1, + } +} + +#[derive(Clone, Copy)] +pub struct Admin(pub AdminRole); impl Component for Admin { - type Storage = NullStorage; + type Storage = IdvStorage; } diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 4bcbb77ba3..bde3d3c3ee 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -45,7 +45,7 @@ pub mod visual; #[cfg(not(target_arch = "wasm32"))] pub use self::{ ability::{CharacterAbility, CharacterAbilityType}, - admin::Admin, + admin::{Admin, AdminRole}, agent::{Agent, Alignment, Behavior, BehaviorCapability, BehaviorState}, aura::{Aura, AuraChange, AuraKind, Auras}, beam::{Beam, BeamSegment}, diff --git a/network/src/api.rs b/network/src/api.rs index d4b282eac3..5a3000870b 100644 --- a/network/src/api.rs +++ b/network/src/api.rs @@ -470,9 +470,10 @@ impl Network { trace!(?remote_pid, "Participants will be closed"); let (finished_sender, finished_receiver) = oneshot::channel(); finished_receiver_list.push((remote_pid, finished_receiver)); - a2s_disconnect_s - .send((remote_pid, (Duration::from_secs(10), finished_sender))) - .expect("Scheduler is closed, but nobody other should be able to close it"); + // If the channel was already dropped, we can assume that the other side + // already released its resources. + let _ = a2s_disconnect_s + .send((remote_pid, (Duration::from_secs(10), finished_sender))); }, None => trace!(?remote_pid, "Participant already disconnected gracefully"), } @@ -706,9 +707,11 @@ impl Participant { let (finished_sender, finished_receiver) = oneshot::channel(); // Participant is connecting to Scheduler here, not as usual // Participant<->BParticipant - a2s_disconnect_s - .send((self.remote_pid, (Duration::from_secs(120), finished_sender))) - .expect("Something is wrong in internal scheduler coding"); + + // If this is already dropped, we can assume the other side already freed its + // resources. + let _ = a2s_disconnect_s + .send((self.remote_pid, (Duration::from_secs(120), finished_sender))); match finished_receiver.await { Ok(res) => { match res { diff --git a/network/src/participant.rs b/network/src/participant.rs index c4a6101692..3d669b6209 100644 --- a/network/src/participant.rs +++ b/network/src/participant.rs @@ -632,7 +632,7 @@ impl BParticipant { } }; - let (timeout_time, sender) = s2b_shutdown_bparticipant_r.await.unwrap(); + let awaited = s2b_shutdown_bparticipant_r.await.ok(); debug!("participant_shutdown_mgr triggered. Closing all streams for send"); { let lock = self.streams.read().await; @@ -659,7 +659,12 @@ impl BParticipant { drop(lock); trace!("wait for other managers"); - let timeout = tokio::time::sleep(timeout_time); + let timeout = tokio::time::sleep( + awaited + .as_ref() + .map(|(timeout_time, _)| *timeout_time) + .unwrap_or_default(), + ); let timeout = tokio::select! { _ = wait_for_manager() => false, _ = timeout => true, @@ -681,8 +686,10 @@ impl BParticipant { trace!("wait again"); wait_for_manager().await; - if sender.send(Ok(())).is_err() { - trace!("couldn't notify sender that participant is dropped"); + if let Some((_, sender)) = awaited { + // Don't care whether this send succeeded since if the other end is dropped + // there's nothing to synchronize on. + let _ = sender.send(Ok(())); } #[cfg(feature = "metrics")] diff --git a/network/src/scheduler.rs b/network/src/scheduler.rs index 95f3e4364a..b6576b4c7b 100644 --- a/network/src/scheduler.rs +++ b/network/src/scheduler.rs @@ -280,14 +280,17 @@ impl Scheduler { trace!(?pid, "dropped participants lock"); let r = if let Some(mut pi) = pi { let (finished_sender, finished_receiver) = oneshot::channel(); - pi.s2b_shutdown_bparticipant_s + // NOTE: If there's nothing to synchronize on (because the send failed) + // we can assume everything relevant was shut down. + let _ = pi + .s2b_shutdown_bparticipant_s .take() .unwrap() - .send((timeout_time, finished_sender)) - .unwrap(); + .send((timeout_time, finished_sender)); drop(pi); trace!(?pid, "dropped bparticipant, waiting for finish"); - let e = finished_receiver.await.unwrap(); + // If await fails, already shut down, so send Ok(()). + let e = finished_receiver.await.unwrap_or(Ok(())); trace!(?pid, "waiting completed"); // can fail as api.rs has a timeout return_once_successful_shutdown.send(e) diff --git a/server-cli/Cargo.toml b/server-cli/Cargo.toml index 59217755b3..a2563f89c1 100644 --- a/server-cli/Cargo.toml +++ b/server-cli/Cargo.toml @@ -31,6 +31,7 @@ tokio = { version = "1", default-features = false, features = ["rt-multi-thread" num_cpus = "1.0" ansi-parser = "0.7" clap = "2.33" +structopt = "0.3.13" crossterm = "0.19" lazy_static = "1" signal-hook = "0.3.6" diff --git a/server-cli/src/admin.rs b/server-cli/src/admin.rs deleted file mode 100644 index 9a9fef99cb..0000000000 --- a/server-cli/src/admin.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::sync::Arc; -use tokio::runtime::Runtime; - -pub fn admin_subcommand( - runtime: Arc, - sub_m: &clap::ArgMatches, - server_settings: &server::Settings, - editable_settings: &mut server::EditableSettings, - data_dir: &std::path::Path, -) { - let login_provider = server::login_provider::LoginProvider::new( - server_settings.auth_server_address.clone(), - runtime, - ); - - match sub_m.subcommand() { - ("add", Some(sub_m)) => { - if let Some(username) = sub_m.value_of("username") { - server::add_admin(username, &login_provider, editable_settings, data_dir); - } - }, - ("remove", Some(sub_m)) => { - if let Some(username) = sub_m.value_of("username") { - server::remove_admin(username, &login_provider, editable_settings, data_dir); - } - }, - // TODO: can clap enforce this? - // or make this list current admins or something - _ => tracing::error!( - "Invalid input, use one of the subcommands listed using: \nveloren-server-cli help \ - admin" - ), - } -} diff --git a/server-cli/src/cli.rs b/server-cli/src/cli.rs new file mode 100644 index 0000000000..0e03158d2f --- /dev/null +++ b/server-cli/src/cli.rs @@ -0,0 +1,131 @@ +use common::comp; +use server::persistence::SqlLogMode; +use std::sync::mpsc::Sender; +use structopt::StructOpt; +use tracing::error; + +#[derive(Clone, Debug, StructOpt)] +pub enum Admin { + /// Adds an admin + Add { + #[structopt(short, long)] + /// Name of the admin to whom to assign a role + username: String, + /// role to assign to the admin + #[structopt(short, long, possible_values = &comp::AdminRole::variants(), case_insensitive = true)] + role: comp::AdminRole, + }, + Remove { + #[structopt(short, long)] + /// Name of the admin from whom to remove any existing roles + username: String, + }, +} + +#[derive(Clone, Debug, StructOpt)] +pub enum Shutdown { + /// Closes the server immediately + Immediate, + /// Shuts down the server gracefully + Graceful { + /// Number of seconds to wait before shutting down + seconds: u64, + #[structopt(short, long, default_value = "The server is shutting down")] + /// Shutdown reason + reason: String, + }, + /// Cancel any pending graceful shutdown. + Cancel, +} + +#[derive(Clone, Debug, StructOpt)] +pub enum SharedCommand { + /// Perform operations on the admin list + Admin { + #[structopt(subcommand)] + command: Admin, + }, +} + +#[derive(Debug, Clone, StructOpt)] +pub enum Message { + #[structopt(flatten)] + Shared(SharedCommand), + /// Shut down the server (or cancel a shut down) + Shutdown { + #[structopt(subcommand)] + command: Shutdown, + }, + /// Loads up the chunks at map center and adds a entity that mimics a + /// player to keep them from despawning + LoadArea { + /// View distance of the loaded area + view_distance: u32, + }, + /// Enable or disable sql logging + SqlLogMode { + #[structopt(default_value, possible_values = &SqlLogMode::variants())] + mode: SqlLogMode, + }, + /// Disconnects all connected clients + DisconnectAllClients, +} + +#[derive(StructOpt)] +#[structopt( + name = "Veloren server TUI", + version = common::util::DISPLAY_VERSION_LONG.as_str(), + about = "The veloren server tui allows sending commands directly to the running server.", + author = "The veloren devs ", + setting = clap::AppSettings::NoBinaryName, +)] +pub struct TuiApp { + #[structopt(subcommand)] + command: Message, +} + +#[derive(StructOpt)] +pub enum ArgvCommand { + #[structopt(flatten)] + Shared(SharedCommand), +} + +#[derive(StructOpt)] +#[structopt( + name = "Veloren server CLI", + version = common::util::DISPLAY_VERSION_LONG.as_str(), + about = "The veloren server cli provides an easy to use interface to start a veloren server.", + author = "The veloren devs ", +)] +pub struct ArgvApp { + #[structopt(long, short)] + /// Disables the tui + pub basic: bool, + #[structopt(long, short)] + /// Doesn't listen on STDIN + /// + /// Useful if you want to send the server in background, and your kernels + /// terminal driver will send SIGTTIN to it otherwise. (https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Redirections) and you dont want to use `stty -tostop` + /// or `nohub` or `tmux` or `screen` or `<<< \"\\004\"` to the programm. + /// This implies `-b`. + pub non_interactive: bool, + #[structopt(long)] + /// Run without auth enabled + pub no_auth: bool, + #[structopt(default_value, long, short, possible_values = &SqlLogMode::variants())] + /// Enables SQL logging + pub sql_log_mode: SqlLogMode, + #[structopt(subcommand)] + pub command: Option, +} + +pub fn parse_command(input: &str, msg_s: &mut Sender) { + match TuiApp::from_iter_safe(input.split_whitespace()) { + Ok(message) => { + msg_s + .send(message.command) + .unwrap_or_else(|err| error!("Failed to send CLI message, err: {:?}", err)); + }, + Err(err) => error!("{}", err.message), + } +} diff --git a/server-cli/src/cmd.rs b/server-cli/src/cmd.rs deleted file mode 100644 index fb135e2112..0000000000 --- a/server-cli/src/cmd.rs +++ /dev/null @@ -1,177 +0,0 @@ -use core::time::Duration; -use server::persistence::SqlLogMode; -use std::sync::mpsc::Sender; -use tracing::{error, info, warn}; - -#[derive(Debug, Clone)] -pub enum Message { - AbortShutdown, - Shutdown { grace_period: Duration }, - Quit, - AddAdmin(String), - RemoveAdmin(String), - LoadArea(u32), - SetSqlLogMode(SqlLogMode), - DisconnectAllClients, -} - -struct Command<'a> { - name: &'a str, - description: &'a str, - // Whether or not the command splits the arguments on whitespace - split_spaces: bool, - args: usize, - cmd: fn(Vec, &mut Sender), -} - -// TODO: maybe we could be using clap here? -const COMMANDS: [Command; 8] = [ - Command { - name: "quit", - description: "Closes the server", - split_spaces: true, - args: 0, - cmd: |_, sender| send(sender, Message::Quit), - }, - Command { - name: "shutdown", - description: "Initiates a graceful shutdown of the server, waiting the specified number \ - of seconds before shutting down", - split_spaces: true, - args: 1, - cmd: |args, sender| { - if let Ok(grace_period) = args.first().unwrap().parse::() { - send(sender, Message::Shutdown { - grace_period: Duration::from_secs(grace_period), - }) - } else { - error!("Grace period must an integer") - } - }, - }, - Command { - name: "disconnectall", - description: "Disconnects all connected clients", - split_spaces: true, - args: 0, - cmd: |_, sender| send(sender, Message::DisconnectAllClients), - }, - Command { - name: "loadarea", - description: "Loads up the chunks in a random area and adds a entity that mimics a player \ - to keep them from despawning", - split_spaces: true, - args: 1, - cmd: |args, sender| { - if let Ok(view_distance) = args.first().unwrap().parse::() { - send(sender, Message::LoadArea(view_distance)); - } else { - error!("View distance must be an integer"); - } - }, - }, - Command { - name: "abortshutdown", - description: "Aborts a shutdown if one is in progress", - split_spaces: false, - args: 0, - cmd: |_, sender| send(sender, Message::AbortShutdown), - }, - Command { - name: "admin", - description: "Add or remove an admin via \'admin add/remove \'", - split_spaces: true, - args: 2, - cmd: |args, sender| match args.get(..2) { - Some([op, username]) if op == "add" => { - send(sender, Message::AddAdmin(username.clone())); - }, - Some([op, username]) if op == "remove" => { - send(sender, Message::RemoveAdmin(username.clone())); - }, - Some(_) => error!("First arg must be add or remove"), - _ => error!("Not enough args, should be unreachable"), - }, - }, - Command { - name: "sqllog", - description: "Sets the SQL logging mode, valid values are off, trace and profile", - split_spaces: true, - args: 1, - cmd: |args, sender| match args.get(0) { - Some(arg) => { - let sql_log_mode = match arg.to_lowercase().as_str() { - "off" => Some(SqlLogMode::Disabled), - "profile" => Some(SqlLogMode::Profile), - "trace" => Some(SqlLogMode::Trace), - _ => None, - }; - - if let Some(sql_log_mode) = sql_log_mode { - send(sender, Message::SetSqlLogMode(sql_log_mode)); - } else { - error!("Invalid SQL log mode"); - } - }, - _ => error!("Not enough args"), - }, - }, - Command { - name: "help", - description: "List all command available", - split_spaces: true, - args: 0, - cmd: |_, _| { - info!("===== Help ====="); - for command in COMMANDS.iter() { - info!("{} - {}", command.name, command.description) - } - info!("================"); - }, - }, -]; - -fn send(sender: &mut Sender, message: Message) { - sender - .send(message) - .unwrap_or_else(|err| error!("Failed to send CLI message, err: {:?}", err)); -} - -pub fn parse_command(input: &str, msg_s: &mut Sender) { - let mut args = input.split_whitespace(); - - if let Some(cmd_name) = args.next() { - if let Some(cmd) = COMMANDS.iter().find(|cmd| cmd.name == cmd_name) { - let args = args.collect::>(); - - let (arg_len, args) = if cmd.split_spaces { - ( - args.len(), - args.into_iter() - .map(|s| s.to_string()) - .collect::>(), - ) - } else { - (0, vec![args.into_iter().collect::()]) - }; - - use core::cmp::Ordering; - match arg_len.cmp(&cmd.args) { - Ordering::Less => error!("{} takes {} arguments", cmd_name, cmd.args), - Ordering::Greater => { - warn!("{} only takes {} arguments", cmd_name, cmd.args); - let cmd = cmd.cmd; - - cmd(args, msg_s) - }, - Ordering::Equal => { - let cmd = cmd.cmd; - - cmd(args, msg_s) - }, - } - } else { - error!("{} not found", cmd_name); - } - } -} diff --git a/server-cli/src/main.rs b/server-cli/src/main.rs index c8d1a6ac39..944e737246 100644 --- a/server-cli/src/main.rs +++ b/server-cli/src/main.rs @@ -2,30 +2,29 @@ #![deny(clippy::clone_on_ref_ptr)] #![feature(bool_to_option)] -mod admin; /// `server-cli` interface commands not to be confused with the commands sent /// from the client to the server -mod cmd; +mod cli; mod settings; mod shutdown_coordinator; mod tui_runner; mod tuilog; use crate::{ - cmd::Message, shutdown_coordinator::ShutdownCoordinator, tui_runner::Tui, tuilog::TuiLog, + cli::{Admin, ArgvApp, ArgvCommand, Message, SharedCommand, Shutdown}, + shutdown_coordinator::ShutdownCoordinator, + tui_runner::Tui, + tuilog::TuiLog, }; -use clap::{App, Arg, SubCommand}; use common::{clock::Clock, consts::MIN_RECOMMENDED_TOKIO_THREADS}; use common_base::span; use core::sync::atomic::{AtomicUsize, Ordering}; -use server::{ - persistence::{DatabaseSettings, SqlLogMode}, - Event, Input, Server, -}; +use server::{persistence::DatabaseSettings, Event, Input, Server}; use std::{ io, sync::{atomic::AtomicBool, mpsc, Arc}, time::Duration, }; +use structopt::StructOpt; use tracing::{info, trace}; lazy_static::lazy_static! { @@ -35,62 +34,12 @@ const TPS: u64 = 30; #[allow(clippy::unnecessary_wraps)] fn main() -> io::Result<()> { - let matches = App::new("Veloren server cli") - .version(common::util::DISPLAY_VERSION_LONG.as_str()) - .author("The veloren devs ") - .about("The veloren server cli provides an easy to use interface to start a veloren server") - .args(&[ - Arg::with_name("basic") - .short("b") - .long("basic") - .help("Disables the tui"), - Arg::with_name("non-interactive") - .short("n") - .long("non-interactive") - .help("doesn't listen on STDIN. Useful if you want to send the server in background, and your kernels terminal driver will send SIGTTIN to it otherwise. (https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Redirections). and you dont want to use `stty -tostop` or `nohub` or `tmux` or `screen` or `<<< \"\\004\"` to the programm. This implies `-b`"), - Arg::with_name("no-auth") - .long("no-auth") - .help("Runs without auth enabled"), - Arg::with_name("sql-log-mode") - .long("sql-log-mode") - .help("Enables SQL logging, valid values are \"trace\" and \"profile\"") - .possible_values(&["trace", "profile"]) - .takes_value(true) - ]) - .subcommand( - SubCommand::with_name("admin") - .about("Add or remove admins") - .subcommands(vec![ - SubCommand::with_name("add").about("Adds an admin").arg( - Arg::with_name("username") - .help("Name of the admin to add") - .required(true), - ), - SubCommand::with_name("remove") - .about("Removes an admin") - .arg( - Arg::with_name("username") - .help("Name of the admin to remove") - .required(true), - ), - ]), - ) - .get_matches(); + let app = ArgvApp::from_args(); - let basic = matches.is_present("basic") - // Default to basic with these subcommands - || matches - .subcommand_name() - .filter(|name| ["admin"].contains(name)) - .is_some(); - let noninteractive = matches.is_present("non-interactive"); - let no_auth = matches.is_present("no-auth"); - - let sql_log_mode = match matches.value_of("sql-log-mode") { - Some("trace") => SqlLogMode::Trace, - Some("profile") => SqlLogMode::Profile, - _ => SqlLogMode::Disabled, - }; + let basic = app.basic || app.command.is_some(); + let noninteractive = app.non_interactive; + let no_auth = app.no_auth; + let sql_log_mode = app.sql_log_mode; // noninteractive implies basic let basic = basic || noninteractive; @@ -145,19 +94,46 @@ fn main() -> io::Result<()> { sql_log_mode, }; - #[allow(clippy::single_match)] // Note: remove this when there are more subcommands - match matches.subcommand() { - ("admin", Some(sub_m)) => { - admin::admin_subcommand( - runtime, - sub_m, - &server_settings, - &mut editable_settings, - &server_data_dir, - ); - return Ok(()); - }, - _ => {}, + if let Some(command) = app.command { + return match command { + ArgvCommand::Shared(SharedCommand::Admin { command }) => { + let login_provider = server::login_provider::LoginProvider::new( + server_settings.auth_server_address, + runtime, + ); + + match command { + Admin::Add { username, role } => { + // FIXME: Currently the UUID can get returned even if the file didn't + // change, so this can't be relied on as an error + // code; moreover, we do nothing with the UUID + // returned in the success case. Fix the underlying function to return + // enough information that we can reliably return an error code. + let _ = server::add_admin( + &username, + role, + &login_provider, + &mut editable_settings, + &server_data_dir, + ); + }, + Admin::Remove { username } => { + // FIXME: Currently the UUID can get returned even if the file didn't + // change, so this can't be relied on as an error + // code; moreover, we do nothing with the UUID + // returned in the success case. Fix the underlying function to return + // enough information that we can reliably return an error code. + let _ = server::remove_admin( + &username, + &login_provider, + &mut editable_settings, + &server_data_dir, + ); + }, + } + Ok(()) + }, + }; } // Panic hook to ensure that console mode is set back correctly if in non-basic @@ -170,7 +146,7 @@ fn main() -> io::Result<()> { })); } - let tui = (!basic || !noninteractive).then(|| Tui::run(basic)); + let tui = (!noninteractive).then(|| Tui::run(basic)); info!("Starting server..."); @@ -233,30 +209,40 @@ fn main() -> io::Result<()> { if let Some(tui) = tui.as_ref() { match tui.msg_r.try_recv() { Ok(msg) => match msg { - Message::AbortShutdown => shutdown_coordinator.abort_shutdown(&mut server), - Message::Shutdown { grace_period } => { - // TODO: The TUI parser doesn't support quoted strings so it is not - // currently possible to provide a shutdown reason - // from the console. - let message = "The server is shutting down".to_owned(); - shutdown_coordinator.initiate_shutdown(&mut server, grace_period, message); + Message::Shutdown { + command: Shutdown::Cancel, + } => shutdown_coordinator.abort_shutdown(&mut server), + Message::Shutdown { + command: Shutdown::Graceful { seconds, reason }, + } => { + shutdown_coordinator.initiate_shutdown( + &mut server, + Duration::from_secs(seconds), + reason, + ); }, - Message::Quit => { + Message::Shutdown { + command: Shutdown::Immediate, + } => { info!("Closing the server"); break; }, - Message::AddAdmin(username) => { - server.add_admin(&username); + Message::Shared(SharedCommand::Admin { + command: Admin::Add { username, role }, + }) => { + server.add_admin(&username, role); }, - Message::RemoveAdmin(username) => { + Message::Shared(SharedCommand::Admin { + command: Admin::Remove { username }, + }) => { server.remove_admin(&username); }, - Message::LoadArea(view_distance) => { + Message::LoadArea { view_distance } => { #[cfg(feature = "worldgen")] server.create_centered_persister(view_distance); }, - Message::SetSqlLogMode(sql_log_mode) => { - server.set_sql_log_mode(sql_log_mode); + Message::SqlLogMode { mode } => { + server.set_sql_log_mode(mode); }, Message::DisconnectAllClients => { server.disconnect_all_clients(); diff --git a/server-cli/src/settings.rs b/server-cli/src/settings.rs index aa1ab10741..0ca35bde70 100644 --- a/server-cli/src/settings.rs +++ b/server-cli/src/settings.rs @@ -45,7 +45,7 @@ impl Settings { default_settings } - pub fn save_to_file_warn(&self) { + fn save_to_file_warn(&self) { if let Err(e) = self.save_to_file() { warn!(?e, "Failed to save settings"); } diff --git a/server-cli/src/tui_runner.rs b/server-cli/src/tui_runner.rs index d54d48d689..c844a97999 100644 --- a/server-cli/src/tui_runner.rs +++ b/server-cli/src/tui_runner.rs @@ -1,4 +1,4 @@ -use crate::{cmd, Message, LOG}; +use crate::{cli, Message, Shutdown, LOG}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -35,7 +35,11 @@ impl Tui { match event.code { KeyCode::Char('c') => { if event.modifiers.contains(KeyModifiers::CONTROL) { - msg_s.send(Message::Quit).unwrap() + msg_s + .send(Message::Shutdown { + command: Shutdown::Immediate, + }) + .unwrap() } else { input.push('c'); } @@ -46,7 +50,7 @@ impl Tui { }, KeyCode::Enter => { debug!(?input, "tui mode: command entered"); - cmd::parse_command(input, msg_s); + cli::parse_command(input, msg_s); *input = String::new(); }, @@ -96,7 +100,7 @@ impl Tui { }, Ok(_) => { debug!(?line, "basic mode: command entered"); - crate::cmd::parse_command(&line, &mut msg_s); + crate::cli::parse_command(&line, &mut msg_s); }, } } diff --git a/server/Cargo.toml b/server/Cargo.toml index b30c8b1a12..31633df285 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,6 +31,9 @@ vek = { version = "0.14.1", features = ["serde"] } futures-util = "0.3.7" tokio = { version = "1", default-features = false, features = ["rt"] } prometheus-hyper = "0.1.2" +atomicwrites = "0.3.0" +chrono = { version = "0.4.9", features = ["serde"] } +humantime = "2.1.0" itertools = "0.10" lazy_static = "1.4.0" scan_fmt = "0.2.6" @@ -38,7 +41,6 @@ ron = { version = "0.6", default-features = false } serde = { version = "1.0.110", features = ["derive"] } serde_json = "1.0.50" rand = { version = "0.8", features = ["small_rng"] } -chrono = "0.4.9" hashbrown = { version = "0.9", features = ["rayon", "serde", "nightly"] } rayon = "1.5" crossbeam-channel = "0.5" diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 4ba7c43dd9..c615dfdcf7 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -3,13 +3,15 @@ //! `CHAT_COMMANDS` and provide a handler function. use crate::{ - settings::{BanRecord, EditableSetting}, + settings::{ + Ban, BanAction, BanInfo, EditableSetting, SettingError, WhitelistInfo, WhitelistRecord, + }, wiring::{Logic, OutputFormula}, Server, SpawnPoint, StateExt, }; use assets::AssetExt; use authc::Uuid; -use chrono::{NaiveTime, Timelike}; +use chrono::{NaiveTime, Timelike, Utc}; use common::{ assets, cmd::{ChatCommand, BUFF_PACK, BUFF_PARSER, CHAT_COMMANDS, CHAT_SHORTCUTS}, @@ -19,7 +21,7 @@ use common::{ buff::{Buff, BuffCategory, BuffData, BuffKind, BuffSource}, inventory::item::{tool::AbilityMap, MaterialStatManifest, Quality}, invite::InviteKind, - ChatType, Inventory, Item, LightEmitter, WaypointArea, + AdminRole, ChatType, Inventory, Item, LightEmitter, WaypointArea, }, depot, effect::Effect, @@ -36,10 +38,11 @@ use common_net::{ sync::WorldSyncExt, }; use common_state::{BuildAreaError, BuildAreas}; -use core::{convert::TryFrom, ops::Not, time::Duration}; +use core::{cmp::Ordering, convert::TryFrom, time::Duration}; use hashbrown::{HashMap, HashSet}; +use humantime::Duration as HumanDuration; use rand::Rng; -use specs::{Builder, Entity as EcsEntity, Join, WorldExt}; +use specs::{storage::StorageEntry, Builder, Entity as EcsEntity, Join, WorldExt}; use vek::*; use wiring::{Circuit, Wire, WiringAction, WiringActionEffect, WiringElement}; use world::util::Sampler; @@ -54,15 +57,7 @@ pub trait ChatCommandExt { impl ChatCommandExt for ChatCommand { #[allow(clippy::needless_return)] // TODO: Pending review in #587 fn execute(&self, server: &mut Server, entity: EcsEntity, args: String) { - if self.needs_admin() && !server.entity_is_admin(entity) { - server.notify_client( - entity, - ServerGeneral::server_msg( - ChatType::CommandError, - format!("You don't have permission to use '/{}'.", self.keyword()), - ), - ); - } else if let Err(err) = get_handler(self)(server, entity, entity, args, &self) { + if let Err(err) = do_command(server, entity, entity, args, self) { server.notify_client( entity, ServerGeneral::server_msg(ChatType::CommandError, err), @@ -91,8 +86,22 @@ type CmdResult = Result; /// failed; on failure, the string is sent to the client who initiated the /// command. type CommandHandler = fn(&mut Server, EcsEntity, EcsEntity, String, &ChatCommand) -> CmdResult<()>; -fn get_handler(cmd: &ChatCommand) -> CommandHandler { - match cmd { + +fn do_command( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + args: String, + cmd: &ChatCommand, +) -> CmdResult<()> { + // Make sure your role is at least high enough to execute this command. + if cmd.needs_role() > server.entity_admin_role(client) { + return Err(format!( + "You don't have permission to use '/{}'.", + cmd.keyword() + )); + } + let handler: CommandHandler = match cmd { ChatCommand::Adminify => handle_adminify, ChatCommand::Airship => handle_spawn_airship, ChatCommand::Alias => handle_alias, @@ -155,7 +164,9 @@ fn get_handler(cmd: &ChatCommand) -> CommandHandler { ChatCommand::Wiring => handle_spawn_wiring, ChatCommand::Whitelist => handle_whitelist, ChatCommand::World => handle_world, - } + }; + + handler(server, client, target, args, cmd) } // Fallibly get position of entity with the given descriptor (used for error @@ -198,6 +209,25 @@ fn insert_or_replace_component( .map_err(|_| format!("Entity {:?} is dead!", descriptor)) } +fn uuid(server: &Server, entity: EcsEntity, descriptor: &str) -> CmdResult { + server + .state + .ecs() + .read_storage::() + .get(entity) + .map(|player| player.uuid()) + .ok_or_else(|| format!("Cannot get player information for {:?}", descriptor)) +} + +fn real_role(server: &Server, uuid: Uuid, descriptor: &str) -> CmdResult { + server + .editable_settings() + .admins + .get(&uuid) + .map(|record| record.role.into()) + .ok_or_else(|| format!("Cannot get administrator roles for {:?} uuid", descriptor)) +} + // Fallibly get uid of entity with the given descriptor (used for error // message). fn uid(server: &Server, target: EcsEntity, descriptor: &str) -> CmdResult { @@ -230,15 +260,46 @@ fn no_sudo(client: EcsEntity, target: EcsEntity) -> CmdResult<()> { } } -// Prevent application to hardcoded administrators. -fn verify_not_hardcoded_admin(server: &mut Server, uuid: Uuid, reason: &str) -> CmdResult<()> { - server +/// Ensure that client role is above target role, for the purpose of performing +/// some (often permanent) administrative action on the target. Note that this +/// function is *not* a replacement for actually verifying that the client +/// should be able to execute the command at all, which still needs to be +/// rechecked, nor does it guarantee that either the client or the target +/// actually have an entry in the admin settings file. +/// +/// For our purposes, there are *two* roles--temporary role, and permanent role. +/// For the purpose of these checks, currently *any* permanent role overrides +/// *any* temporary role (this may change if more roles are added that aren't +/// moderator or administrator). If the permanent roles match, the temporary +/// roles are used as a tiebreaker. /adminify should ensure that no one's +/// temporary role can be different from their permanent role without someone +/// with a higher role than their permanent role allowing it, and only permanent +/// roles should be recorded in the settings files. +fn verify_above_role( + server: &mut Server, + (client, client_uuid): (EcsEntity, Uuid), + (player, player_uuid): (EcsEntity, Uuid), + reason: &str, +) -> CmdResult<()> { + let client_temp = server.entity_admin_role(client); + let client_perm = server .editable_settings() .admins - .contains(&uuid) - .not() - .then_some(()) - .ok_or_else(|| reason.into()) + .get(&client_uuid) + .map(|record| record.role); + + let player_temp = server.entity_admin_role(player); + let player_perm = server + .editable_settings() + .admins + .get(&player_uuid) + .map(|record| record.role); + + if client_perm > player_perm || client_perm == player_perm && client_temp > player_temp { + Ok(()) + } else { + Err(reason.into()) + } } fn find_alias(ecs: &specs::World, alias: &str) -> CmdResult<(EcsEntity, Uuid)> { @@ -262,7 +323,72 @@ fn find_username(server: &mut Server, username: &str) -> CmdResult { .state .mut_resource::() .username_to_uuid(username) - .map_err(|_| format!("Unable to determine UUID for username \"{}\"", username)) + .map_err(|_| format!("Unable to determine UUID for username {:?}", username)) +} + +/// NOTE: Intended to be run only on logged-in clients. +fn uuid_to_username( + server: &mut Server, + fallback_entity: EcsEntity, + uuid: Uuid, +) -> CmdResult { + let make_err = || format!("Unable to determine username for UUID {:?}", uuid); + let player_storage = server.state.ecs().read_storage::(); + + let fallback_alias = &player_storage + .get(fallback_entity) + .ok_or_else(make_err)? + .alias; + + server + .state + .ecs() + .read_resource::() + .uuid_to_username(uuid, fallback_alias) + .map_err(|_| make_err()) +} + +fn edit_setting_feedback( + server: &mut Server, + client: EcsEntity, + result: Option<(String, Result<(), SettingError>)>, + failure: impl FnOnce() -> String, +) -> CmdResult<()> { + let (info, result) = result.ok_or_else(failure)?; + match result { + Ok(()) => { + server.notify_client( + client, + ServerGeneral::server_msg(ChatType::CommandInfo, info), + ); + Ok(()) + }, + Err(SettingError::Io(err)) => { + warn!( + ?err, + "Failed to write settings file to disk, but succeeded in memory (success message: \ + {})", + info, + ); + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandError, + format!( + "Failed to write settings file to disk, but succeeded in memory.\n + Error (storage): {:?}\n + Success (memory): {}", + err, info + ), + ), + ); + Ok(()) + }, + Err(SettingError::Integrity(err)) => Err(format!( + "Encountered an error while validating the request: {:?}", + err + )), + } } fn handle_drop_all( @@ -451,35 +577,41 @@ fn handle_set_motd( action: &ChatCommand, ) -> CmdResult<()> { let data_dir = server.data_dir(); + let client_uuid = uuid(server, client, "client")?; + // Ensure the person setting this has a real role in the settings file, since + // it's persistent. + let _client_real_role = real_role(server, client_uuid, "client")?; match scan_fmt!(&args, &action.arg_fmt(), String) { Ok(msg) => { - server - .editable_settings_mut() - .server_description - .edit(data_dir.as_ref(), |d| **d = msg.clone()); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, - format!("Server description set to \"{}\"", msg), - ), - ); + let edit = + server + .editable_settings_mut() + .server_description + .edit(data_dir.as_ref(), |d| { + let info = format!("Server description set to {:?}", msg); + **d = msg; + Some(info) + }); + drop(data_dir); + edit_setting_feedback(server, client, edit, || { + unreachable!("edit always returns Some") + }) }, Err(_) => { - server - .editable_settings_mut() - .server_description - .edit(data_dir.as_ref(), |d| d.clear()); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, - "Removed server description".to_string(), - ), - ); + let edit = + server + .editable_settings_mut() + .server_description + .edit(data_dir.as_ref(), |d| { + d.clear(); + Some("Removed server description".to_string()) + }); + drop(data_dir); + edit_setting_feedback(server, client, edit, || { + unreachable!("edit always returns Some") + }) }, } - Ok(()) } fn handle_jump( @@ -1365,12 +1497,16 @@ fn handle_help( ) } else { let mut message = String::new(); - for cmd in CHAT_COMMANDS.iter() { - if !cmd.needs_admin() || server.entity_is_admin(client) { + let entity_role = server.entity_admin_role(client); + + // Iterate through all commands you have permission to use. + CHAT_COMMANDS + .iter() + .filter(|cmd| cmd.needs_role() <= entity_role) + .for_each(|cmd| { message += &cmd.help_string(); message += "\n"; - } - } + }); message += "Additionally, you can use the following shortcuts:"; for (k, v) in CHAT_SHORTCUTS.iter() { message += &format!(" /{} => /{}", k, v.keyword()); @@ -1870,40 +2006,107 @@ fn handle_spawn_wiring( #[allow(clippy::useless_conversion)] // TODO: Pending review in #587 fn handle_adminify( server: &mut Server, - _client: EcsEntity, + client: EcsEntity, _target: EcsEntity, args: String, action: &ChatCommand, ) -> CmdResult<()> { - if let Ok(alias) = scan_fmt!(&args, &action.arg_fmt(), String) { - let (player, uuid) = find_alias(server.state.ecs(), &alias)?; - let uid = uid(server, player, "player")?; - verify_not_hardcoded_admin( - server, - uuid, - "Admins specified in server configuration files cannot be de-adminified.", - )?; - let is_admin = if server - .state - .read_component_copied::(player) - .is_some() - { - server - .state - .ecs() - .write_storage::() - .remove(player); - false + if let (Some(alias), desired_role) = scan_fmt_some!(&args, &action.arg_fmt(), String, String) { + let desired_role = if let Some(mut desired_role) = desired_role { + desired_role.make_ascii_lowercase(); + Some(match &*desired_role { + "admin" => AdminRole::Admin, + "moderator" => AdminRole::Moderator, + _ => { + return Err(action.help_string()); + }, + }) } else { - server - .state - .ecs() - .write_storage() - .insert(player, comp::Admin) - .is_ok() + None }; - // Update player list so the player shows up as admin in client chat. - let msg = ServerGeneral::PlayerListUpdate(PlayerListUpdate::Admin(uid, is_admin)); + let (player, player_uuid) = find_alias(server.state.ecs(), &alias)?; + let client_uuid = uuid(server, client, "client")?; + let uid = uid(server, player, "player")?; + + // Your permanent role, not your temporary role, is what's used to determine + // what temporary roles you can grant. + let client_real_role = real_role(server, client_uuid, "client")?; + + // This appears to prevent de-mod / de-admin for mods / admins with access to + // this command, but it does not in the case where the target is + // temporary, because `verify_above_role` always values permanent roles + // above temporary ones. + verify_above_role( + server, + (client, client_uuid), + (player, player_uuid), + "Cannot reassign a role for anyone with your role or higher.", + )?; + + // Ensure that it's not possible to assign someone a higher role than your own + // (i.e. even if mods had the ability to create temporary mods, they + // wouldn't be able to create temporary admins). + // + // Also note that we perform no more permissions checks after this point based + // on the assignee's temporary role--even if the player's temporary role + // is higher than the client's, we still allow the role to be reduced to + // the selected role, as long as they would have permission to assign it + // in the first place. This is consistent with our + // policy on bans--banning or lengthening a ban (decreasing player permissions) + // can be done even after an unban or ban shortening (increasing player + // permissions) by someone with a higher role than the person doing the + // ban. So if we change how bans work, we should change how things work + // here, too, for consistency. + if desired_role > Some(client_real_role.into()) { + return Err( + "Cannot assign someone a temporary role higher than your own permanent one".into(), + ); + } + + let mut admin_storage = server.state.ecs().write_storage::(); + let entry = admin_storage + .entry(player) + .map_err(|_| "Cannot find player entity!".to_string())?; + match (entry, desired_role) { + (StorageEntry::Vacant(_), None) => { + return Err("Player already has no role!".into()); + }, + (StorageEntry::Occupied(o), None) => { + let old_role = o.remove().0; + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandInfo, + format!("Role removed from player {}: {:?}", alias, old_role), + ), + ); + }, + (entry, Some(desired_role)) => { + let verb = match entry + .replace(comp::Admin(desired_role)) + .map(|old_admin| old_admin.0.cmp(&desired_role)) + { + Some(Ordering::Equal) => { + return Err("Player already has that role!".into()); + }, + Some(Ordering::Greater) => "downgraded", + Some(Ordering::Less) | None => "upgraded", + }; + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandInfo, + format!("Role for player {} {} to {:?}", alias, verb, desired_role), + ), + ); + }, + }; + // Update player list so the player shows up as moderator in client chat. + // + // NOTE: We deliberately choose not to differentiate between moderators and + // administrators in the player list. + let is_moderator = desired_role.is_some(); + let msg = ServerGeneral::PlayerListUpdate(PlayerListUpdate::Moderator(uid, is_moderator)); server.state.notify_players(msg); Ok(()) } else { @@ -2299,6 +2502,10 @@ fn handle_disconnect_all_players( args: String, _action: &ChatCommand, ) -> CmdResult<()> { + let client_uuid = uuid(server, client, "client")?; + // Make sure temporary mods/admins can't run this command. + let _role = real_role(server, client_uuid, "role")?; + if args != *"confirm" { return Err( "Please run the command again with the second argument of \"confirm\" to confirm that \ @@ -2433,14 +2640,19 @@ fn handle_sudo( { let cmd_args = cmd_args.unwrap_or_else(|| String::from("")); if let Ok(action) = cmd.parse() { - let ecs = server.state.ecs(); - let (entity, uuid) = find_alias(ecs, &player_alias)?; - verify_not_hardcoded_admin( + let (player, player_uuid) = find_alias(server.state.ecs(), &player_alias)?; + let client_uuid = uuid(server, client, "client")?; + verify_above_role( server, - uuid, - "Cannot sudo admins specified in server configuration files.", + (client, client_uuid), + (player, player_uuid), + "Cannot sudo players with roles higher than your own.", )?; - get_handler(&action)(server, client, entity, cmd_args, &action) + + // TODO: consider making this into a tail call or loop (to avoid the potential + // stack overflow, although it's less of a risk coming from only mods and + // admins). + do_command(server, client, player, cmd_args, &action) } else { Err(format!("Unknown command: /{}", cmd)) } @@ -2477,35 +2689,63 @@ fn handle_whitelist( args: String, action: &ChatCommand, ) -> CmdResult<()> { + let now = Utc::now(); + if let Ok((whitelist_action, username)) = scan_fmt!(&args, &action.arg_fmt(), String, String) { + let client_uuid = uuid(server, client, "client")?; + let client_username = uuid_to_username(server, client, client_uuid)?; + let client_role = real_role(server, client_uuid, "client")?; + if whitelist_action.eq_ignore_ascii_case("add") { let uuid = find_username(server, &username)?; - server - .editable_settings_mut() - .whitelist - .edit(server.data_dir().as_ref(), |w| w.insert(uuid)); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, - format!("\"{}\" added to whitelist", username), - ), - ); - Ok(()) + + let record = WhitelistRecord { + date: now, + info: Some(WhitelistInfo { + username_when_whitelisted: username.clone(), + whitelisted_by: client_uuid, + whitelisted_by_username: client_username, + whitelisted_by_role: client_role.into(), + }), + }; + + let edit = + server + .editable_settings_mut() + .whitelist + .edit(server.data_dir().as_ref(), |w| { + if w.insert(uuid, record).is_some() { + None + } else { + Some(format!("added to whitelist: {}", username)) + } + }); + edit_setting_feedback(server, client, edit, || { + format!("already in whitelist: {}!", username) + }) } else if whitelist_action.eq_ignore_ascii_case("remove") { + let client_uuid = uuid(server, client, "client")?; + let client_role = real_role(server, client_uuid, "client")?; + let uuid = find_username(server, &username)?; - server - .editable_settings_mut() - .whitelist - .edit(server.data_dir().as_ref(), |w| w.remove(&uuid)); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, - format!("\"{}\" removed from whitelist", username), - ), - ); - Ok(()) + let mut err_info = "not part of whitelist: "; + let edit = + server + .editable_settings_mut() + .whitelist + .edit(server.data_dir().as_ref(), |w| { + w.remove(&uuid) + .filter(|record| { + if record.whitelisted_by_role() <= client_role.into() { + true + } else { + err_info = "permission denied to remove user: "; + false + } + }) + .map(|_| format!("removed from whitelist: {}", username)) + }); + edit_setting_feedback(server, client, edit, || format!("{}{}", err_info, username)) } else { Err(action.help_string()) } @@ -2516,13 +2756,15 @@ fn handle_whitelist( fn kick_player( server: &mut Server, - (target_player, uuid): (EcsEntity, Uuid), + (client, client_uuid): (EcsEntity, Uuid), + (target_player, target_player_uuid): (EcsEntity, Uuid), reason: &str, ) -> CmdResult<()> { - verify_not_hardcoded_admin( + verify_above_role( server, - uuid, - "Cannot kick admins specified in server configuration files.", + (client, client_uuid), + (target_player, target_player_uuid), + "Cannot kick players with roles higher than your own.", )?; server.notify_client( target_player, @@ -2548,11 +2790,12 @@ fn handle_kick( if let (Some(target_alias), reason_opt) = scan_fmt_some!(&args, &action.arg_fmt(), String, String) { + let client_uuid = uuid(server, client, "client")?; let reason = reason_opt.unwrap_or_default(); let ecs = server.state.ecs(); let target_player = find_alias(ecs, &target_alias)?; - kick_player(server, target_player, &reason)?; + kick_player(server, (client, client_uuid), target_player, &reason)?; server.notify_client( client, ServerGeneral::server_msg( @@ -2576,39 +2819,77 @@ fn handle_ban( args: String, action: &ChatCommand, ) -> CmdResult<()> { - if let (Some(username), reason_opt) = scan_fmt_some!(&args, &action.arg_fmt(), String, String) { + if let (Some(username), overwrite, parse_duration, reason_opt) = scan_fmt_some!( + &args, + &action.arg_fmt(), + String, + bool, + HumanDuration, + String + ) { let reason = reason_opt.unwrap_or_default(); - let uuid = find_username(server, &username)?; + let overwrite = overwrite.unwrap_or(false); - if server.editable_settings().banlist.contains_key(&uuid) { - Err(format!("{} is already on the banlist", username)) - } else { - server - .editable_settings_mut() - .banlist - .edit(server.data_dir().as_ref(), |b| { - b.insert(uuid, BanRecord { - username_when_banned: username.clone(), - reason: reason.clone(), - }); - }); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, + let player_uuid = find_username(server, &username)?; + + let client_uuid = uuid(server, client, "client")?; + let client_username = uuid_to_username(server, client, client_uuid)?; + let client_role = real_role(server, client_uuid, "client")?; + + let now = Utc::now(); + let end_date = parse_duration + .map(|duration| chrono::Duration::from_std(duration.into())) + .transpose() + .map_err(|err| format!("Error converting to duration: {}", err))? + // On overflow (someone adding some ridiculous timespan), just make the ban infinite. + .and_then(|duration| now.checked_add_signed(duration)); + + let ban_info = BanInfo { + performed_by: client_uuid, + performed_by_username: client_username, + performed_by_role: client_role.into(), + }; + + let ban = Ban { + reason: reason.clone(), + info: Some(ban_info), + end_date, + }; + + let edit = server + .editable_settings_mut() + .banlist + .ban_action( + server.data_dir().as_ref(), + now, + player_uuid, + username.clone(), + BanAction::Ban(ban), + overwrite, + ) + .map(|result| { + ( format!("Added {} to the banlist with reason: {}", username, reason), - ), - ); + result, + ) + }); - // If the player is online kick them (this may fail if the player is a hardcoded - // admin; we don't care about that case because hardcoded admins can log on even - // if they're on the ban list). - let ecs = server.state.ecs(); - if let Ok(target_player) = find_uuid(ecs, uuid) { - let _ = kick_player(server, (target_player, uuid), &reason); - } - Ok(()) + edit_setting_feedback(server, client, edit, || { + format!("{} is already on the banlist", username) + })?; + // If the player is online kick them (this may fail if the player is a hardcoded + // admin; we don't care about that case because hardcoded admins can log on even + // if they're on the ban list). + let ecs = server.state.ecs(); + if let Ok(target_player) = find_uuid(ecs, player_uuid) { + let _ = kick_player( + server, + (client, client_uuid), + (target_player, player_uuid), + &reason, + ); } + Ok(()) } else { Err(action.help_string()) } @@ -2622,22 +2903,38 @@ fn handle_unban( action: &ChatCommand, ) -> CmdResult<()> { if let Ok(username) = scan_fmt!(&args, &action.arg_fmt(), String) { - let uuid = find_username(server, &username)?; + let player_uuid = find_username(server, &username)?; - server + let client_uuid = uuid(server, client, "client")?; + let client_username = uuid_to_username(server, client, client_uuid)?; + let client_role = real_role(server, client_uuid, "client")?; + + let now = Utc::now(); + + let ban_info = BanInfo { + performed_by: client_uuid, + performed_by_username: client_username, + performed_by_role: client_role.into(), + }; + + let unban = BanAction::Unban(ban_info); + + let edit = server .editable_settings_mut() .banlist - .edit(server.data_dir().as_ref(), |b| { - b.remove(&uuid); - }); - server.notify_client( - client, - ServerGeneral::server_msg( - ChatType::CommandInfo, - format!("{} was successfully unbanned", username), - ), - ); - Ok(()) + .ban_action( + server.data_dir().as_ref(), + now, + player_uuid, + username.clone(), + unban, + false, + ) + .map(|result| (format!("{} was successfully unbanned", username), result)); + + edit_setting_feedback(server, client, edit, || { + format!("{} was already unbanned", username) + }) } else { Err(action.help_string()) } diff --git a/server/src/events/interaction.rs b/server/src/events/interaction.rs index c559fc96cf..60293e928d 100644 --- a/server/src/events/interaction.rs +++ b/server/src/events/interaction.rs @@ -173,7 +173,7 @@ pub fn handle_possess(server: &mut Server, possessor_uid: Uid, possesse_uid: Uid common_net::msg::server::PlayerInfo { player_alias: possessor_player.alias.clone(), is_online: true, - is_admin: admins.get(possessor).is_some(), + is_moderator: admins.get(possessor).is_some(), character: ecs.read_storage::().get(possesse).map( |s| common_net::msg::CharacterInfo { name: s.name.clone(), diff --git a/server/src/lib.rs b/server/src/lib.rs index 6507e234e2..eba8a14299 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -103,7 +103,7 @@ use std::{ #[cfg(not(feature = "worldgen"))] use test_world::{IndexOwned, World}; use tokio::{runtime::Runtime, sync::Notify}; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, info, trace, warn}; use vek::*; use crate::{ @@ -1048,23 +1048,25 @@ impl Server { } } - fn entity_is_admin(&self, entity: EcsEntity) -> bool { + fn entity_admin_role(&self, entity: EcsEntity) -> Option { self.state - .read_storage::() - .get(entity) - .is_some() + .read_component_copied::(entity) + .map(|admin| admin.0) } pub fn number_of_players(&self) -> i64 { self.state.ecs().read_storage::().join().count() as i64 } - pub fn add_admin(&self, username: &str) { + /// NOTE: Do *not* allow this to be called from any command that doesn't go + /// through the CLI! + pub fn add_admin(&mut self, username: &str, role: comp::AdminRole) { let mut editable_settings = self.editable_settings_mut(); let login_provider = self.state.ecs().fetch::(); let data_dir = self.data_dir(); if let Some(entity) = add_admin( username, + role, &login_provider, &mut editable_settings, &data_dir.path, @@ -1079,11 +1081,16 @@ impl Server { .find(|(_, player)| player.uuid() == uuid) .map(|(e, _)| e) }) { - // Add admin component if the player is ingame - let _ = self.state.ecs().write_storage().insert(entity, comp::Admin); + drop((data_dir, login_provider, editable_settings)); + // Add admin component if the player is ingame; if they are not, we can ignore + // the write failure. + self.state + .write_component_ignore_entity_dead(entity, comp::Admin(role)); }; } + /// NOTE: Do *not* allow this to be called from any command that doesn't go + /// through the CLI! pub fn remove_admin(&self, username: &str) { let mut editable_settings = self.editable_settings_mut(); let login_provider = self.state.ecs().fetch::(); @@ -1162,29 +1169,77 @@ impl Drop for Server { } } +#[must_use] +pub fn handle_edit( + data: T, + result: Option<(String, Result<(), settings::SettingError>)>, +) -> Option { + use crate::settings::SettingError; + let (info, result) = result?; + match result { + Ok(()) => { + info!("{}", info); + Some(data) + }, + Err(SettingError::Io(err)) => { + warn!( + ?err, + "Failed to write settings file to disk, but succeeded in memory (success message: \ + {})", + info, + ); + Some(data) + }, + Err(SettingError::Integrity(err)) => { + error!(?err, "Encountered an error while validating the request",); + None + }, + } +} + /// If successful returns the Some(uuid) of the added admin +/// +/// NOTE: Do *not* allow this to be called from any command that doesn't go +/// through the CLI! +#[must_use] pub fn add_admin( username: &str, + role: comp::AdminRole, login_provider: &LoginProvider, editable_settings: &mut EditableSettings, data_dir: &std::path::Path, ) -> Option { use crate::settings::EditableSetting; + let role_ = role.into(); match login_provider.username_to_uuid(username) { - Ok(uuid) => editable_settings.admins.edit(data_dir, |admins| { - if admins.insert(uuid) { - info!("Successfully added {} ({}) as an admin!", username, uuid); - Some(uuid) - } else { - info!("{} ({}) is already an admin!", username, uuid); - None - } - }), + Ok(uuid) => handle_edit( + uuid, + editable_settings.admins.edit(data_dir, |admins| { + match admins.insert(uuid, settings::AdminRecord { + username_when_admined: Some(username.into()), + date: chrono::Utc::now(), + role: role_, + }) { + None => Some(format!( + "Successfully added {} ({}) as an admin!", + username, uuid + )), + Some(old_admin) if old_admin.role == role_ => { + info!("{} ({}) already has role: {:?}!", username, uuid, role); + None + }, + Some(old_admin) => Some(format!( + "{} ({}) role changed from {:?} to {:?}!", + username, uuid, old_admin.role, role + )), + } + }), + ), Err(err) => { error!( ?err, - "Could not find uuid for this name either the user does not exist or there was an \ - error communicating with the auth server." + "Could not find uuid for this name; either the user does not exist or there was \ + an error communicating with the auth server." ); None }, @@ -1192,6 +1247,10 @@ pub fn add_admin( } /// If successful returns the Some(uuid) of the removed admin +/// +/// NOTE: Do *not* allow this to be called from any command that doesn't go +/// through the CLI! +#[must_use] pub fn remove_admin( username: &str, login_provider: &LoginProvider, @@ -1200,23 +1259,25 @@ pub fn remove_admin( ) -> Option { use crate::settings::EditableSetting; match login_provider.username_to_uuid(username) { - Ok(uuid) => editable_settings.admins.edit(data_dir, |admins| { - if admins.remove(&uuid) { - info!( - "Successfully removed {} ({}) from the admins", - username, uuid - ); - Some(uuid) - } else { - info!("{} ({}) is not an admin!", username, uuid); - None - } - }), + Ok(uuid) => handle_edit( + uuid, + editable_settings.admins.edit(data_dir, |admins| { + if let Some(admin) = admins.remove(&uuid) { + Some(format!( + "Successfully removed {} ({}) with role {:?} from the admins list", + username, uuid, admin.role, + )) + } else { + info!("{} ({}) is not an admin!", username, uuid); + None + } + }), + ), Err(err) => { error!( ?err, - "Could not find uuid for this name either the user does not exist or there was an \ - error communicating with the auth server." + "Could not find uuid for this name; either the user does not exist or there was \ + an error communicating with the auth server." ); None }, diff --git a/server/src/login_provider.rs b/server/src/login_provider.rs index b0b149b76d..90eac64fa6 100644 --- a/server/src/login_provider.rs +++ b/server/src/login_provider.rs @@ -1,11 +1,13 @@ -use crate::settings::BanRecord; +use crate::settings::{AdminRecord, BanEntry, WhitelistRecord}; use authc::{AuthClient, AuthClientError, AuthToken, Uuid}; +use chrono::Utc; +use common::comp::AdminRole; use common_net::msg::RegisterError; #[cfg(feature = "plugins")] use common_state::plugin::memory_manager::EcsWorld; #[cfg(feature = "plugins")] use common_state::plugin::PluginMgr; -use hashbrown::{HashMap, HashSet}; +use hashbrown::HashMap; use plugin_api::event::{PlayerJoinEvent, PlayerJoinResult}; use specs::Component; use specs_idvs::IdvStorage; @@ -100,26 +102,39 @@ impl LoginProvider { pending: &mut PendingLogin, #[cfg(feature = "plugins")] world: &EcsWorld, #[cfg(feature = "plugins")] plugin_manager: &PluginMgr, - admins: &HashSet, - whitelist: &HashSet, - banlist: &HashMap, + admins: &HashMap, + whitelist: &HashMap, + banlist: &HashMap, ) -> Option> { match pending.pending_r.try_recv() { Ok(Err(e)) => Some(Err(e)), Ok(Ok((username, uuid))) => { + let now = Utc::now(); // Hardcoded admins can always log in. - let is_admin = admins.contains(&uuid); - if !is_admin { - if let Some(ban_record) = banlist.get(&uuid) { + let admin = admins.get(&uuid); + if let Some(ban) = banlist + .get(&uuid) + .and_then(|ban_record| ban_record.current.action.ban()) + { + // Make sure the ban is active, and that we can't override it. + // + // If we are an admin and our role is at least as high as the role of the + // person who banned us, we can override the ban; we negate this to find + // people who cannot override it. + let exceeds_ban_role = |admin: &AdminRecord| { + Into::::into(admin.role) + >= Into::::into(ban.performed_by_role()) + }; + if !ban.is_expired(now) && !admin.map_or(false, exceeds_ban_role) { // Pull reason string out of ban record and send a copy of it - return Some(Err(RegisterError::Banned(ban_record.reason.clone()))); + return Some(Err(RegisterError::Banned(ban.reason.clone()))); } + } - // non-admins can only join if the whitelist is empty (everyone can join) - // or his name is in the whitelist - if !whitelist.is_empty() && !whitelist.contains(&uuid) { - return Some(Err(RegisterError::NotOnWhitelist)); - } + // non-admins can only join if the whitelist is empty (everyone can join) + // or their name is in the whitelist. + if admin.is_none() && !whitelist.is_empty() && !whitelist.contains_key(&uuid) { + return Some(Err(RegisterError::NotOnWhitelist)); } #[cfg(feature = "plugins")] @@ -131,7 +146,7 @@ impl LoginProvider { player_id: *uuid.as_bytes(), }) { Ok(e) => { - if !is_admin { + if admin.is_none() { for i in e.into_iter() { if let PlayerJoinResult::Kick(a) = i { return Some(Err(RegisterError::Kicked(a))); @@ -189,4 +204,18 @@ impl LoginProvider { None => Ok(derive_uuid(username)), } } + + pub fn uuid_to_username( + &self, + uuid: Uuid, + fallback_alias: &str, + ) -> Result { + match &self.auth_server { + Some(srv) => { + //TODO: optimize + self.runtime.block_on(srv.uuid_to_username(uuid)) + }, + None => Ok(fallback_alias.into()), + } + } } diff --git a/server/src/persistence/mod.rs b/server/src/persistence/mod.rs index 0b09177279..7cbdbe1e50 100644 --- a/server/src/persistence/mod.rs +++ b/server/src/persistence/mod.rs @@ -115,6 +115,38 @@ pub enum SqlLogMode { Trace, } +impl SqlLogMode { + pub fn variants() -> [&'static str; 3] { ["disabled", "profile", "trace"] } +} + +impl Default for SqlLogMode { + fn default() -> Self { Self::Disabled } +} + +impl core::str::FromStr for SqlLogMode { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "disabled" => Ok(Self::Disabled), + "profile" => Ok(Self::Profile), + "trace" => Ok(Self::Trace), + _ => Err("Could not parse SqlLogMode"), + } + } +} + +impl ToString for SqlLogMode { + fn to_string(&self) -> String { + match self { + SqlLogMode::Disabled => "disabled", + SqlLogMode::Profile => "profile", + SqlLogMode::Trace => "trace", + } + .into() + } +} + /// Runs any pending database migrations. This is executed during server startup pub fn run_migrations(settings: &DatabaseSettings) { let mut conn = establish_connection(settings, ConnectionMode::ReadWrite); diff --git a/server/src/settings.rs b/server/src/settings.rs index d193ac657a..1424af9fe5 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -1,17 +1,26 @@ +pub mod admin; +pub mod banlist; mod editable; +pub mod server_description; +pub mod whitelist; -pub use editable::EditableSetting; +pub use editable::{EditableSetting, Error as SettingError}; -use authc::Uuid; -use hashbrown::{HashMap, HashSet}; +pub use admin::{AdminRecord, Admins}; +pub use banlist::{ + Ban, BanAction, BanEntry, BanError, BanErrorKind, BanInfo, BanKind, BanRecord, Banlist, +}; +pub use server_description::ServerDescription; +pub use whitelist::{Whitelist, WhitelistInfo, WhitelistRecord}; + +use chrono::Utc; +use core::time::Duration; use portpicker::pick_unused_port; use serde::{Deserialize, Serialize}; use std::{ fs, net::SocketAddr, - ops::{Deref, DerefMut}, path::{Path, PathBuf}, - time::Duration, }; use tracing::{error, warn}; use world::sim::FileOpts; @@ -159,30 +168,17 @@ fn with_config_dir(path: &Path) -> PathBuf { path } -#[derive(Deserialize, Serialize)] -pub struct BanRecord { - pub username_when_banned: String, - pub reason: String, -} - -#[derive(Deserialize, Serialize, Default)] -#[serde(transparent)] -pub struct Whitelist(HashSet); - -#[derive(Deserialize, Serialize, Default)] -#[serde(transparent)] -pub struct Banlist(HashMap); - -#[derive(Deserialize, Serialize)] -#[serde(transparent)] -pub struct ServerDescription(String); -impl Default for ServerDescription { - fn default() -> Self { Self("This is the best Veloren server".into()) } -} - -#[derive(Deserialize, Serialize, Default)] -#[serde(transparent)] -pub struct Admins(HashSet); +/// Our upgrade guarantee is that if validation succeeds +/// for an old version, then migration to the next version must always succeed +/// and produce a valid settings file for that version (if we need to change +/// this in the future, it should require careful discussion). Therefore, we +/// would normally panic if the upgrade produced an invalid settings file, which +/// we would perform by doing the following post-validation (example +/// is given for a hypothetical upgrade from Whitelist_V1 to Whitelist_V2): +/// +/// Ok(Whitelist_V2::try_into().expect()) +const MIGRATION_UPGRADE_GUARANTEE: &str = "Any valid file of an old verison should be able to \ + successfully migrate to the latest version."; /// Combines all the editable settings into one struct that is stored in the ecs pub struct EditableSettings { @@ -204,69 +200,25 @@ impl EditableSettings { pub fn singleplayer(data_dir: &Path) -> Self { let load = Self::load(data_dir); + + let mut server_description = ServerDescription::default(); + *server_description = "Who needs friends anyway?".into(); + + let mut admins = Admins::default(); + // TODO: Let the player choose if they want to use admin commands or not + admins.insert( + crate::login_provider::derive_singleplayer_uuid(), + AdminRecord { + username_when_admined: Some("singleplayer".into()), + date: Utc::now(), + role: admin::Role::Admin, + }, + ); + Self { - server_description: ServerDescription("Who needs friends anyway?".into()), - // TODO: Let the player choose if they want to use admin commands or not - admins: Admins( - std::iter::once(crate::login_provider::derive_singleplayer_uuid()).collect(), - ), + server_description, + admins, ..load } } } - -impl EditableSetting for Whitelist { - const FILENAME: &'static str = WHITELIST_FILENAME; -} - -impl EditableSetting for Banlist { - const FILENAME: &'static str = BANLIST_FILENAME; -} - -impl EditableSetting for ServerDescription { - const FILENAME: &'static str = SERVER_DESCRIPTION_FILENAME; -} - -impl EditableSetting for Admins { - const FILENAME: &'static str = ADMINS_FILENAME; -} - -impl Deref for Whitelist { - type Target = HashSet; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for Whitelist { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} - -impl Deref for Banlist { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for Banlist { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} - -impl Deref for ServerDescription { - type Target = String; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for ServerDescription { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} - -impl Deref for Admins { - type Target = HashSet; - - fn deref(&self) -> &Self::Target { &self.0 } -} - -impl DerefMut for Admins { - fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } -} diff --git a/server/src/settings/admin.rs b/server/src/settings/admin.rs new file mode 100644 index 0000000000..52d8a4a150 --- /dev/null +++ b/server/src/settings/admin.rs @@ -0,0 +1,257 @@ +//! Versioned admins settings files. + +// NOTE: Needed to allow the second-to-last migration to call try_into(). +#![allow(clippy::useless_conversion)] + +use super::{ADMINS_FILENAME as FILENAME, MIGRATION_UPGRADE_GUARANTEE}; +use crate::settings::editable::{EditableSetting, Version}; +use core::convert::{Infallible, TryFrom, TryInto}; +use serde::{Deserialize, Serialize}; + +/// NOTE: Always replace this with the latest admins version. Then update the +/// AdminsRaw, the TryFrom for Admins, the previously most recent +/// module, and add a new module for the latest version! Please respect the +/// migration upgrade guarantee found in the parent module with any upgrade. +pub use self::v1::*; + +/// Versioned settings files, one per version (v0 is only here as an example; we +/// do not expect to see any actual v0 settings files). +#[derive(Deserialize, Serialize)] +pub enum AdminsRaw { + V0(v0::Admins), + V1(v1::Admins), +} + +impl From for AdminsRaw { + fn from(value: Admins) -> Self { + // Replace variant with that of current latest version. + Self::V1(value) + } +} + +impl TryFrom for (Version, Admins) { + type Error = ::Error; + + fn try_from(value: AdminsRaw) -> Result::Error> { + use AdminsRaw::*; + Ok(match value { + // Old versions + V0(value) => (Version::Old, value.try_into()?), + // Latest version (move to old section using the pattern of other old version when it + // is no longer latest). + V1(mut value) => (value.validate()?, value), + }) + } +} + +type Final = Admins; + +impl EditableSetting for Admins { + type Error = Infallible; + type Legacy = legacy::Admins; + type Setting = AdminsRaw; + + const FILENAME: &'static str = FILENAME; +} + +mod legacy { + use super::{v0 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use authc::Uuid; + use core::convert::TryInto; + use hashbrown::HashSet; + use serde::{Deserialize, Serialize}; + + #[derive(Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Admins(pub(super) HashSet); + + impl From for Final { + /// Legacy migrations can be migrated to the latest version through the + /// process of "chaining" migrations, starting from + /// `next::Admins`. + /// + /// Note that legacy files are always valid, which is why we implement + /// From rather than TryFrom. + fn from(value: Admins) -> Self { + next::Admins::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE) + } + } +} + +/// This module represents a admins version that isn't actually used. It is +/// here and part of the migration process to provide an example for how to +/// perform a migration for an old version; please use this as a reference when +/// constructing new migrations. +mod v0 { + use super::{legacy as prev, v1 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use crate::settings::editable::{EditableSetting, Version}; + use authc::Uuid; + use core::convert::{TryFrom, TryInto}; + use hashbrown::HashSet; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Admins(pub(super) HashSet); + + impl Admins { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Admins) -> Self { Admins(prev.0) } + + /// Perform any needed validation on this admins that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + /// Pretty much every TryFrom implementation except that of the very last + /// version should look exactly like this. + impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Admins) -> Result { + value.validate()?; + Ok(next::Admins::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } +} + +mod v1 { + use super::{v0 as prev, Final}; + use crate::settings::editable::{EditableSetting, Version}; + use authc::Uuid; + use chrono::{prelude::*, Utc}; + use common::comp::AdminRole; + use core::ops::{Deref, DerefMut}; + use hashbrown::HashMap; + use serde::{Deserialize, Serialize}; + /* use super::v2 as next; */ + + /// Important: even if the role we are storing here appears to be identical + /// to one used in another versioned store (like banlist::Role), we + /// *must* have our own versioned copy! This ensures that if there's an + /// update to the role somewhere else, the conversion function between + /// them will break, letting people make an intelligent decision. + /// + /// In particular, *never remove variants from this enum* (or any other enum + /// in a versioned settings file) without bumping the version and + /// writing a migration that understands how to properly deal with + /// existing instances of the old variant (you can delete From instances + /// for the old variants at this point). Otherwise, we will lose + /// compatibility with old settings files, since we won't be able to + /// deserialize them! + #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialOrd, PartialEq, Serialize)] + pub enum Role { + Moderator = 0, + Admin = 1, + } + + impl From for Role { + fn from(value: AdminRole) -> Self { + match value { + AdminRole::Moderator => Self::Moderator, + AdminRole::Admin => Self::Admin, + } + } + } + + impl From for AdminRole { + fn from(value: Role) -> Self { + match value { + Role::Moderator => Self::Moderator, + Role::Admin => Self::Admin, + } + } + } + + #[derive(Clone, Deserialize, Serialize)] + /// NOTE: This does not include info structs like other settings, because we + /// (deliberately) provide no interface for creating new mods or admins + /// except through the command line, ensuring that the host of the + /// server has total control over these things and avoiding the creation + /// of code paths to alter the admin list that are accessible during normal + /// gameplay. + pub struct AdminRecord { + /// NOTE: Should only be None for migrations from legacy data. + pub username_when_admined: Option, + /// Date that the user was given this role. + pub date: DateTime, + pub role: Role, + } + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + /// NOTE: Records should only be unavailable for cases where we are + /// migration from a legacy version. + pub struct Admins(pub(super) HashMap); + + impl Deref for Admins { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { &self.0 } + } + + impl DerefMut for Admins { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + } + + impl Admins { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Admins) -> Self { + // The role assignment date for migrations from legacy is the current one; we + // could record that they actually have an unknown start date, but + // this would just complicate the format. + let date = Utc::now(); + Admins( + prev.0 + .into_iter() + .map(|uid| { + (uid, AdminRecord { + date, + // We don't have username information for old admin records. + username_when_admined: None, + // All legacy roles are Admin, because we didn't have any other roles at + // the time. + role: Role::Admin, + }) + }) + .collect(), + ) + } + + /// Perform any needed validation on this admins that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + // NOTE: Whenever there is a version upgrade, copy this note as well as the + // commented-out code below to the next version, then uncomment the code + // for this version. + /* impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Admins) -> Result { + value.validate()?; + Ok(next::Admins::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } */ +} diff --git a/server/src/settings/banlist.rs b/server/src/settings/banlist.rs new file mode 100644 index 0000000000..5fbab03338 --- /dev/null +++ b/server/src/settings/banlist.rs @@ -0,0 +1,662 @@ +//! Versioned banlist settings files. + +// NOTE: Needed to allow the second-to-last migration to call try_into(). +#![allow(clippy::useless_conversion)] + +use super::{BANLIST_FILENAME as FILENAME, MIGRATION_UPGRADE_GUARANTEE}; +use crate::settings::editable::{EditableSetting, Version}; +use authc::Uuid; +use core::convert::{TryFrom, TryInto}; +use serde::{Deserialize, Serialize}; + +/// NOTE: Always replace this with the latest banlist version. Then update the +/// BanlistRaw, the TryFrom for Banlist, the previously most recent +/// module, and add a new module for the latest version! Please respect the +/// migration upgrade guarantee found in the parent module with any upgrade. +pub use self::v1::*; + +/// Versioned settings files, one per version (v0 is only here as an example; we +/// do not expect to see any actual v0 settings files). +#[derive(Deserialize, Serialize)] +pub enum BanlistRaw { + V0(v0::Banlist), + V1(v1::Banlist), +} + +impl From for BanlistRaw { + fn from(value: Banlist) -> Self { + // Replace variant with that of current latest version. + Self::V1(value) + } +} + +impl TryFrom for (Version, Banlist) { + type Error = ::Error; + + fn try_from(value: BanlistRaw) -> Result::Error> { + use BanlistRaw::*; + Ok(match value { + // Old versions + V0(value) => (Version::Old, value.try_into()?), + // Latest version (move to old section using the pattern of other old version when it + // is no longer latest). + V1(mut value) => (value.validate()?, value), + }) + } +} + +type Final = Banlist; + +impl EditableSetting for Banlist { + type Error = BanError; + type Legacy = legacy::Banlist; + type Setting = BanlistRaw; + + const FILENAME: &'static str = FILENAME; +} + +#[derive(Clone, Copy, Debug)] +pub enum BanKind { + Ban, + Unban, +} + +#[derive(Clone, Copy, Debug)] +pub enum BanErrorKind { + /// An end date went past a start date. + InvalidDateRange { + start_date: chrono::DateTime, + end_date: chrono::DateTime, + }, + /// Cannot unban an already-unbanned user. + AlreadyUnbanned, + /// Permission denied to perform requested action. + PermissionDenied(BanKind), +} + +#[derive(Debug)] +pub struct BanError { + kind: BanErrorKind, + /// Uuid of affected user + uuid: Uuid, + /// Username of affected user (as of ban/unban time). + username: String, +} + +mod legacy { + use super::{v0 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use authc::Uuid; + use core::convert::TryInto; + use hashbrown::HashMap; + use serde::{Deserialize, Serialize}; + + #[derive(Deserialize, Serialize)] + pub struct BanRecord { + pub username_when_banned: String, + pub reason: String, + } + + #[derive(Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Banlist(pub(super) HashMap); + + impl From for Final { + /// Legacy migrations can be migrated to the latest version through the + /// process of "chaining" migrations, starting from + /// `next::Banlist`. + /// + /// Note that legacy files are always valid, which is why we implement + /// From rather than TryFrom. + fn from(value: Banlist) -> Self { + next::Banlist::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE) + } + } +} + +/// This module represents a banlist version that isn't actually used. It is +/// here and part of the migration process to provide an example for how to +/// perform a migration for an old version; please use this as a reference when +/// constructing new migrations. +mod v0 { + use super::{legacy as prev, v1 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use crate::settings::editable::{EditableSetting, Version}; + use authc::Uuid; + use core::convert::{TryFrom, TryInto}; + use hashbrown::HashMap; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Deserialize, Serialize)] + pub struct BanRecord { + pub username_when_banned: String, + pub reason: String, + } + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Banlist(pub(super) HashMap); + + impl Banlist { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Banlist) -> Self { + Banlist( + prev.0 + .into_iter() + .map( + |( + uid, + prev::BanRecord { + username_when_banned, + reason, + }, + )| { + (uid, BanRecord { + username_when_banned, + reason, + }) + }, + ) + .collect(), + ) + } + + /// Perform any needed validation on this banlist that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + /// Pretty much every TryFrom implementation except that of the very last + /// version should look exactly like this. + impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Banlist) -> Result { + value.validate()?; + Ok(next::Banlist::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } +} + +mod v1 { + use super::{v0 as prev, BanError, BanErrorKind, BanKind, Final}; + use crate::settings::editable::{EditableSetting, Error, Version}; + use authc::Uuid; + use chrono::{prelude::*, Utc}; + use common::comp::AdminRole; + use core::{mem, ops::Deref}; + use hashbrown::{hash_map, HashMap}; + use serde::{Deserialize, Serialize}; + use tracing::warn; + /* use super::v2 as next; */ + + /// Important: even if the role we are storing here appears to be identical + /// to one used in another versioned store (like admin::Role), we *must* + /// have our own versioned copy! This ensures that if there's an update + /// to the role somewhere else, the conversion function between them + /// will break, letting people make an intelligent decision. + /// + /// In particular, *never remove variants from this enum* (or any other enum + /// in a versioned settings file) without bumping the version and + /// writing a migration that understands how to properly deal with + /// existing instances of the old variant (you can delete From instances + /// for the old variants at this point). Otherwise, we will lose + /// compatibility with old settings files, since we won't be able to + /// deserialize them! + #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] + pub enum Role { + Moderator = 0, + Admin = 1, + } + + impl From for Role { + fn from(value: AdminRole) -> Self { + match value { + AdminRole::Moderator => Self::Moderator, + AdminRole::Admin => Self::Admin, + } + } + } + + impl From for AdminRole { + fn from(value: Role) -> Self { + match value { + Role::Moderator => Self::Moderator, + Role::Admin => Self::Admin, + } + } + } + + #[derive(Clone, Deserialize, Serialize)] + /// NOTE: May not be present if performed from the command line or from a + /// legacy file. + pub struct BanInfo { + pub performed_by: Uuid, + /// NOTE: May not be up to date, if we allow username changes. + pub performed_by_username: String, + /// NOTE: Role of the banning user at the time of the ban. + pub performed_by_role: Role, + } + + #[derive(Clone, Deserialize, Serialize)] + pub struct Ban { + pub reason: String, + /// NOTE: Should only be None for migrations from legacy data. + pub info: Option, + /// NOTE: Should always be higher than start_date, if both are + /// present! + pub end_date: Option>, + } + + impl Ban { + /// Returns true if the ban is expired, false otherwise. + pub fn is_expired(&self, now: DateTime) -> bool { + self.end_date.map_or(false, |end_date| end_date <= now) + } + + pub fn performed_by_role(&self) -> Role { + self.info.as_ref().map(|info| info.performed_by_role) + // We know all legacy bans were performed by an admin, since we had no other roles + // at the time. + .unwrap_or(Role::Admin) + } + } + + type Unban = BanInfo; + + #[derive(Clone, Deserialize, Serialize)] + pub enum BanAction { + Unban(Unban), + Ban(Ban), + } + + impl BanAction { + pub fn ban(&self) -> Option<&Ban> { + match self { + BanAction::Unban(_) => None, + BanAction::Ban(ban) => Some(ban), + } + } + } + + #[derive(Clone, Deserialize, Serialize)] + pub struct BanRecord { + /// Username of the user upon whom the action was performed, when it was + /// performed. + pub username_when_performed: String, + pub action: BanAction, + /// NOTE: When migrating from legacy versions, this will just be the + /// time of the first migration (only applies to BanRecord). + pub date: DateTime, + } + + impl BanRecord { + /// Returns true if this record represents an expired ban, false + /// otherwise. + fn is_expired(&self, now: DateTime) -> bool { + match &self.action { + BanAction::Ban(ban) => ban.is_expired(now), + BanAction::Unban(_) => true, + } + } + + /// The history vector in a BanEntry is stored forwards (from oldest + /// entry to newest), so `prev_record` is the previous entry in + /// this vector when iterating forwards (by array index). + /// + /// Errors are: + /// + /// AlreadyUnbanned if an unban comes after anything but a ban. + /// + /// Permission(Unban) if an unban attempt is by a user with a lower role + /// level than the original banning party. + /// + /// PermissionDenied(Ban) if a ban length is made shorter by a user with + /// a role level than the original banning party. + /// + /// InvalidDateRange if the end date of the ban exceeds the start date. + fn validate(&self, prev_record: Option<&BanRecord>) -> Result<(), BanErrorKind> { + // Check to make sure the actions temporally line up--if they don't, we will + // prevent warn an administrator (since this may indicate a system + // clock issue and could require manual editing to resolve). + // However, we will not actually invalidate the ban list for this, in case + // this would otherwise prevent people from adding a new ban. + // + // We also deliberately leave the bad order intact, in case this reflects + // history more accurately than the system clock does. + if let Some(prev_record) = prev_record { + if prev_record.date > self.date { + warn!( + "Ban list history is inconsistent, or a just-added ban was behind a \ + historical entry in the ban + record; please investigate the contents of the file (might indicate a \ + system clock change?)." + ); + } + } + let ban = match (&self.action, prev_record.map(|record| &record.action)) { + // A ban is always valid if it follows an unban. + (BanAction::Ban(ban), None) | (BanAction::Ban(ban), Some(BanAction::Unban(_))) => { + ban + }, + // A ban record following a ban is valid if either the role of the person doing the + // banning is at least the privilege level of the person who did the ban, or the + // ban's new end time is at least the previous end time. + (BanAction::Ban(new_ban), Some(BanAction::Ban(old_ban))) => { + match (new_ban.end_date, old_ban.end_date) { + // New role ≥ old role + _ if new_ban.performed_by_role() >= old_ban.performed_by_role() => new_ban, + // Permanent ban retracted to temp ban. + (Some(_), None) => { + return Err(BanErrorKind::PermissionDenied(BanKind::Ban)); + }, + // Temp ban retracted to shorter temp ban. + (Some(new_date), Some(old_date)) if new_date < old_date => { + return Err(BanErrorKind::PermissionDenied(BanKind::Ban)); + }, + // Anything else (extension to permanent ban, or temp ban extension to + // longer temp ban). + _ => new_ban, + } + }, + // An unban record is invalid if it does not follow a ban. + (BanAction::Unban(_), None) | (BanAction::Unban(_), Some(BanAction::Unban(_))) => { + return Err(BanErrorKind::AlreadyUnbanned); + }, + // An unban record following a ban is valid if the role of the person doing the + // unbanning is at least the privilege level of the person who did the ban. + (BanAction::Unban(unban), Some(BanAction::Ban(ban))) => { + if unban.performed_by_role >= ban.performed_by_role() { + return Ok(()); + } else { + return Err(BanErrorKind::PermissionDenied(BanKind::Unban)); + } + }, + }; + + // End date of a ban must be at least as big as the start date. + if let Some(end_date) = ban.end_date { + if self.date > end_date { + return Err(BanErrorKind::InvalidDateRange { + start_date: self.date, + end_date, + }); + } + } + Ok(()) + } + } + + #[derive(Clone, Deserialize, Serialize)] + pub struct BanEntry { + /// The latest ban record for this user. + pub current: BanRecord, + /// Historical ban records for this user, stored in order from oldest to + /// newest. + pub history: Vec, + /// A *hint* about whether the system thinks this entry is expired, + /// mostly to make it easier for someone manually going through + /// a file to see whether an entry is currently in effect or + /// not. This is based off the contents of `current`. + pub expired: bool, + } + + impl Deref for BanEntry { + type Target = BanRecord; + + fn deref(&self) -> &Self::Target { &self.current } + } + + impl BanEntry { + /// Both validates, and updates the hint bit if it's inconsistent with + /// reality. + /// + /// If we were invalid, returns an error. Otherwise, returns Ok(v), + /// where v is Latest if the hint bit was modified, Old + /// otherwise. + fn validate( + &mut self, + now: DateTime, + uuid: Uuid, + ) -> Result::Error> { + let make_error = |current_entry: &BanRecord| { + let username = current_entry.username_when_performed.clone(); + move |kind| BanError { + kind, + uuid, + username, + } + }; + // First, go forwards through history (also forwards in terms of the iterator + // direction), validating each entry in turn. + let mut prev_entry = None; + for current_entry in &self.history { + current_entry + .validate(prev_entry) + .map_err(make_error(current_entry))?; + prev_entry = Some(current_entry); + } + + // History has now been validated, so validate the current entry. + self.current + .validate(prev_entry) + .map_err(make_error(&self.current))?; + + // Make sure the expired hint is correct, and if not indicate that we should + // resave the file. + let is_expired = self.current.is_expired(now); + if self.expired != is_expired { + self.expired = is_expired; + Ok(Version::Old) + } else { + Ok(Version::Latest) + } + } + } + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Banlist(pub(super) HashMap); + + impl Deref for Banlist { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { &self.0 } + } + + impl Banlist { + /// Attempt to perform the ban action `action` for the user with UUID + /// `uuid` and username `username`, starting from itme `now` + /// (the information about the banning party will + /// be in the `action` record), with a settings file maintained at path + /// root `data_dir`. + /// + /// If trying to unban an already unbanned player, or trying to ban but + /// the ban status would not immediately change, the "overwrite" + /// boolean should also be set to true. + /// + /// We try to detect duplicates (bans that would have no effect) and + /// return None if such effects are encountered. Otherwise, we + /// return Some(result), which works as follows. + /// + /// If the ban was invalid for any reason, then neither the in-memory + /// banlist nor the on-disk banlist are modified. If the ban + /// entry is valid but the file encounters an error that + /// prevents it from being atomically written to disk, we return an + /// error but retain the change in memory. Otherwise, we + /// complete successfully and atomically write the banlist to + /// disk. + /// + /// Note that the IO operation is only *guaranteed* atomic in the weak + /// sense that either the whole page is written or it isn't; we + /// cannot guarantee that the data we read in order to modify + /// the file was definitely up to date, so we could be missing + /// information if the file was manually edited or a function + /// edits it without going through the usual specs resources. + /// So, please be careful with ad hoc modifications to the file while + /// the server is running. + /// + /// Panics if provided a ban action with info set to None, as info: None + /// should only be used for legacy records. + /// + /// TODO: Consider creating a new type specifically for the entry to + /// avoid needing the precondition on info. + #[must_use] + pub fn ban_action( + &mut self, + data_dir: &std::path::Path, + now: DateTime, + uuid: Uuid, + username_when_performed: String, + action: BanAction, + overwrite: bool, + ) -> Option>> { + assert!( + matches!( + action, + BanAction::Unban(_) | BanAction::Ban(Ban { info: Some(_), .. }) + ), + "The info field is only None for legacy reasons--any new bans should have it set!", + ); + + let ban_record = BanRecord { + username_when_performed, + action, + date: now, + }; + + // Perform an atomic edit. + Some( + self.edit(data_dir.as_ref(), |banlist| { + match banlist.0.entry(uuid) { + hash_map::Entry::Vacant(v) => { + // If this is an unban, it will have no effect, so return early. + if matches!(ban_record.action, BanAction::Unban(_)) { + return None; + } + // Otherwise, this will at least potentially have an effect (assuming it + // succeeds). + v.insert(BanEntry { + current: ban_record, + history: Vec::new(), + // This is a hint anyway, but expired will also be set to true + // before saving by the call `edit` + // makes to `validate` (through `try_into`), which will set it to + // true in the event that the ban + // time was so short that it expired during the interval + // between creating the action and saving it. + // + // TODO: Decide if we even care enough about this case to worry + // about the gap. Probably not, even + // though it does involve time! + expired: false, + }); + Some(()) + }, + hash_map::Entry::Occupied(mut o) => { + let entry = o.get_mut(); + // If overwrite is off, check that this entry (if successful) would + // actually change the ban status. + if !overwrite + && entry.current.is_expired(now) == ban_record.is_expired(now) + { + return None; + } + // Push the current (most recent) entry to the back of the history list. + entry + .history + .push(mem::replace(&mut entry.current, ban_record)); + Some(()) + }, + } + })? + .1, + ) + } + } + + impl Banlist { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Banlist) -> Self { + // The ban start date for migrations from legacy is the current one; we could + // record that they actually have an unknown start date, but this + // would just complicate the format. + let date = Utc::now(); + Banlist( + prev.0 + .into_iter() + .map( + |( + uid, + prev::BanRecord { + username_when_banned, + reason, + }, + )| { + (uid, BanEntry { + current: BanRecord { + username_when_performed: username_when_banned, + // We only recorded unbans pre-migration. + action: BanAction::Ban(Ban { + reason, + // We don't know who banned this user pre-migration. + info: None, + // All bans pre-migration are of unlimited duration. + end_date: None, + }), + date, + }, + // Old bans never expire, so set the expiration hint to false. + expired: false, + // There is no known ban history yet. + history: Vec::new(), + }) + }, + ) + .collect(), + ) + } + + /// Perform any needed validation on this banlist that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + let mut version = Version::Latest; + let now = Utc::now(); + for (&uuid, value) in self.0.iter_mut() { + if matches!(value.validate(now, uuid)?, Version::Old) { + // Update detected. + version = Version::Old; + } + } + Ok(version) + } + } + + // NOTE: Whenever there is a version upgrade, copy this note as well as the + // commented-out code below to the next version, then uncomment the code + // for this version. + /* impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Banlist) -> Result { + value.validate()?; + Ok(next::Banlist::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } */ +} diff --git a/server/src/settings/editable.rs b/server/src/settings/editable.rs index 9b1c451fd1..94b86ac158 100644 --- a/server/src/settings/editable.rs +++ b/server/src/settings/editable.rs @@ -1,26 +1,136 @@ +use atomicwrites::{AtomicFile, Error as AtomicError, OverwriteBehavior}; +use core::{convert::TryInto, fmt}; use serde::{de::DeserializeOwned, Serialize}; use std::{ fs, + io::{Seek, SeekFrom, Write}, path::{Path, PathBuf}, }; -use tracing::{error, warn}; +use tracing::{error, info, warn}; -pub trait EditableSetting: Serialize + DeserializeOwned + Default { +#[derive(Debug)] +/// Errors that can occur during edits to a settings file. +pub enum Error { + /// An error occurred validating the settings file. + Integrity(S::Error), + /// An IO error occurred when writing to the settings file. + Io(std::io::Error), +} + +#[derive(Debug)] +/// Same as Error, but carries the validated settings in the Io case. +enum ErrorInternal { + Integrity(S::Error), + Io(std::io::Error, S), +} + +pub enum Version { + /// This was an old version of the settings file, so overwrite with the + /// modern config. + Old, + /// Latest version of the settings file. + Latest, +} + +pub trait EditableSetting: Clone + Default { const FILENAME: &'static str; + /// Please use this error sparingly, since we ideally want to preserve + /// forwards compatibility for all migrations. In particular, this + /// error should be used to fail validation *of the original settings + /// file* that cannot be caught with ordinary parsing, rather than used + /// to signal errors that occurred during migrations. + /// + /// The best error type is Infallible. + type Error: fmt::Debug; + + /// Into is expected to migrate directly to the latest version, + /// which can be implemented using "chaining". The use of `Into` here + /// rather than TryInto is intended (together with the expected use of + /// chaining) to prevent migrations from invalidating old save files + /// without warning; there should always be a non-failing migration path + /// from the oldest to latest format (if the migration path fails, we can + /// panic). + type Legacy: Serialize + DeserializeOwned + Into; + + /// TryInto<(Version, Self)> is expected to migrate to the latest version + /// from any older version, using "chaining" (see [super::banlist] for + /// examples). + /// + /// From is intended to construct the latest version of the + /// configuratino file from Self, which we use to save the config file + /// on migration or modification. Note that it should always be the + /// case that if x is constructed from any of Self::clone, Self::default, or + /// Setting::try_into, then Setting::try_from(Self::into(x)).is_ok() must be + /// true! + /// + /// The error should be used to fail validation *of the original settings + /// file* that cannot be caught with parsing. If we can possibly avoid + /// it, we should not create errors in valid settings files during + /// migration, to ensure forwards compatibility. + type Setting: Serialize + + DeserializeOwned + + TryInto<(Version, Self), Error = Self::Error> + + From; + fn load(data_dir: &Path) -> Self { let path = Self::get_path(data_dir); - if let Ok(file) = fs::File::open(&path) { - match ron::de::from_reader(file) { - Ok(setting) => setting, - Err(e) => { + if let Ok(mut file) = fs::File::open(&path) { + match ron::de::from_reader(&mut file) + .map(|setting: Self::Setting| setting.try_into()) + .or_else(|orig_err| { + file.seek(SeekFrom::Start(0))?; + ron::de::from_reader(file) + .map(|legacy| Ok((Version::Old, Self::Legacy::into(legacy)))) + // When both legacy and nonlegacy have parse errors, prioritize the + // nonlegacy one, since we can't tell which one is "right" and legacy + // formats are simple, early, and uncommon enough that we expect + // few parse errors in those. + .or(Err(orig_err)) + }) + .map_err(|e| { warn!( ?e, "Failed to parse setting file! Falling back to default and moving \ existing file to a .invalid" ); - + }) + .and_then(|inner| { + inner.map_err(|e| { + warn!( + ?e, + "Failed to parse setting file! Falling back to default and moving \ + existing file to a .invalid" + ); + }) + }) { + Ok((version, mut settings)) => { + if matches!(version, Version::Old) { + // Old version, which means we either performed a migration or there was + // some needed update to the file. If this is the case, we preemptively + // overwrite the settings file (not strictly needed, but useful for + // people who do manual editing). + info!("Settings were changed on load, updating file..."); + // We don't care if we encountered an error on saving updates to a + // settings file that we just loaded (it's already logged and migrated). + // However, we should crash if it reported an integrity failure, since we + // supposedly just validated it. + if let Err(Error::Integrity(err)) = settings + .edit(data_dir, |_| Some(())) + .expect("Some always returns Some") + .1 + { + panic!( + "The identity conversion from a validated settings file must + always be valid, but we found an integrity error: {:?}", + err + ); + } + } + settings + }, + Err(()) => { // Rename existing file to .invalid.ron let mut new_path = path.with_extension("invalid.ron"); @@ -50,13 +160,58 @@ pub trait EditableSetting: Serialize + DeserializeOwned + Default { } } - fn edit(&mut self, data_dir: &Path, f: impl FnOnce(&mut Self) -> R) -> R { + /// If the result of calling f is None,we return None (this constitutes an + /// early return and lets us abandon the in-progress edit). For + /// example, this can be used to avoid adding a new ban entry if someone + /// is already banned and the user didn't explicitly specify that they + /// wanted to add a new ban record, even though it would be completely + /// valid to attach one. + /// + /// Otherwise (the result of calling f was Some(r)), we always return + /// Some((r, res)), where: + /// + /// If res is Ok(()), validation succeeded for the edited, and changes made + /// inside the closure are applied both in memory (to self) and + /// atomically on disk. + /// + /// Otherwise (res is Err(e)), some step in the edit process failed. + /// Specifically: + /// + /// * If e is Integrity, validation failed and the settings were not + /// updated. + /// * If e is Io, validation succeeded and the settings were updated in + /// memory, but they + /// could not be saved to storage (and a warning was logged). The reason we + /// return an error even though the operation was partially successful + /// is so we can alert the player who ran the command about the failure, + /// as they will often be an administrator who can usefully act upon that + /// information. + #[must_use] + fn edit( + &mut self, + data_dir: &Path, + f: impl FnOnce(&mut Self) -> Option, + ) -> Option<(R, Result<(), Error>)> { let path = Self::get_path(data_dir); - let r = f(self); - save_to_file(&*self, &path) - .unwrap_or_else(|err| warn!("Failed to save setting: {:?}", err)); - r + // First, edit a copy. + let mut copy = self.clone(); + let r = f(&mut copy)?; + // Validate integrity of the raw data before saving (by making sure that + // converting to and from the Settings format still produces a valid + // file). + Some((r, match save_to_file(copy, &path) { + Ok(new_settings) => { + *self = new_settings; + Ok(()) + }, + Err(ErrorInternal::Io(err, new_settings)) => { + warn!("Failed to save setting: {:?}", err); + *self = new_settings; + Err(Error::Io(err)) + }, + Err(ErrorInternal::Integrity(err)) => Err(Error::Integrity(err)), + })) } fn get_path(data_dir: &Path) -> PathBuf { @@ -66,24 +221,43 @@ pub trait EditableSetting: Serialize + DeserializeOwned + Default { } } -fn save_to_file(setting: &S, path: &Path) -> std::io::Result<()> { +fn save_to_file(setting: S, path: &Path) -> Result> { + let raw: ::Setting = setting.into(); + let ron = ron::ser::to_string_pretty(&raw, ron::ser::PrettyConfig::default()) + .expect("RON does not throw any parse errors during serialization to string."); + // This has the side effect of validating the copy, meaning it's safe to save + // the file. + let (_, settings): (Version, S) = raw.try_into().map_err(ErrorInternal::Integrity)?; // Create dir if it doesn't exist if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; + if let Err(err) = fs::create_dir_all(dir) { + return Err(ErrorInternal::Io(err, settings)); + } + } + // Atomically write the validated string to the settings file. + let atomic_file = AtomicFile::new(path, OverwriteBehavior::AllowOverwrite); + match atomic_file.write(|file| file.write_all(ron.as_bytes())) { + Ok(()) => Ok(settings), + Err(AtomicError::Internal(err)) | Err(AtomicError::User(err)) => { + Err(ErrorInternal::Io(err, settings)) + }, } - - let ron = ron::ser::to_string_pretty(setting, ron::ser::PrettyConfig::default()) - .expect("Failed serialize setting."); - - fs::write(path, ron.as_bytes())?; - - Ok(()) } fn create_and_save_default(path: &Path) -> S { let default = S::default(); - if let Err(e) = save_to_file(&default, path) { - error!(?e, "Failed to create default setting file!"); + match save_to_file(default, path) { + Ok(settings) => settings, + Err(ErrorInternal::Io(e, settings)) => { + error!(?e, "Failed to create default setting file!"); + settings + }, + Err(ErrorInternal::Integrity(err)) => { + panic!( + "The default settings file must always be valid, but we found an integrity error: \ + {:?}", + err + ); + }, } - default } diff --git a/server/src/settings/server_description.rs b/server/src/settings/server_description.rs new file mode 100644 index 0000000000..bd7f9308af --- /dev/null +++ b/server/src/settings/server_description.rs @@ -0,0 +1,182 @@ +//! Versioned server description settings files. + +// NOTE: Needed to allow the second-to-last migration to call try_into(). +#![allow(clippy::useless_conversion)] + +use super::{MIGRATION_UPGRADE_GUARANTEE, SERVER_DESCRIPTION_FILENAME as FILENAME}; +use crate::settings::editable::{EditableSetting, Version}; +use core::convert::{Infallible, TryFrom, TryInto}; +use serde::{Deserialize, Serialize}; + +/// NOTE: Always replace this with the latest server description version. Then +/// update the ServerDescriptionRaw, the TryFrom for +/// ServerDescription, the previously most recent module, and add a new module +/// for the latest version! Please respect the migration upgrade guarantee +/// found in the parent module with any upgrade. +pub use self::v1::*; + +/// Versioned settings files, one per version (v0 is only here as an example; we +/// do not expect to see any actual v0 settings files). +#[derive(Deserialize, Serialize)] +pub enum ServerDescriptionRaw { + V0(v0::ServerDescription), + V1(v1::ServerDescription), +} + +impl From for ServerDescriptionRaw { + fn from(value: ServerDescription) -> Self { + // Replace variant with that of current latest version. + Self::V1(value) + } +} + +impl TryFrom for (Version, ServerDescription) { + type Error = ::Error; + + fn try_from( + value: ServerDescriptionRaw, + ) -> Result::Error> { + use ServerDescriptionRaw::*; + Ok(match value { + // Old versions + V0(value) => (Version::Old, value.try_into()?), + // Latest version (move to old section using the pattern of other old version when it + // is no longer latest). + V1(mut value) => (value.validate()?, value), + }) + } +} + +type Final = ServerDescription; + +impl EditableSetting for ServerDescription { + type Error = Infallible; + type Legacy = legacy::ServerDescription; + type Setting = ServerDescriptionRaw; + + const FILENAME: &'static str = FILENAME; +} + +mod legacy { + use super::{v0 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use core::convert::TryInto; + use serde::{Deserialize, Serialize}; + + #[derive(Deserialize, Serialize)] + #[serde(transparent)] + pub struct ServerDescription(pub(super) String); + + impl From for Final { + /// Legacy migrations can be migrated to the latest version through the + /// process of "chaining" migrations, starting from + /// `next::ServerDescription`. + /// + /// Note that legacy files are always valid, which is why we implement + /// From rather than TryFrom. + fn from(value: ServerDescription) -> Self { + next::ServerDescription::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE) + } + } +} + +/// This module represents a server description version that isn't actually +/// used. It is here and part of the migration process to provide an example +/// for how to perform a migration for an old version; please use this as a +/// reference when constructing new migrations. +mod v0 { + use super::{legacy as prev, v1 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use crate::settings::editable::{EditableSetting, Version}; + use core::convert::{TryFrom, TryInto}; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Deserialize, Serialize)] + #[serde(transparent)] + pub struct ServerDescription(pub(super) String); + + impl ServerDescription { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::ServerDescription) -> Self { ServerDescription(prev.0) } + + /// Perform any needed validation on this server description that can't + /// be done using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + /// Pretty much every TryFrom implementation except that of the very last + /// version should look exactly like this. + impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: ServerDescription) -> Result { + value.validate()?; + Ok(next::ServerDescription::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } +} + +mod v1 { + use super::{v0 as prev, Final}; + use crate::settings::editable::{EditableSetting, Version}; + use core::ops::{Deref, DerefMut}; + use serde::{Deserialize, Serialize}; + /* use super::v2 as next; */ + + #[derive(Clone, Deserialize, Serialize)] + #[serde(transparent)] + pub struct ServerDescription(pub(super) String); + + impl Default for ServerDescription { + fn default() -> Self { Self("This is the best Veloren server".into()) } + } + + impl Deref for ServerDescription { + type Target = String; + + fn deref(&self) -> &Self::Target { &self.0 } + } + + impl DerefMut for ServerDescription { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + } + + impl ServerDescription { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::ServerDescription) -> Self { ServerDescription(prev.0) } + + /// Perform any needed validation on this server description that can't + /// be done using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + // NOTE: Whenever there is a version upgrade, copy this note as well as the + // commented-out code below to the next version, then uncomment the code + // for this version. + /* impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: ServerDescription) -> Result { + value.validate()?; + Ok(next::ServerDescription::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } */ +} diff --git a/server/src/settings/whitelist.rs b/server/src/settings/whitelist.rs new file mode 100644 index 0000000000..377f76c7b5 --- /dev/null +++ b/server/src/settings/whitelist.rs @@ -0,0 +1,269 @@ +//! Versioned whitelist settings files. + +// NOTE: Needed to allow the second-to-last migration to call try_into(). +#![allow(clippy::useless_conversion)] + +use super::{MIGRATION_UPGRADE_GUARANTEE, WHITELIST_FILENAME as FILENAME}; +use crate::settings::editable::{EditableSetting, Version}; +use core::convert::{Infallible, TryFrom, TryInto}; +use serde::{Deserialize, Serialize}; + +/// NOTE: Always replace this with the latest whitelist version. Then update the +/// WhitelistRaw, the TryFrom for Whitelist, the previously most +/// recent module, and add a new module for the latest version! Please respect +/// the migration upgrade guarantee found in the parent module with any upgrade. +pub use self::v1::*; + +/// Versioned settings files, one per version (v0 is only here as an example; we +/// do not expect to see any actual v0 settings files). +#[derive(Deserialize, Serialize)] +pub enum WhitelistRaw { + V0(v0::Whitelist), + V1(v1::Whitelist), +} + +impl From for WhitelistRaw { + fn from(value: Whitelist) -> Self { + // Replace variant with that of current latest version. + Self::V1(value) + } +} + +impl TryFrom for (Version, Whitelist) { + type Error = ::Error; + + fn try_from(value: WhitelistRaw) -> Result::Error> { + use WhitelistRaw::*; + Ok(match value { + // Old versions + V0(value) => (Version::Old, value.try_into()?), + // Latest version (move to old section using the pattern of other old version when it + // is no longer latest). + V1(mut value) => (value.validate()?, value), + }) + } +} + +type Final = Whitelist; + +impl EditableSetting for Whitelist { + type Error = Infallible; + type Legacy = legacy::Whitelist; + type Setting = WhitelistRaw; + + const FILENAME: &'static str = FILENAME; +} + +mod legacy { + use super::{v0 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use authc::Uuid; + use core::convert::TryInto; + use hashbrown::HashSet; + use serde::{Deserialize, Serialize}; + + #[derive(Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Whitelist(pub(super) HashSet); + + impl From for Final { + /// Legacy migrations can be migrated to the latest version through the + /// process of "chaining" migrations, starting from + /// `next::Whitelist`. + /// + /// Note that legacy files are always valid, which is why we implement + /// From rather than TryFrom. + fn from(value: Whitelist) -> Self { + next::Whitelist::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE) + } + } +} + +/// This module represents a whitelist version that isn't actually used. It is +/// here and part of the migration process to provide an example for how to +/// perform a migration for an old version; please use this as a reference when +/// constructing new migrations. +mod v0 { + use super::{legacy as prev, v1 as next, Final, MIGRATION_UPGRADE_GUARANTEE}; + use crate::settings::editable::{EditableSetting, Version}; + use authc::Uuid; + use core::convert::{TryFrom, TryInto}; + use hashbrown::HashSet; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Whitelist(pub(super) HashSet); + + impl Whitelist { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Whitelist) -> Self { Whitelist(prev.0) } + + /// Perform any needed validation on this whitelist that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + /// Pretty much every TryFrom implementation except that of the very last + /// version should look exactly like this. + impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Whitelist) -> Result { + value.validate()?; + Ok(next::Whitelist::migrate(value) + .try_into() + .expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } +} + +mod v1 { + use super::{v0 as prev, Final}; + use crate::settings::editable::{EditableSetting, Version}; + use authc::Uuid; + use chrono::{prelude::*, Utc}; + use common::comp::AdminRole; + use core::ops::{Deref, DerefMut}; + use hashbrown::HashMap; + use serde::{Deserialize, Serialize}; + /* use super::v2 as next; */ + + /// Important: even if the role we are storing here appears to be identical + /// to one used in another versioned store (like admin::Role), we *must* + /// have our own versioned copy! This ensures that if there's an update + /// to the role somewhere else, the conversion function between them + /// will break, letting people make an intelligent decision. + /// + /// In particular, *never remove variants from this enum* (or any other enum + /// in a versioned settings file) without bumping the version and + /// writing a migration that understands how to properly deal with + /// existing instances of the old variant (you can delete From instances + /// for the old variants at this point). Otherwise, we will lose + /// compatibility with old settings files, since we won't be able to + /// deserialize them! + #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] + pub enum Role { + Moderator = 0, + Admin = 1, + } + + impl From for Role { + fn from(value: AdminRole) -> Self { + match value { + AdminRole::Moderator => Self::Moderator, + AdminRole::Admin => Self::Admin, + } + } + } + + impl From for AdminRole { + fn from(value: Role) -> Self { + match value { + Role::Moderator => Self::Moderator, + Role::Admin => Self::Admin, + } + } + } + + #[derive(Clone, Deserialize, Serialize)] + /// NOTE: May not be present if performed from the command line or from a + /// legacy file. + pub struct WhitelistInfo { + pub username_when_whitelisted: String, + pub whitelisted_by: Uuid, + /// NOTE: May not be up to date, if we allow username changes. + pub whitelisted_by_username: String, + /// NOTE: Role of the whitelisting user at the time of the ban. + pub whitelisted_by_role: Role, + } + + #[derive(Clone, Deserialize, Serialize)] + pub struct WhitelistRecord { + /// Date when the user was added to the whitelist. + pub date: DateTime, + /// NOTE: Should only be None for migrations from legacy data. + pub info: Option, + } + + impl WhitelistRecord { + pub fn whitelisted_by_role(&self) -> Role { + self.info.as_ref().map(|info| info.whitelisted_by_role) + // We know all legacy bans were performed by an admin, since we had no other roles + // at the time. + .unwrap_or(Role::Admin) + } + } + + #[derive(Clone, Deserialize, Serialize, Default)] + #[serde(transparent)] + pub struct Whitelist(pub(super) HashMap); + + impl Deref for Whitelist { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { &self.0 } + } + + impl DerefMut for Whitelist { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } + } + + impl Whitelist { + /// One-off migration from the previous version. This must be + /// guaranteed to produce a valid settings file as long as it is + /// called with a valid settings file from the previous version. + pub(super) fn migrate(prev: prev::Whitelist) -> Self { + // The whitelist start date for migrations from legacy is the current one; we + // could record that they actually have an unknown start date, but + // this would just complicate the format. + let date = Utc::now(); + // We don't have any of the information we need for the whitelist for legacy + // records. + Whitelist( + prev.0 + .into_iter() + .map(|uid| { + (uid, WhitelistRecord { + date, + // We have none of the information needed for WhitelistInfo for old + // whitelist records. + info: None, + }) + }) + .collect(), + ) + } + + /// Perform any needed validation on this whitelist that can't be done + /// using parsing. + /// + /// The returned version being "Old" indicates the loaded setting has + /// been modified during validation (this is why validate takes + /// `&mut self`). + pub(super) fn validate(&mut self) -> Result::Error> { + Ok(Version::Latest) + } + } + + // NOTE: Whenever there is a version upgrade, copy this note as well as the + // commented-out code below to the next version, then uncomment the code + // for this version. + /* impl TryFrom for Final { + type Error = ::Error; + + fn try_from(mut value: Whitelist) -> Result { + value.validate()?; + Ok(next::Whitelist::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE)) + } + } */ +} diff --git a/server/src/sys/msg/register.rs b/server/src/sys/msg/register.rs index 2e3d3319a5..99dda57b95 100644 --- a/server/src/sys/msg/register.rs +++ b/server/src/sys/msg/register.rs @@ -16,7 +16,9 @@ use common_net::msg::{ }; use hashbrown::HashMap; use plugin_api::Health; -use specs::{Entities, Join, Read, ReadExpect, ReadStorage, WriteExpect, WriteStorage}; +use specs::{ + storage::StorageEntry, Entities, Join, Read, ReadExpect, ReadStorage, WriteExpect, WriteStorage, +}; use tracing::trace; #[cfg(feature = "plugins")] @@ -81,7 +83,7 @@ impl<'a> System<'a> for Sys { .map(|(uid, player, stats, admin)| { (*uid, PlayerInfo { is_online: true, - is_admin: admin.is_some(), + is_moderator: admin.is_some(), player_alias: player.alias.clone(), character: stats.map(|stats| CharacterInfo { name: stats.name.clone(), @@ -131,6 +133,10 @@ impl<'a> System<'a> for Sys { trace!(?r, "pending login returned"); match r { Err(e) => { + server_event_bus.emit_now(ServerEvent::ClientDisconnect( + entity, + common::comp::DisconnectReason::Kicked, + )); client.send(ServerRegisterAnswer::Err(e))?; return Ok(()); }, @@ -164,7 +170,7 @@ impl<'a> System<'a> for Sys { } let player = Player::new(username, uuid); - let is_admin = editable_settings.admins.contains(&uuid); + let admin = editable_settings.admins.get(&uuid); if !player.is_valid() { // Invalid player @@ -172,15 +178,17 @@ impl<'a> System<'a> for Sys { return Ok(()); } - if !players.contains(entity) { - // Add Player component to this client - let _ = players.insert(entity, player); + if let Ok(StorageEntry::Vacant(v)) = players.entry(entity) { + // Add Player component to this client, if the entity exists. + v.insert(player); player_metrics.players_connected.inc(); // Give the Admin component to the player if their name exists in // admin list - if is_admin { - let _ = admins.insert(entity, Admin); + if let Some(admin) = admin { + admins + .insert(entity, Admin(admin.role.into())) + .expect("Inserting into players proves the entity exists."); } // Tell the client its request was successful. @@ -218,7 +226,7 @@ impl<'a> System<'a> for Sys { PlayerListUpdate::Add(*uid, PlayerInfo { player_alias: player.alias.clone(), is_online: true, - is_admin: admins.get(entity).is_some(), + is_moderator: admins.get(entity).is_some(), character: None, // new players will be on character select. }), ))); diff --git a/voxygen/Cargo.toml b/voxygen/Cargo.toml index 47e5be9c1a..dfd6c62396 100644 --- a/voxygen/Cargo.toml +++ b/voxygen/Cargo.toml @@ -78,7 +78,7 @@ server = {package = "veloren-server", path = "../server", optional = true} # Utility backtrace = "0.3.40" bincode = "1.3.1" -chrono = "0.4.9" +chrono = { version = "0.4.9", features = ["serde"] } cpal = "0.13" copy_dir = "0.1.2" crossbeam = "0.8.0" diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index 85e3287966..bbc2a76e05 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -699,7 +699,7 @@ impl PlayState for SessionState { // The server should do its own filtering of which entities are sent // to clients to prevent abuse. let camera = self.scene.camera_mut(); - camera.next_mode(self.client.borrow().is_admin()); + camera.next_mode(self.client.borrow().is_moderator()); }, GameInput::Select => { if !state {