Added non-admin moderators and timed bans.

The security model has been updated to reflect this change (for example,
moderators cannot revert a ban by an administrator).  Ban history is
also now recorded in the ban file, and much more information about the
ban is stored (whitelists and administrators also have extra
information).

To support the new information without losing important information,
this commit also introduces a new migration path for editable settings
(both from legacy to the new format, and between versions).  Examples
of how to do this correctly, and migrate to new versions of a settings
file, are in the settings/ subdirectory.

As part of this effort, editable settings have been revamped to
guarantee atomic saves (due to the increased amount of information in
each file), some latent bugs in networking were fixed, and server-cli
has been updated to go through StructOpt for both calls through TUI
and argv, greatly simplifying parsing logic.
This commit is contained in:
Joshua Yanovski 2021-05-08 11:22:21 -07:00
parent c1fc375543
commit e7587c4d9d
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(
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,
sub_m,
&server_settings,
);
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,
);
return Ok(());
},
_ => {},
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

@ -31,6 +31,9 @@ vek = { version = "0.14.1", features = ["serde"] }
futures-util = "0.3.7"
tokio = { version = "1", default-features = false, features = ["rt"] }
prometheus-hyper = "0.1.2"
atomicwrites = "0.3.0"
chrono = { version = "0.4.9", features = ["serde"] }
humantime = "2.1.0"
itertools = "0.10"
lazy_static = "1.4.0"
scan_fmt = "0.2.6"
@ -38,7 +41,6 @@ ron = { version = "0.6", default-features = false }
serde = { version = "1.0.110", features = ["derive"] }
serde_json = "1.0.50"
rand = { version = "0.8", features = ["small_rng"] }
chrono = "0.4.9"
hashbrown = { version = "0.9", features = ["rayon", "serde", "nightly"] }
rayon = "1.5"
crossbeam-channel = "0.5"

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) => {
let edit =
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),
),
);
.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(_) => {
let edit =
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(),
),
);
.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)?;
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| w.insert(uuid));
server.notify_client(
client,
ServerGeneral::server_msg(
ChatType::CommandInfo,
format!("\"{}\" added to whitelist", username),
),
);
Ok(())
.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)?;
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));
server.notify_client(
client,
ServerGeneral::server_msg(
ChatType::CommandInfo,
format!("\"{}\" removed from whitelist", username),
),
);
Ok(())
.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
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
.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,
.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,
)
});
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, uuid) {
let _ = kick_player(server, (target_player, uuid), &reason);
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);
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)
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,27 +102,40 @@ 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) {
// 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);
Self {
server_description: ServerDescription("Who needs friends anyway?".into()),
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: Admins(
std::iter::once(crate::login_provider::derive_singleplayer_uuid()).collect(),
),
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,
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) {
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 {