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 {