Merge branch 'sharp/modtools' into 'master'

Added non-admin moderators and timed bans.

See merge request veloren/veloren!2276
This commit is contained in:
Joshua Yanovski 2021-05-10 04:54:06 +00:00
commit 0cf0f59fa7
34 changed files with 2648 additions and 756 deletions

View File

@ -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

22
Cargo.lock generated
View File

@ -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",

View File

@ -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::<Uid>(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 {

View File

@ -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"

View File

@ -64,6 +64,7 @@ where
.parse()
.unwrap(),
)
.add_directive("veloren_server::settings=info".parse().unwrap())
.add_directive(LevelFilter::INFO.into())
};

View File

@ -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<CharacterInfo>,

View File

@ -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<Role>,
}
impl ChatCommandData {
pub fn new(
args: Vec<ArgumentSpec>,
description: &'static str,
needs_admin: IsAdminOnly,
needs_role: Option<Role>,
) -> Self {
Self {
args,
description,
needs_admin,
needs_role,
}
}
}
@ -300,6 +300,8 @@ lazy_static! {
.cloned()
.collect();
static ref ROLES: Vec<String> = ["admin", "moderator"].iter().copied().map(Into::into).collect();
/// List of item specifiers. Useful for tab completing
static ref ITEM_SPECS: Vec<String> = {
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<Role> { 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,

View File

@ -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<Self>;
type Storage = IdvStorage<Self>;
}

View File

@ -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},

View File

@ -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 {

View File

@ -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")]

View File

@ -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)

View File

@ -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"

View File

@ -1,34 +0,0 @@
use std::sync::Arc;
use tokio::runtime::Runtime;
pub fn admin_subcommand(
runtime: Arc<Runtime>,
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"
),
}
}

131
server-cli/src/cli.rs Normal file
View File

@ -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 <https://gitlab.com/veloren/veloren>",
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 <https://gitlab.com/veloren/veloren>",
)]
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<ArgvCommand>,
}
pub fn parse_command(input: &str, msg_s: &mut Sender<Message>) {
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),
}
}

View File

@ -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<String>, &mut Sender<Message>),
}
// 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::<u64>() {
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::<u32>() {
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 <username>\'",
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: 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<Message>) {
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::<Vec<_>>();
let (arg_len, args) = if cmd.split_spaces {
(
args.len(),
args.into_iter()
.map(|s| s.to_string())
.collect::<Vec<String>>(),
)
} else {
(0, vec![args.into_iter().collect::<String>()])
};
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);
}
}
}

View File

@ -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 <https://gitlab.com/veloren/veloren>")
.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();

View File

@ -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");
}

View File

@ -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);
},
}
}

View File

@ -33,6 +33,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"
@ -40,7 +43,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"

View File

@ -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<T> = Result<T, String>;
/// 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<C: specs::Component>(
.map_err(|_| format!("Entity {:?} is dead!", descriptor))
}
fn uuid(server: &Server, entity: EcsEntity, descriptor: &str) -> CmdResult<Uuid> {
server
.state
.ecs()
.read_storage::<comp::Player>()
.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<comp::AdminRole> {
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<Uid> {
@ -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<Uuid> {
.state
.mut_resource::<LoginProvider>()
.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<String> {
let make_err = || format!("Unable to determine username for UUID {:?}", uuid);
let player_storage = server.state.ecs().read_storage::<comp::Player>();
let fallback_alias = &player_storage
.get(fallback_entity)
.ok_or_else(make_err)?
.alias;
server
.state
.ecs()
.read_resource::<LoginProvider>()
.uuid_to_username(uuid, fallback_alias)
.map_err(|_| make_err())
}
fn edit_setting_feedback<S: EditableSetting>(
server: &mut Server,
client: EcsEntity,
result: Option<(String, Result<(), SettingError<S>>)>,
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::<comp::Admin>(player)
.is_some()
{
server
.state
.ecs()
.write_storage::<comp::Admin>()
.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::<comp::Admin>();
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())
}

View File

@ -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::<comp::Stats>().get(possesse).map(
|s| common_net::msg::CharacterInfo {
name: s.name.clone(),

View File

@ -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<comp::AdminRole> {
self.state
.read_storage::<comp::Admin>()
.get(entity)
.is_some()
.read_component_copied::<comp::Admin>(entity)
.map(|admin| admin.0)
}
pub fn number_of_players(&self) -> i64 {
self.state.ecs().read_storage::<Client>().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::<LoginProvider>();
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::<LoginProvider>();
@ -1162,29 +1169,77 @@ impl Drop for Server {
}
}
#[must_use]
pub fn handle_edit<T, S: settings::EditableSetting>(
data: T,
result: Option<(String, Result<(), settings::SettingError<S>>)>,
) -> Option<T> {
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<common::uuid::Uuid> {
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<common::uuid::Uuid> {
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
},

View File

@ -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<Uuid>,
whitelist: &HashSet<Uuid>,
banlist: &HashMap<Uuid, BanRecord>,
admins: &HashMap<Uuid, AdminRecord>,
whitelist: &HashMap<Uuid, WhitelistRecord>,
banlist: &HashMap<Uuid, BanEntry>,
) -> Option<Result<(String, Uuid), RegisterError>> {
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::<AdminRole>::into(admin.role)
>= Into::<AdminRole>::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<String, AuthClientError> {
match &self.auth_server {
Some(srv) => {
//TODO: optimize
self.runtime.block_on(srv.uuid_to_username(uuid))
},
None => Ok(fallback_alias.into()),
}
}
}

View File

@ -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<Self, Self::Err> {
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);

View File

@ -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<Uuid>);
#[derive(Deserialize, Serialize, Default)]
#[serde(transparent)]
pub struct Banlist(HashMap<Uuid, BanRecord>);
#[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<Uuid>);
/// 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<Uuid>;
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<Uuid, BanRecord>;
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<Uuid>;
fn deref(&self) -> &Self::Target { &self.0 }
}
impl DerefMut for Admins {
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
}

View File

@ -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<AdminsRaw> 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<Admins> for AdminsRaw {
fn from(value: Admins) -> Self {
// Replace variant with that of current latest version.
Self::V1(value)
}
}
impl TryFrom<AdminsRaw> for (Version, Admins) {
type Error = <Admins as EditableSetting>::Error;
fn try_from(value: AdminsRaw) -> Result<Self, <Admins as EditableSetting>::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<Uuid>);
impl From<Admins> 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<Uuid>);
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<Version, <Final as EditableSetting>::Error> {
Ok(Version::Latest)
}
}
/// Pretty much every TryFrom implementation except that of the very last
/// version should look exactly like this.
impl TryFrom<Admins> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Admins) -> Result<Final, Self::Error> {
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<AdminRole> for Role {
fn from(value: AdminRole) -> Self {
match value {
AdminRole::Moderator => Self::Moderator,
AdminRole::Admin => Self::Admin,
}
}
}
impl From<Role> 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<String>,
/// Date that the user was given this role.
pub date: DateTime<Utc>,
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<Uuid, AdminRecord>);
impl Deref for Admins {
type Target = HashMap<Uuid, AdminRecord>;
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<Version, <Final as EditableSetting>::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<Admins> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Admins) -> Result<Final, Self::Error> {
value.validate()?;
Ok(next::Admins::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE))
}
} */
}

View File

@ -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<BanlistRaw> 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<Banlist> for BanlistRaw {
fn from(value: Banlist) -> Self {
// Replace variant with that of current latest version.
Self::V1(value)
}
}
impl TryFrom<BanlistRaw> for (Version, Banlist) {
type Error = <Banlist as EditableSetting>::Error;
fn try_from(value: BanlistRaw) -> Result<Self, <Banlist as EditableSetting>::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<chrono::Utc>,
end_date: chrono::DateTime<chrono::Utc>,
},
/// 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<Uuid, BanRecord>);
impl From<Banlist> 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<Uuid, BanRecord>);
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<Version, <Final as EditableSetting>::Error> {
Ok(Version::Latest)
}
}
/// Pretty much every TryFrom implementation except that of the very last
/// version should look exactly like this.
impl TryFrom<Banlist> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Banlist) -> Result<Final, Self::Error> {
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<AdminRole> for Role {
fn from(value: AdminRole) -> Self {
match value {
AdminRole::Moderator => Self::Moderator,
AdminRole::Admin => Self::Admin,
}
}
}
impl From<Role> 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<BanInfo>,
/// NOTE: Should always be higher than start_date, if both are
/// present!
pub end_date: Option<DateTime<Utc>>,
}
impl Ban {
/// Returns true if the ban is expired, false otherwise.
pub fn is_expired(&self, now: DateTime<Utc>) -> 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<Utc>,
}
impl BanRecord {
/// Returns true if this record represents an expired ban, false
/// otherwise.
fn is_expired(&self, now: DateTime<Utc>) -> 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<BanRecord>,
/// 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<Utc>,
uuid: Uuid,
) -> Result<Version, <Final as EditableSetting>::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<Uuid, BanEntry>);
impl Deref for Banlist {
type Target = HashMap<Uuid, BanEntry>;
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<Utc>,
uuid: Uuid,
username_when_performed: String,
action: BanAction,
overwrite: bool,
) -> Option<Result<(), Error<Final>>> {
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<Version, <Final as EditableSetting>::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<Banlist> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Banlist) -> Result<Final, Self::Error> {
value.validate()?;
Ok(next::Banlist::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE))
}
} */
}

View File

@ -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<S: EditableSetting> {
/// 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<S: EditableSetting> {
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<Setting> 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<Self>;
/// TryInto<(Version, Self)> is expected to migrate to the latest version
/// from any older version, using "chaining" (see [super::banlist] for
/// examples).
///
/// From<Self> 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<Self>;
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<R>(&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<R>(
&mut self,
data_dir: &Path,
f: impl FnOnce(&mut Self) -> Option<R>,
) -> Option<(R, Result<(), Error<Self>>)> {
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<S: Serialize>(setting: &S, path: &Path) -> std::io::Result<()> {
fn save_to_file<S: EditableSetting>(setting: S, path: &Path) -> Result<S, ErrorInternal<S>> {
let raw: <S as EditableSetting>::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<S: EditableSetting>(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
}

View File

@ -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<ServerDescriptionRaw> 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<ServerDescription> for ServerDescriptionRaw {
fn from(value: ServerDescription) -> Self {
// Replace variant with that of current latest version.
Self::V1(value)
}
}
impl TryFrom<ServerDescriptionRaw> for (Version, ServerDescription) {
type Error = <ServerDescription as EditableSetting>::Error;
fn try_from(
value: ServerDescriptionRaw,
) -> Result<Self, <ServerDescription as EditableSetting>::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<ServerDescription> 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<Version, <Final as EditableSetting>::Error> {
Ok(Version::Latest)
}
}
/// Pretty much every TryFrom implementation except that of the very last
/// version should look exactly like this.
impl TryFrom<ServerDescription> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: ServerDescription) -> Result<Final, Self::Error> {
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<Version, <Final as EditableSetting>::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<ServerDescription> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: ServerDescription) -> Result<Final, Self::Error> {
value.validate()?;
Ok(next::ServerDescription::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE))
}
} */
}

View File

@ -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<WhitelistRaw> 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<Whitelist> for WhitelistRaw {
fn from(value: Whitelist) -> Self {
// Replace variant with that of current latest version.
Self::V1(value)
}
}
impl TryFrom<WhitelistRaw> for (Version, Whitelist) {
type Error = <Whitelist as EditableSetting>::Error;
fn try_from(value: WhitelistRaw) -> Result<Self, <Whitelist as EditableSetting>::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<Uuid>);
impl From<Whitelist> 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<Uuid>);
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<Version, <Final as EditableSetting>::Error> {
Ok(Version::Latest)
}
}
/// Pretty much every TryFrom implementation except that of the very last
/// version should look exactly like this.
impl TryFrom<Whitelist> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Whitelist) -> Result<Final, Self::Error> {
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<AdminRole> for Role {
fn from(value: AdminRole) -> Self {
match value {
AdminRole::Moderator => Self::Moderator,
AdminRole::Admin => Self::Admin,
}
}
}
impl From<Role> 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<Utc>,
/// NOTE: Should only be None for migrations from legacy data.
pub info: Option<WhitelistInfo>,
}
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<Uuid, WhitelistRecord>);
impl Deref for Whitelist {
type Target = HashMap<Uuid, WhitelistRecord>;
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<Version, <Final as EditableSetting>::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<Whitelist> for Final {
type Error = <Final as EditableSetting>::Error;
fn try_from(mut value: Whitelist) -> Result<Final, Self::Error> {
value.validate()?;
Ok(next::Whitelist::migrate(value).try_into().expect(MIGRATION_UPGRADE_GUARANTEE))
}
} */
}

View File

@ -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.
}),
)));

View File

@ -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"

View File

@ -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 {