mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
Merge branch 'sharp/modtools' into 'master'
Added non-admin moderators and timed bans. See merge request veloren/veloren!2276
This commit is contained in:
commit
0cf0f59fa7
@ -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
22
Cargo.lock
generated
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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"
|
||||
|
@ -64,6 +64,7 @@ where
|
||||
.parse()
|
||||
.unwrap(),
|
||||
)
|
||||
.add_directive("veloren_server::settings=info".parse().unwrap())
|
||||
.add_directive(LevelFilter::INFO.into())
|
||||
};
|
||||
|
||||
|
@ -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>,
|
||||
|
@ -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,
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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},
|
||||
|
@ -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 {
|
||||
|
@ -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")]
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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
131
server-cli/src/cli.rs
Normal 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),
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,30 +2,29 @@
|
||||
#![deny(clippy::clone_on_ref_ptr)]
|
||||
#![feature(bool_to_option)]
|
||||
|
||||
mod admin;
|
||||
/// `server-cli` interface commands not to be confused with the commands sent
|
||||
/// from the client to the server
|
||||
mod cmd;
|
||||
mod cli;
|
||||
mod settings;
|
||||
mod shutdown_coordinator;
|
||||
mod tui_runner;
|
||||
mod tuilog;
|
||||
use crate::{
|
||||
cmd::Message, shutdown_coordinator::ShutdownCoordinator, tui_runner::Tui, tuilog::TuiLog,
|
||||
cli::{Admin, ArgvApp, ArgvCommand, Message, SharedCommand, Shutdown},
|
||||
shutdown_coordinator::ShutdownCoordinator,
|
||||
tui_runner::Tui,
|
||||
tuilog::TuiLog,
|
||||
};
|
||||
use clap::{App, Arg, SubCommand};
|
||||
use common::{clock::Clock, consts::MIN_RECOMMENDED_TOKIO_THREADS};
|
||||
use common_base::span;
|
||||
use core::sync::atomic::{AtomicUsize, Ordering};
|
||||
use server::{
|
||||
persistence::{DatabaseSettings, SqlLogMode},
|
||||
Event, Input, Server,
|
||||
};
|
||||
use server::{persistence::DatabaseSettings, Event, Input, Server};
|
||||
use std::{
|
||||
io,
|
||||
sync::{atomic::AtomicBool, mpsc, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use tracing::{info, trace};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@ -35,62 +34,12 @@ const TPS: u64 = 30;
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn main() -> io::Result<()> {
|
||||
let matches = App::new("Veloren server cli")
|
||||
.version(common::util::DISPLAY_VERSION_LONG.as_str())
|
||||
.author("The veloren devs <https://gitlab.com/veloren/veloren>")
|
||||
.about("The veloren server cli provides an easy to use interface to start a veloren server")
|
||||
.args(&[
|
||||
Arg::with_name("basic")
|
||||
.short("b")
|
||||
.long("basic")
|
||||
.help("Disables the tui"),
|
||||
Arg::with_name("non-interactive")
|
||||
.short("n")
|
||||
.long("non-interactive")
|
||||
.help("doesn't listen on STDIN. Useful if you want to send the server in background, and your kernels terminal driver will send SIGTTIN to it otherwise. (https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Redirections). and you dont want to use `stty -tostop` or `nohub` or `tmux` or `screen` or `<<< \"\\004\"` to the programm. This implies `-b`"),
|
||||
Arg::with_name("no-auth")
|
||||
.long("no-auth")
|
||||
.help("Runs without auth enabled"),
|
||||
Arg::with_name("sql-log-mode")
|
||||
.long("sql-log-mode")
|
||||
.help("Enables SQL logging, valid values are \"trace\" and \"profile\"")
|
||||
.possible_values(&["trace", "profile"])
|
||||
.takes_value(true)
|
||||
])
|
||||
.subcommand(
|
||||
SubCommand::with_name("admin")
|
||||
.about("Add or remove admins")
|
||||
.subcommands(vec![
|
||||
SubCommand::with_name("add").about("Adds an admin").arg(
|
||||
Arg::with_name("username")
|
||||
.help("Name of the admin to add")
|
||||
.required(true),
|
||||
),
|
||||
SubCommand::with_name("remove")
|
||||
.about("Removes an admin")
|
||||
.arg(
|
||||
Arg::with_name("username")
|
||||
.help("Name of the admin to remove")
|
||||
.required(true),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.get_matches();
|
||||
let app = ArgvApp::from_args();
|
||||
|
||||
let basic = matches.is_present("basic")
|
||||
// Default to basic with these subcommands
|
||||
|| matches
|
||||
.subcommand_name()
|
||||
.filter(|name| ["admin"].contains(name))
|
||||
.is_some();
|
||||
let noninteractive = matches.is_present("non-interactive");
|
||||
let no_auth = matches.is_present("no-auth");
|
||||
|
||||
let sql_log_mode = match matches.value_of("sql-log-mode") {
|
||||
Some("trace") => SqlLogMode::Trace,
|
||||
Some("profile") => SqlLogMode::Profile,
|
||||
_ => SqlLogMode::Disabled,
|
||||
};
|
||||
let basic = app.basic || app.command.is_some();
|
||||
let noninteractive = app.non_interactive;
|
||||
let no_auth = app.no_auth;
|
||||
let sql_log_mode = app.sql_log_mode;
|
||||
|
||||
// noninteractive implies basic
|
||||
let basic = basic || noninteractive;
|
||||
@ -145,19 +94,46 @@ fn main() -> io::Result<()> {
|
||||
sql_log_mode,
|
||||
};
|
||||
|
||||
#[allow(clippy::single_match)] // Note: remove this when there are more subcommands
|
||||
match matches.subcommand() {
|
||||
("admin", Some(sub_m)) => {
|
||||
admin::admin_subcommand(
|
||||
runtime,
|
||||
sub_m,
|
||||
&server_settings,
|
||||
&mut editable_settings,
|
||||
&server_data_dir,
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
_ => {},
|
||||
if let Some(command) = app.command {
|
||||
return match command {
|
||||
ArgvCommand::Shared(SharedCommand::Admin { command }) => {
|
||||
let login_provider = server::login_provider::LoginProvider::new(
|
||||
server_settings.auth_server_address,
|
||||
runtime,
|
||||
);
|
||||
|
||||
match command {
|
||||
Admin::Add { username, role } => {
|
||||
// FIXME: Currently the UUID can get returned even if the file didn't
|
||||
// change, so this can't be relied on as an error
|
||||
// code; moreover, we do nothing with the UUID
|
||||
// returned in the success case. Fix the underlying function to return
|
||||
// enough information that we can reliably return an error code.
|
||||
let _ = server::add_admin(
|
||||
&username,
|
||||
role,
|
||||
&login_provider,
|
||||
&mut editable_settings,
|
||||
&server_data_dir,
|
||||
);
|
||||
},
|
||||
Admin::Remove { username } => {
|
||||
// FIXME: Currently the UUID can get returned even if the file didn't
|
||||
// change, so this can't be relied on as an error
|
||||
// code; moreover, we do nothing with the UUID
|
||||
// returned in the success case. Fix the underlying function to return
|
||||
// enough information that we can reliably return an error code.
|
||||
let _ = server::remove_admin(
|
||||
&username,
|
||||
&login_provider,
|
||||
&mut editable_settings,
|
||||
&server_data_dir,
|
||||
);
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Panic hook to ensure that console mode is set back correctly if in non-basic
|
||||
@ -170,7 +146,7 @@ fn main() -> io::Result<()> {
|
||||
}));
|
||||
}
|
||||
|
||||
let tui = (!basic || !noninteractive).then(|| Tui::run(basic));
|
||||
let tui = (!noninteractive).then(|| Tui::run(basic));
|
||||
|
||||
info!("Starting server...");
|
||||
|
||||
@ -233,30 +209,40 @@ fn main() -> io::Result<()> {
|
||||
if let Some(tui) = tui.as_ref() {
|
||||
match tui.msg_r.try_recv() {
|
||||
Ok(msg) => match msg {
|
||||
Message::AbortShutdown => shutdown_coordinator.abort_shutdown(&mut server),
|
||||
Message::Shutdown { grace_period } => {
|
||||
// TODO: The TUI parser doesn't support quoted strings so it is not
|
||||
// currently possible to provide a shutdown reason
|
||||
// from the console.
|
||||
let message = "The server is shutting down".to_owned();
|
||||
shutdown_coordinator.initiate_shutdown(&mut server, grace_period, message);
|
||||
Message::Shutdown {
|
||||
command: Shutdown::Cancel,
|
||||
} => shutdown_coordinator.abort_shutdown(&mut server),
|
||||
Message::Shutdown {
|
||||
command: Shutdown::Graceful { seconds, reason },
|
||||
} => {
|
||||
shutdown_coordinator.initiate_shutdown(
|
||||
&mut server,
|
||||
Duration::from_secs(seconds),
|
||||
reason,
|
||||
);
|
||||
},
|
||||
Message::Quit => {
|
||||
Message::Shutdown {
|
||||
command: Shutdown::Immediate,
|
||||
} => {
|
||||
info!("Closing the server");
|
||||
break;
|
||||
},
|
||||
Message::AddAdmin(username) => {
|
||||
server.add_admin(&username);
|
||||
Message::Shared(SharedCommand::Admin {
|
||||
command: Admin::Add { username, role },
|
||||
}) => {
|
||||
server.add_admin(&username, role);
|
||||
},
|
||||
Message::RemoveAdmin(username) => {
|
||||
Message::Shared(SharedCommand::Admin {
|
||||
command: Admin::Remove { username },
|
||||
}) => {
|
||||
server.remove_admin(&username);
|
||||
},
|
||||
Message::LoadArea(view_distance) => {
|
||||
Message::LoadArea { view_distance } => {
|
||||
#[cfg(feature = "worldgen")]
|
||||
server.create_centered_persister(view_distance);
|
||||
},
|
||||
Message::SetSqlLogMode(sql_log_mode) => {
|
||||
server.set_sql_log_mode(sql_log_mode);
|
||||
Message::SqlLogMode { mode } => {
|
||||
server.set_sql_log_mode(mode);
|
||||
},
|
||||
Message::DisconnectAllClients => {
|
||||
server.disconnect_all_clients();
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,9 @@ vek = { version = "0.14.1", features = ["serde"] }
|
||||
futures-util = "0.3.7"
|
||||
tokio = { version = "1", default-features = false, features = ["rt"] }
|
||||
prometheus-hyper = "0.1.2"
|
||||
atomicwrites = "0.3.0"
|
||||
chrono = { version = "0.4.9", features = ["serde"] }
|
||||
humantime = "2.1.0"
|
||||
itertools = "0.10"
|
||||
lazy_static = "1.4.0"
|
||||
scan_fmt = "0.2.6"
|
||||
@ -40,7 +43,6 @@ ron = { version = "0.6", default-features = false }
|
||||
serde = { version = "1.0.110", features = ["derive"] }
|
||||
serde_json = "1.0.50"
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
chrono = "0.4.9"
|
||||
hashbrown = { version = "0.9", features = ["rayon", "serde", "nightly"] }
|
||||
rayon = "1.5"
|
||||
crossbeam-channel = "0.5"
|
||||
|
@ -3,13 +3,15 @@
|
||||
//! `CHAT_COMMANDS` and provide a handler function.
|
||||
|
||||
use crate::{
|
||||
settings::{BanRecord, EditableSetting},
|
||||
settings::{
|
||||
Ban, BanAction, BanInfo, EditableSetting, SettingError, WhitelistInfo, WhitelistRecord,
|
||||
},
|
||||
wiring::{Logic, OutputFormula},
|
||||
Server, SpawnPoint, StateExt,
|
||||
};
|
||||
use assets::AssetExt;
|
||||
use authc::Uuid;
|
||||
use chrono::{NaiveTime, Timelike};
|
||||
use chrono::{NaiveTime, Timelike, Utc};
|
||||
use common::{
|
||||
assets,
|
||||
cmd::{ChatCommand, BUFF_PACK, BUFF_PARSER, CHAT_COMMANDS, CHAT_SHORTCUTS},
|
||||
@ -19,7 +21,7 @@ use common::{
|
||||
buff::{Buff, BuffCategory, BuffData, BuffKind, BuffSource},
|
||||
inventory::item::{tool::AbilityMap, MaterialStatManifest, Quality},
|
||||
invite::InviteKind,
|
||||
ChatType, Inventory, Item, LightEmitter, WaypointArea,
|
||||
AdminRole, ChatType, Inventory, Item, LightEmitter, WaypointArea,
|
||||
},
|
||||
depot,
|
||||
effect::Effect,
|
||||
@ -36,10 +38,11 @@ use common_net::{
|
||||
sync::WorldSyncExt,
|
||||
};
|
||||
use common_state::{BuildAreaError, BuildAreas};
|
||||
use core::{convert::TryFrom, ops::Not, time::Duration};
|
||||
use core::{cmp::Ordering, convert::TryFrom, time::Duration};
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use humantime::Duration as HumanDuration;
|
||||
use rand::Rng;
|
||||
use specs::{Builder, Entity as EcsEntity, Join, WorldExt};
|
||||
use specs::{storage::StorageEntry, Builder, Entity as EcsEntity, Join, WorldExt};
|
||||
use vek::*;
|
||||
use wiring::{Circuit, Wire, WiringAction, WiringActionEffect, WiringElement};
|
||||
use world::util::Sampler;
|
||||
@ -54,15 +57,7 @@ pub trait ChatCommandExt {
|
||||
impl ChatCommandExt for ChatCommand {
|
||||
#[allow(clippy::needless_return)] // TODO: Pending review in #587
|
||||
fn execute(&self, server: &mut Server, entity: EcsEntity, args: String) {
|
||||
if self.needs_admin() && !server.entity_is_admin(entity) {
|
||||
server.notify_client(
|
||||
entity,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandError,
|
||||
format!("You don't have permission to use '/{}'.", self.keyword()),
|
||||
),
|
||||
);
|
||||
} else if let Err(err) = get_handler(self)(server, entity, entity, args, &self) {
|
||||
if let Err(err) = do_command(server, entity, entity, args, self) {
|
||||
server.notify_client(
|
||||
entity,
|
||||
ServerGeneral::server_msg(ChatType::CommandError, err),
|
||||
@ -91,8 +86,22 @@ type CmdResult<T> = Result<T, String>;
|
||||
/// failed; on failure, the string is sent to the client who initiated the
|
||||
/// command.
|
||||
type CommandHandler = fn(&mut Server, EcsEntity, EcsEntity, String, &ChatCommand) -> CmdResult<()>;
|
||||
fn get_handler(cmd: &ChatCommand) -> CommandHandler {
|
||||
match cmd {
|
||||
|
||||
fn do_command(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
target: EcsEntity,
|
||||
args: String,
|
||||
cmd: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
// Make sure your role is at least high enough to execute this command.
|
||||
if cmd.needs_role() > server.entity_admin_role(client) {
|
||||
return Err(format!(
|
||||
"You don't have permission to use '/{}'.",
|
||||
cmd.keyword()
|
||||
));
|
||||
}
|
||||
let handler: CommandHandler = match cmd {
|
||||
ChatCommand::Adminify => handle_adminify,
|
||||
ChatCommand::Airship => handle_spawn_airship,
|
||||
ChatCommand::Alias => handle_alias,
|
||||
@ -155,7 +164,9 @@ fn get_handler(cmd: &ChatCommand) -> CommandHandler {
|
||||
ChatCommand::Wiring => handle_spawn_wiring,
|
||||
ChatCommand::Whitelist => handle_whitelist,
|
||||
ChatCommand::World => handle_world,
|
||||
}
|
||||
};
|
||||
|
||||
handler(server, client, target, args, cmd)
|
||||
}
|
||||
|
||||
// Fallibly get position of entity with the given descriptor (used for error
|
||||
@ -198,6 +209,25 @@ fn insert_or_replace_component<C: specs::Component>(
|
||||
.map_err(|_| format!("Entity {:?} is dead!", descriptor))
|
||||
}
|
||||
|
||||
fn uuid(server: &Server, entity: EcsEntity, descriptor: &str) -> CmdResult<Uuid> {
|
||||
server
|
||||
.state
|
||||
.ecs()
|
||||
.read_storage::<comp::Player>()
|
||||
.get(entity)
|
||||
.map(|player| player.uuid())
|
||||
.ok_or_else(|| format!("Cannot get player information for {:?}", descriptor))
|
||||
}
|
||||
|
||||
fn real_role(server: &Server, uuid: Uuid, descriptor: &str) -> CmdResult<comp::AdminRole> {
|
||||
server
|
||||
.editable_settings()
|
||||
.admins
|
||||
.get(&uuid)
|
||||
.map(|record| record.role.into())
|
||||
.ok_or_else(|| format!("Cannot get administrator roles for {:?} uuid", descriptor))
|
||||
}
|
||||
|
||||
// Fallibly get uid of entity with the given descriptor (used for error
|
||||
// message).
|
||||
fn uid(server: &Server, target: EcsEntity, descriptor: &str) -> CmdResult<Uid> {
|
||||
@ -230,15 +260,46 @@ fn no_sudo(client: EcsEntity, target: EcsEntity) -> CmdResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent application to hardcoded administrators.
|
||||
fn verify_not_hardcoded_admin(server: &mut Server, uuid: Uuid, reason: &str) -> CmdResult<()> {
|
||||
server
|
||||
/// Ensure that client role is above target role, for the purpose of performing
|
||||
/// some (often permanent) administrative action on the target. Note that this
|
||||
/// function is *not* a replacement for actually verifying that the client
|
||||
/// should be able to execute the command at all, which still needs to be
|
||||
/// rechecked, nor does it guarantee that either the client or the target
|
||||
/// actually have an entry in the admin settings file.
|
||||
///
|
||||
/// For our purposes, there are *two* roles--temporary role, and permanent role.
|
||||
/// For the purpose of these checks, currently *any* permanent role overrides
|
||||
/// *any* temporary role (this may change if more roles are added that aren't
|
||||
/// moderator or administrator). If the permanent roles match, the temporary
|
||||
/// roles are used as a tiebreaker. /adminify should ensure that no one's
|
||||
/// temporary role can be different from their permanent role without someone
|
||||
/// with a higher role than their permanent role allowing it, and only permanent
|
||||
/// roles should be recorded in the settings files.
|
||||
fn verify_above_role(
|
||||
server: &mut Server,
|
||||
(client, client_uuid): (EcsEntity, Uuid),
|
||||
(player, player_uuid): (EcsEntity, Uuid),
|
||||
reason: &str,
|
||||
) -> CmdResult<()> {
|
||||
let client_temp = server.entity_admin_role(client);
|
||||
let client_perm = server
|
||||
.editable_settings()
|
||||
.admins
|
||||
.contains(&uuid)
|
||||
.not()
|
||||
.then_some(())
|
||||
.ok_or_else(|| reason.into())
|
||||
.get(&client_uuid)
|
||||
.map(|record| record.role);
|
||||
|
||||
let player_temp = server.entity_admin_role(player);
|
||||
let player_perm = server
|
||||
.editable_settings()
|
||||
.admins
|
||||
.get(&player_uuid)
|
||||
.map(|record| record.role);
|
||||
|
||||
if client_perm > player_perm || client_perm == player_perm && client_temp > player_temp {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(reason.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_alias(ecs: &specs::World, alias: &str) -> CmdResult<(EcsEntity, Uuid)> {
|
||||
@ -262,7 +323,72 @@ fn find_username(server: &mut Server, username: &str) -> CmdResult<Uuid> {
|
||||
.state
|
||||
.mut_resource::<LoginProvider>()
|
||||
.username_to_uuid(username)
|
||||
.map_err(|_| format!("Unable to determine UUID for username \"{}\"", username))
|
||||
.map_err(|_| format!("Unable to determine UUID for username {:?}", username))
|
||||
}
|
||||
|
||||
/// NOTE: Intended to be run only on logged-in clients.
|
||||
fn uuid_to_username(
|
||||
server: &mut Server,
|
||||
fallback_entity: EcsEntity,
|
||||
uuid: Uuid,
|
||||
) -> CmdResult<String> {
|
||||
let make_err = || format!("Unable to determine username for UUID {:?}", uuid);
|
||||
let player_storage = server.state.ecs().read_storage::<comp::Player>();
|
||||
|
||||
let fallback_alias = &player_storage
|
||||
.get(fallback_entity)
|
||||
.ok_or_else(make_err)?
|
||||
.alias;
|
||||
|
||||
server
|
||||
.state
|
||||
.ecs()
|
||||
.read_resource::<LoginProvider>()
|
||||
.uuid_to_username(uuid, fallback_alias)
|
||||
.map_err(|_| make_err())
|
||||
}
|
||||
|
||||
fn edit_setting_feedback<S: EditableSetting>(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
result: Option<(String, Result<(), SettingError<S>>)>,
|
||||
failure: impl FnOnce() -> String,
|
||||
) -> CmdResult<()> {
|
||||
let (info, result) = result.ok_or_else(failure)?;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(ChatType::CommandInfo, info),
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
Err(SettingError::Io(err)) => {
|
||||
warn!(
|
||||
?err,
|
||||
"Failed to write settings file to disk, but succeeded in memory (success message: \
|
||||
{})",
|
||||
info,
|
||||
);
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandError,
|
||||
format!(
|
||||
"Failed to write settings file to disk, but succeeded in memory.\n
|
||||
Error (storage): {:?}\n
|
||||
Success (memory): {}",
|
||||
err, info
|
||||
),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
Err(SettingError::Integrity(err)) => Err(format!(
|
||||
"Encountered an error while validating the request: {:?}",
|
||||
err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_drop_all(
|
||||
@ -451,35 +577,41 @@ fn handle_set_motd(
|
||||
action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
let data_dir = server.data_dir();
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
// Ensure the person setting this has a real role in the settings file, since
|
||||
// it's persistent.
|
||||
let _client_real_role = real_role(server, client_uuid, "client")?;
|
||||
match scan_fmt!(&args, &action.arg_fmt(), String) {
|
||||
Ok(msg) => {
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.server_description
|
||||
.edit(data_dir.as_ref(), |d| **d = msg.clone());
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("Server description set to \"{}\"", msg),
|
||||
),
|
||||
);
|
||||
let edit =
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.server_description
|
||||
.edit(data_dir.as_ref(), |d| {
|
||||
let info = format!("Server description set to {:?}", msg);
|
||||
**d = msg;
|
||||
Some(info)
|
||||
});
|
||||
drop(data_dir);
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
unreachable!("edit always returns Some")
|
||||
})
|
||||
},
|
||||
Err(_) => {
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.server_description
|
||||
.edit(data_dir.as_ref(), |d| d.clear());
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
"Removed server description".to_string(),
|
||||
),
|
||||
);
|
||||
let edit =
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.server_description
|
||||
.edit(data_dir.as_ref(), |d| {
|
||||
d.clear();
|
||||
Some("Removed server description".to_string())
|
||||
});
|
||||
drop(data_dir);
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
unreachable!("edit always returns Some")
|
||||
})
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_jump(
|
||||
@ -1365,12 +1497,16 @@ fn handle_help(
|
||||
)
|
||||
} else {
|
||||
let mut message = String::new();
|
||||
for cmd in CHAT_COMMANDS.iter() {
|
||||
if !cmd.needs_admin() || server.entity_is_admin(client) {
|
||||
let entity_role = server.entity_admin_role(client);
|
||||
|
||||
// Iterate through all commands you have permission to use.
|
||||
CHAT_COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| cmd.needs_role() <= entity_role)
|
||||
.for_each(|cmd| {
|
||||
message += &cmd.help_string();
|
||||
message += "\n";
|
||||
}
|
||||
}
|
||||
});
|
||||
message += "Additionally, you can use the following shortcuts:";
|
||||
for (k, v) in CHAT_SHORTCUTS.iter() {
|
||||
message += &format!(" /{} => /{}", k, v.keyword());
|
||||
@ -1870,40 +2006,107 @@ fn handle_spawn_wiring(
|
||||
#[allow(clippy::useless_conversion)] // TODO: Pending review in #587
|
||||
fn handle_adminify(
|
||||
server: &mut Server,
|
||||
_client: EcsEntity,
|
||||
client: EcsEntity,
|
||||
_target: EcsEntity,
|
||||
args: String,
|
||||
action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
if let Ok(alias) = scan_fmt!(&args, &action.arg_fmt(), String) {
|
||||
let (player, uuid) = find_alias(server.state.ecs(), &alias)?;
|
||||
let uid = uid(server, player, "player")?;
|
||||
verify_not_hardcoded_admin(
|
||||
server,
|
||||
uuid,
|
||||
"Admins specified in server configuration files cannot be de-adminified.",
|
||||
)?;
|
||||
let is_admin = if server
|
||||
.state
|
||||
.read_component_copied::<comp::Admin>(player)
|
||||
.is_some()
|
||||
{
|
||||
server
|
||||
.state
|
||||
.ecs()
|
||||
.write_storage::<comp::Admin>()
|
||||
.remove(player);
|
||||
false
|
||||
if let (Some(alias), desired_role) = scan_fmt_some!(&args, &action.arg_fmt(), String, String) {
|
||||
let desired_role = if let Some(mut desired_role) = desired_role {
|
||||
desired_role.make_ascii_lowercase();
|
||||
Some(match &*desired_role {
|
||||
"admin" => AdminRole::Admin,
|
||||
"moderator" => AdminRole::Moderator,
|
||||
_ => {
|
||||
return Err(action.help_string());
|
||||
},
|
||||
})
|
||||
} else {
|
||||
server
|
||||
.state
|
||||
.ecs()
|
||||
.write_storage()
|
||||
.insert(player, comp::Admin)
|
||||
.is_ok()
|
||||
None
|
||||
};
|
||||
// Update player list so the player shows up as admin in client chat.
|
||||
let msg = ServerGeneral::PlayerListUpdate(PlayerListUpdate::Admin(uid, is_admin));
|
||||
let (player, player_uuid) = find_alias(server.state.ecs(), &alias)?;
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let uid = uid(server, player, "player")?;
|
||||
|
||||
// Your permanent role, not your temporary role, is what's used to determine
|
||||
// what temporary roles you can grant.
|
||||
let client_real_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
// This appears to prevent de-mod / de-admin for mods / admins with access to
|
||||
// this command, but it does not in the case where the target is
|
||||
// temporary, because `verify_above_role` always values permanent roles
|
||||
// above temporary ones.
|
||||
verify_above_role(
|
||||
server,
|
||||
(client, client_uuid),
|
||||
(player, player_uuid),
|
||||
"Cannot reassign a role for anyone with your role or higher.",
|
||||
)?;
|
||||
|
||||
// Ensure that it's not possible to assign someone a higher role than your own
|
||||
// (i.e. even if mods had the ability to create temporary mods, they
|
||||
// wouldn't be able to create temporary admins).
|
||||
//
|
||||
// Also note that we perform no more permissions checks after this point based
|
||||
// on the assignee's temporary role--even if the player's temporary role
|
||||
// is higher than the client's, we still allow the role to be reduced to
|
||||
// the selected role, as long as they would have permission to assign it
|
||||
// in the first place. This is consistent with our
|
||||
// policy on bans--banning or lengthening a ban (decreasing player permissions)
|
||||
// can be done even after an unban or ban shortening (increasing player
|
||||
// permissions) by someone with a higher role than the person doing the
|
||||
// ban. So if we change how bans work, we should change how things work
|
||||
// here, too, for consistency.
|
||||
if desired_role > Some(client_real_role.into()) {
|
||||
return Err(
|
||||
"Cannot assign someone a temporary role higher than your own permanent one".into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut admin_storage = server.state.ecs().write_storage::<comp::Admin>();
|
||||
let entry = admin_storage
|
||||
.entry(player)
|
||||
.map_err(|_| "Cannot find player entity!".to_string())?;
|
||||
match (entry, desired_role) {
|
||||
(StorageEntry::Vacant(_), None) => {
|
||||
return Err("Player already has no role!".into());
|
||||
},
|
||||
(StorageEntry::Occupied(o), None) => {
|
||||
let old_role = o.remove().0;
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("Role removed from player {}: {:?}", alias, old_role),
|
||||
),
|
||||
);
|
||||
},
|
||||
(entry, Some(desired_role)) => {
|
||||
let verb = match entry
|
||||
.replace(comp::Admin(desired_role))
|
||||
.map(|old_admin| old_admin.0.cmp(&desired_role))
|
||||
{
|
||||
Some(Ordering::Equal) => {
|
||||
return Err("Player already has that role!".into());
|
||||
},
|
||||
Some(Ordering::Greater) => "downgraded",
|
||||
Some(Ordering::Less) | None => "upgraded",
|
||||
};
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("Role for player {} {} to {:?}", alias, verb, desired_role),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
// Update player list so the player shows up as moderator in client chat.
|
||||
//
|
||||
// NOTE: We deliberately choose not to differentiate between moderators and
|
||||
// administrators in the player list.
|
||||
let is_moderator = desired_role.is_some();
|
||||
let msg = ServerGeneral::PlayerListUpdate(PlayerListUpdate::Moderator(uid, is_moderator));
|
||||
server.state.notify_players(msg);
|
||||
Ok(())
|
||||
} else {
|
||||
@ -2299,6 +2502,10 @@ fn handle_disconnect_all_players(
|
||||
args: String,
|
||||
_action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
// Make sure temporary mods/admins can't run this command.
|
||||
let _role = real_role(server, client_uuid, "role")?;
|
||||
|
||||
if args != *"confirm" {
|
||||
return Err(
|
||||
"Please run the command again with the second argument of \"confirm\" to confirm that \
|
||||
@ -2433,14 +2640,19 @@ fn handle_sudo(
|
||||
{
|
||||
let cmd_args = cmd_args.unwrap_or_else(|| String::from(""));
|
||||
if let Ok(action) = cmd.parse() {
|
||||
let ecs = server.state.ecs();
|
||||
let (entity, uuid) = find_alias(ecs, &player_alias)?;
|
||||
verify_not_hardcoded_admin(
|
||||
let (player, player_uuid) = find_alias(server.state.ecs(), &player_alias)?;
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
verify_above_role(
|
||||
server,
|
||||
uuid,
|
||||
"Cannot sudo admins specified in server configuration files.",
|
||||
(client, client_uuid),
|
||||
(player, player_uuid),
|
||||
"Cannot sudo players with roles higher than your own.",
|
||||
)?;
|
||||
get_handler(&action)(server, client, entity, cmd_args, &action)
|
||||
|
||||
// TODO: consider making this into a tail call or loop (to avoid the potential
|
||||
// stack overflow, although it's less of a risk coming from only mods and
|
||||
// admins).
|
||||
do_command(server, client, player, cmd_args, &action)
|
||||
} else {
|
||||
Err(format!("Unknown command: /{}", cmd))
|
||||
}
|
||||
@ -2477,35 +2689,63 @@ fn handle_whitelist(
|
||||
args: String,
|
||||
action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
if let Ok((whitelist_action, username)) = scan_fmt!(&args, &action.arg_fmt(), String, String) {
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
if whitelist_action.eq_ignore_ascii_case("add") {
|
||||
let uuid = find_username(server, &username)?;
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.whitelist
|
||||
.edit(server.data_dir().as_ref(), |w| w.insert(uuid));
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("\"{}\" added to whitelist", username),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
|
||||
let record = WhitelistRecord {
|
||||
date: now,
|
||||
info: Some(WhitelistInfo {
|
||||
username_when_whitelisted: username.clone(),
|
||||
whitelisted_by: client_uuid,
|
||||
whitelisted_by_username: client_username,
|
||||
whitelisted_by_role: client_role.into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let edit =
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.whitelist
|
||||
.edit(server.data_dir().as_ref(), |w| {
|
||||
if w.insert(uuid, record).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("added to whitelist: {}", username))
|
||||
}
|
||||
});
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
format!("already in whitelist: {}!", username)
|
||||
})
|
||||
} else if whitelist_action.eq_ignore_ascii_case("remove") {
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
let uuid = find_username(server, &username)?;
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.whitelist
|
||||
.edit(server.data_dir().as_ref(), |w| w.remove(&uuid));
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("\"{}\" removed from whitelist", username),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
let mut err_info = "not part of whitelist: ";
|
||||
let edit =
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.whitelist
|
||||
.edit(server.data_dir().as_ref(), |w| {
|
||||
w.remove(&uuid)
|
||||
.filter(|record| {
|
||||
if record.whitelisted_by_role() <= client_role.into() {
|
||||
true
|
||||
} else {
|
||||
err_info = "permission denied to remove user: ";
|
||||
false
|
||||
}
|
||||
})
|
||||
.map(|_| format!("removed from whitelist: {}", username))
|
||||
});
|
||||
edit_setting_feedback(server, client, edit, || format!("{}{}", err_info, username))
|
||||
} else {
|
||||
Err(action.help_string())
|
||||
}
|
||||
@ -2516,13 +2756,15 @@ fn handle_whitelist(
|
||||
|
||||
fn kick_player(
|
||||
server: &mut Server,
|
||||
(target_player, uuid): (EcsEntity, Uuid),
|
||||
(client, client_uuid): (EcsEntity, Uuid),
|
||||
(target_player, target_player_uuid): (EcsEntity, Uuid),
|
||||
reason: &str,
|
||||
) -> CmdResult<()> {
|
||||
verify_not_hardcoded_admin(
|
||||
verify_above_role(
|
||||
server,
|
||||
uuid,
|
||||
"Cannot kick admins specified in server configuration files.",
|
||||
(client, client_uuid),
|
||||
(target_player, target_player_uuid),
|
||||
"Cannot kick players with roles higher than your own.",
|
||||
)?;
|
||||
server.notify_client(
|
||||
target_player,
|
||||
@ -2548,11 +2790,12 @@ fn handle_kick(
|
||||
if let (Some(target_alias), reason_opt) =
|
||||
scan_fmt_some!(&args, &action.arg_fmt(), String, String)
|
||||
{
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let reason = reason_opt.unwrap_or_default();
|
||||
let ecs = server.state.ecs();
|
||||
let target_player = find_alias(ecs, &target_alias)?;
|
||||
|
||||
kick_player(server, target_player, &reason)?;
|
||||
kick_player(server, (client, client_uuid), target_player, &reason)?;
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
@ -2576,39 +2819,77 @@ fn handle_ban(
|
||||
args: String,
|
||||
action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
if let (Some(username), reason_opt) = scan_fmt_some!(&args, &action.arg_fmt(), String, String) {
|
||||
if let (Some(username), overwrite, parse_duration, reason_opt) = scan_fmt_some!(
|
||||
&args,
|
||||
&action.arg_fmt(),
|
||||
String,
|
||||
bool,
|
||||
HumanDuration,
|
||||
String
|
||||
) {
|
||||
let reason = reason_opt.unwrap_or_default();
|
||||
let uuid = find_username(server, &username)?;
|
||||
let overwrite = overwrite.unwrap_or(false);
|
||||
|
||||
if server.editable_settings().banlist.contains_key(&uuid) {
|
||||
Err(format!("{} is already on the banlist", username))
|
||||
} else {
|
||||
server
|
||||
.editable_settings_mut()
|
||||
.banlist
|
||||
.edit(server.data_dir().as_ref(), |b| {
|
||||
b.insert(uuid, BanRecord {
|
||||
username_when_banned: username.clone(),
|
||||
reason: reason.clone(),
|
||||
});
|
||||
});
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
let now = Utc::now();
|
||||
let end_date = parse_duration
|
||||
.map(|duration| chrono::Duration::from_std(duration.into()))
|
||||
.transpose()
|
||||
.map_err(|err| format!("Error converting to duration: {}", err))?
|
||||
// On overflow (someone adding some ridiculous timespan), just make the ban infinite.
|
||||
.and_then(|duration| now.checked_add_signed(duration));
|
||||
|
||||
let ban_info = BanInfo {
|
||||
performed_by: client_uuid,
|
||||
performed_by_username: client_username,
|
||||
performed_by_role: client_role.into(),
|
||||
};
|
||||
|
||||
let ban = Ban {
|
||||
reason: reason.clone(),
|
||||
info: Some(ban_info),
|
||||
end_date,
|
||||
};
|
||||
|
||||
let edit = server
|
||||
.editable_settings_mut()
|
||||
.banlist
|
||||
.ban_action(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
BanAction::Ban(ban),
|
||||
overwrite,
|
||||
)
|
||||
.map(|result| {
|
||||
(
|
||||
format!("Added {} to the banlist with reason: {}", username, reason),
|
||||
),
|
||||
);
|
||||
result,
|
||||
)
|
||||
});
|
||||
|
||||
// If the player is online kick them (this may fail if the player is a hardcoded
|
||||
// admin; we don't care about that case because hardcoded admins can log on even
|
||||
// if they're on the ban list).
|
||||
let ecs = server.state.ecs();
|
||||
if let Ok(target_player) = find_uuid(ecs, uuid) {
|
||||
let _ = kick_player(server, (target_player, uuid), &reason);
|
||||
}
|
||||
Ok(())
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
format!("{} is already on the banlist", username)
|
||||
})?;
|
||||
// If the player is online kick them (this may fail if the player is a hardcoded
|
||||
// admin; we don't care about that case because hardcoded admins can log on even
|
||||
// if they're on the ban list).
|
||||
let ecs = server.state.ecs();
|
||||
if let Ok(target_player) = find_uuid(ecs, player_uuid) {
|
||||
let _ = kick_player(
|
||||
server,
|
||||
(client, client_uuid),
|
||||
(target_player, player_uuid),
|
||||
&reason,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(action.help_string())
|
||||
}
|
||||
@ -2622,22 +2903,38 @@ fn handle_unban(
|
||||
action: &ChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
if let Ok(username) = scan_fmt!(&args, &action.arg_fmt(), String) {
|
||||
let uuid = find_username(server, &username)?;
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
|
||||
server
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let ban_info = BanInfo {
|
||||
performed_by: client_uuid,
|
||||
performed_by_username: client_username,
|
||||
performed_by_role: client_role.into(),
|
||||
};
|
||||
|
||||
let unban = BanAction::Unban(ban_info);
|
||||
|
||||
let edit = server
|
||||
.editable_settings_mut()
|
||||
.banlist
|
||||
.edit(server.data_dir().as_ref(), |b| {
|
||||
b.remove(&uuid);
|
||||
});
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(
|
||||
ChatType::CommandInfo,
|
||||
format!("{} was successfully unbanned", username),
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
.ban_action(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
unban,
|
||||
false,
|
||||
)
|
||||
.map(|result| (format!("{} was successfully unbanned", username), result));
|
||||
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
format!("{} was already unbanned", username)
|
||||
})
|
||||
} else {
|
||||
Err(action.help_string())
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -103,7 +103,7 @@ use std::{
|
||||
#[cfg(not(feature = "worldgen"))]
|
||||
use test_world::{IndexOwned, World};
|
||||
use tokio::{runtime::Runtime, sync::Notify};
|
||||
use tracing::{debug, error, info, trace};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
use vek::*;
|
||||
|
||||
use crate::{
|
||||
@ -1048,23 +1048,25 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_is_admin(&self, entity: EcsEntity) -> bool {
|
||||
fn entity_admin_role(&self, entity: EcsEntity) -> Option<comp::AdminRole> {
|
||||
self.state
|
||||
.read_storage::<comp::Admin>()
|
||||
.get(entity)
|
||||
.is_some()
|
||||
.read_component_copied::<comp::Admin>(entity)
|
||||
.map(|admin| admin.0)
|
||||
}
|
||||
|
||||
pub fn number_of_players(&self) -> i64 {
|
||||
self.state.ecs().read_storage::<Client>().join().count() as i64
|
||||
}
|
||||
|
||||
pub fn add_admin(&self, username: &str) {
|
||||
/// NOTE: Do *not* allow this to be called from any command that doesn't go
|
||||
/// through the CLI!
|
||||
pub fn add_admin(&mut self, username: &str, role: comp::AdminRole) {
|
||||
let mut editable_settings = self.editable_settings_mut();
|
||||
let login_provider = self.state.ecs().fetch::<LoginProvider>();
|
||||
let data_dir = self.data_dir();
|
||||
if let Some(entity) = add_admin(
|
||||
username,
|
||||
role,
|
||||
&login_provider,
|
||||
&mut editable_settings,
|
||||
&data_dir.path,
|
||||
@ -1079,11 +1081,16 @@ impl Server {
|
||||
.find(|(_, player)| player.uuid() == uuid)
|
||||
.map(|(e, _)| e)
|
||||
}) {
|
||||
// Add admin component if the player is ingame
|
||||
let _ = self.state.ecs().write_storage().insert(entity, comp::Admin);
|
||||
drop((data_dir, login_provider, editable_settings));
|
||||
// Add admin component if the player is ingame; if they are not, we can ignore
|
||||
// the write failure.
|
||||
self.state
|
||||
.write_component_ignore_entity_dead(entity, comp::Admin(role));
|
||||
};
|
||||
}
|
||||
|
||||
/// NOTE: Do *not* allow this to be called from any command that doesn't go
|
||||
/// through the CLI!
|
||||
pub fn remove_admin(&self, username: &str) {
|
||||
let mut editable_settings = self.editable_settings_mut();
|
||||
let login_provider = self.state.ecs().fetch::<LoginProvider>();
|
||||
@ -1162,29 +1169,77 @@ impl Drop for Server {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_edit<T, S: settings::EditableSetting>(
|
||||
data: T,
|
||||
result: Option<(String, Result<(), settings::SettingError<S>>)>,
|
||||
) -> Option<T> {
|
||||
use crate::settings::SettingError;
|
||||
let (info, result) = result?;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!("{}", info);
|
||||
Some(data)
|
||||
},
|
||||
Err(SettingError::Io(err)) => {
|
||||
warn!(
|
||||
?err,
|
||||
"Failed to write settings file to disk, but succeeded in memory (success message: \
|
||||
{})",
|
||||
info,
|
||||
);
|
||||
Some(data)
|
||||
},
|
||||
Err(SettingError::Integrity(err)) => {
|
||||
error!(?err, "Encountered an error while validating the request",);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// If successful returns the Some(uuid) of the added admin
|
||||
///
|
||||
/// NOTE: Do *not* allow this to be called from any command that doesn't go
|
||||
/// through the CLI!
|
||||
#[must_use]
|
||||
pub fn add_admin(
|
||||
username: &str,
|
||||
role: comp::AdminRole,
|
||||
login_provider: &LoginProvider,
|
||||
editable_settings: &mut EditableSettings,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Option<common::uuid::Uuid> {
|
||||
use crate::settings::EditableSetting;
|
||||
let role_ = role.into();
|
||||
match login_provider.username_to_uuid(username) {
|
||||
Ok(uuid) => editable_settings.admins.edit(data_dir, |admins| {
|
||||
if admins.insert(uuid) {
|
||||
info!("Successfully added {} ({}) as an admin!", username, uuid);
|
||||
Some(uuid)
|
||||
} else {
|
||||
info!("{} ({}) is already an admin!", username, uuid);
|
||||
None
|
||||
}
|
||||
}),
|
||||
Ok(uuid) => handle_edit(
|
||||
uuid,
|
||||
editable_settings.admins.edit(data_dir, |admins| {
|
||||
match admins.insert(uuid, settings::AdminRecord {
|
||||
username_when_admined: Some(username.into()),
|
||||
date: chrono::Utc::now(),
|
||||
role: role_,
|
||||
}) {
|
||||
None => Some(format!(
|
||||
"Successfully added {} ({}) as an admin!",
|
||||
username, uuid
|
||||
)),
|
||||
Some(old_admin) if old_admin.role == role_ => {
|
||||
info!("{} ({}) already has role: {:?}!", username, uuid, role);
|
||||
None
|
||||
},
|
||||
Some(old_admin) => Some(format!(
|
||||
"{} ({}) role changed from {:?} to {:?}!",
|
||||
username, uuid, old_admin.role, role
|
||||
)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
Err(err) => {
|
||||
error!(
|
||||
?err,
|
||||
"Could not find uuid for this name either the user does not exist or there was an \
|
||||
error communicating with the auth server."
|
||||
"Could not find uuid for this name; either the user does not exist or there was \
|
||||
an error communicating with the auth server."
|
||||
);
|
||||
None
|
||||
},
|
||||
@ -1192,6 +1247,10 @@ pub fn add_admin(
|
||||
}
|
||||
|
||||
/// If successful returns the Some(uuid) of the removed admin
|
||||
///
|
||||
/// NOTE: Do *not* allow this to be called from any command that doesn't go
|
||||
/// through the CLI!
|
||||
#[must_use]
|
||||
pub fn remove_admin(
|
||||
username: &str,
|
||||
login_provider: &LoginProvider,
|
||||
@ -1200,23 +1259,25 @@ pub fn remove_admin(
|
||||
) -> Option<common::uuid::Uuid> {
|
||||
use crate::settings::EditableSetting;
|
||||
match login_provider.username_to_uuid(username) {
|
||||
Ok(uuid) => editable_settings.admins.edit(data_dir, |admins| {
|
||||
if admins.remove(&uuid) {
|
||||
info!(
|
||||
"Successfully removed {} ({}) from the admins",
|
||||
username, uuid
|
||||
);
|
||||
Some(uuid)
|
||||
} else {
|
||||
info!("{} ({}) is not an admin!", username, uuid);
|
||||
None
|
||||
}
|
||||
}),
|
||||
Ok(uuid) => handle_edit(
|
||||
uuid,
|
||||
editable_settings.admins.edit(data_dir, |admins| {
|
||||
if let Some(admin) = admins.remove(&uuid) {
|
||||
Some(format!(
|
||||
"Successfully removed {} ({}) with role {:?} from the admins list",
|
||||
username, uuid, admin.role,
|
||||
))
|
||||
} else {
|
||||
info!("{} ({}) is not an admin!", username, uuid);
|
||||
None
|
||||
}
|
||||
}),
|
||||
),
|
||||
Err(err) => {
|
||||
error!(
|
||||
?err,
|
||||
"Could not find uuid for this name either the user does not exist or there was an \
|
||||
error communicating with the auth server."
|
||||
"Could not find uuid for this name; either the user does not exist or there was \
|
||||
an error communicating with the auth server."
|
||||
);
|
||||
None
|
||||
},
|
||||
|
@ -1,11 +1,13 @@
|
||||
use crate::settings::BanRecord;
|
||||
use crate::settings::{AdminRecord, BanEntry, WhitelistRecord};
|
||||
use authc::{AuthClient, AuthClientError, AuthToken, Uuid};
|
||||
use chrono::Utc;
|
||||
use common::comp::AdminRole;
|
||||
use common_net::msg::RegisterError;
|
||||
#[cfg(feature = "plugins")]
|
||||
use common_state::plugin::memory_manager::EcsWorld;
|
||||
#[cfg(feature = "plugins")]
|
||||
use common_state::plugin::PluginMgr;
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
use hashbrown::HashMap;
|
||||
use plugin_api::event::{PlayerJoinEvent, PlayerJoinResult};
|
||||
use specs::Component;
|
||||
use specs_idvs::IdvStorage;
|
||||
@ -100,26 +102,39 @@ impl LoginProvider {
|
||||
pending: &mut PendingLogin,
|
||||
#[cfg(feature = "plugins")] world: &EcsWorld,
|
||||
#[cfg(feature = "plugins")] plugin_manager: &PluginMgr,
|
||||
admins: &HashSet<Uuid>,
|
||||
whitelist: &HashSet<Uuid>,
|
||||
banlist: &HashMap<Uuid, BanRecord>,
|
||||
admins: &HashMap<Uuid, AdminRecord>,
|
||||
whitelist: &HashMap<Uuid, WhitelistRecord>,
|
||||
banlist: &HashMap<Uuid, BanEntry>,
|
||||
) -> Option<Result<(String, Uuid), RegisterError>> {
|
||||
match pending.pending_r.try_recv() {
|
||||
Ok(Err(e)) => Some(Err(e)),
|
||||
Ok(Ok((username, uuid))) => {
|
||||
let now = Utc::now();
|
||||
// Hardcoded admins can always log in.
|
||||
let is_admin = admins.contains(&uuid);
|
||||
if !is_admin {
|
||||
if let Some(ban_record) = banlist.get(&uuid) {
|
||||
let admin = admins.get(&uuid);
|
||||
if let Some(ban) = banlist
|
||||
.get(&uuid)
|
||||
.and_then(|ban_record| ban_record.current.action.ban())
|
||||
{
|
||||
// Make sure the ban is active, and that we can't override it.
|
||||
//
|
||||
// If we are an admin and our role is at least as high as the role of the
|
||||
// person who banned us, we can override the ban; we negate this to find
|
||||
// people who cannot override it.
|
||||
let exceeds_ban_role = |admin: &AdminRecord| {
|
||||
Into::<AdminRole>::into(admin.role)
|
||||
>= Into::<AdminRole>::into(ban.performed_by_role())
|
||||
};
|
||||
if !ban.is_expired(now) && !admin.map_or(false, exceeds_ban_role) {
|
||||
// Pull reason string out of ban record and send a copy of it
|
||||
return Some(Err(RegisterError::Banned(ban_record.reason.clone())));
|
||||
return Some(Err(RegisterError::Banned(ban.reason.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
// non-admins can only join if the whitelist is empty (everyone can join)
|
||||
// or his name is in the whitelist
|
||||
if !whitelist.is_empty() && !whitelist.contains(&uuid) {
|
||||
return Some(Err(RegisterError::NotOnWhitelist));
|
||||
}
|
||||
// non-admins can only join if the whitelist is empty (everyone can join)
|
||||
// or their name is in the whitelist.
|
||||
if admin.is_none() && !whitelist.is_empty() && !whitelist.contains_key(&uuid) {
|
||||
return Some(Err(RegisterError::NotOnWhitelist));
|
||||
}
|
||||
|
||||
#[cfg(feature = "plugins")]
|
||||
@ -131,7 +146,7 @@ impl LoginProvider {
|
||||
player_id: *uuid.as_bytes(),
|
||||
}) {
|
||||
Ok(e) => {
|
||||
if !is_admin {
|
||||
if admin.is_none() {
|
||||
for i in e.into_iter() {
|
||||
if let PlayerJoinResult::Kick(a) = i {
|
||||
return Some(Err(RegisterError::Kicked(a)));
|
||||
@ -189,4 +204,18 @@ impl LoginProvider {
|
||||
None => Ok(derive_uuid(username)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uuid_to_username(
|
||||
&self,
|
||||
uuid: Uuid,
|
||||
fallback_alias: &str,
|
||||
) -> Result<String, AuthClientError> {
|
||||
match &self.auth_server {
|
||||
Some(srv) => {
|
||||
//TODO: optimize
|
||||
self.runtime.block_on(srv.uuid_to_username(uuid))
|
||||
},
|
||||
None => Ok(fallback_alias.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -1,17 +1,26 @@
|
||||
pub mod admin;
|
||||
pub mod banlist;
|
||||
mod editable;
|
||||
pub mod server_description;
|
||||
pub mod whitelist;
|
||||
|
||||
pub use editable::EditableSetting;
|
||||
pub use editable::{EditableSetting, Error as SettingError};
|
||||
|
||||
use authc::Uuid;
|
||||
use hashbrown::{HashMap, HashSet};
|
||||
pub use admin::{AdminRecord, Admins};
|
||||
pub use banlist::{
|
||||
Ban, BanAction, BanEntry, BanError, BanErrorKind, BanInfo, BanKind, BanRecord, Banlist,
|
||||
};
|
||||
pub use server_description::ServerDescription;
|
||||
pub use whitelist::{Whitelist, WhitelistInfo, WhitelistRecord};
|
||||
|
||||
use chrono::Utc;
|
||||
use core::time::Duration;
|
||||
use portpicker::pick_unused_port;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
net::SocketAddr,
|
||||
ops::{Deref, DerefMut},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
use tracing::{error, warn};
|
||||
use world::sim::FileOpts;
|
||||
@ -159,30 +168,17 @@ fn with_config_dir(path: &Path) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct BanRecord {
|
||||
pub username_when_banned: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct Whitelist(HashSet<Uuid>);
|
||||
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct Banlist(HashMap<Uuid, BanRecord>);
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ServerDescription(String);
|
||||
impl Default for ServerDescription {
|
||||
fn default() -> Self { Self("This is the best Veloren server".into()) }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct Admins(HashSet<Uuid>);
|
||||
/// Our upgrade guarantee is that if validation succeeds
|
||||
/// for an old version, then migration to the next version must always succeed
|
||||
/// and produce a valid settings file for that version (if we need to change
|
||||
/// this in the future, it should require careful discussion). Therefore, we
|
||||
/// would normally panic if the upgrade produced an invalid settings file, which
|
||||
/// we would perform by doing the following post-validation (example
|
||||
/// is given for a hypothetical upgrade from Whitelist_V1 to Whitelist_V2):
|
||||
///
|
||||
/// Ok(Whitelist_V2::try_into().expect())
|
||||
const MIGRATION_UPGRADE_GUARANTEE: &str = "Any valid file of an old verison should be able to \
|
||||
successfully migrate to the latest version.";
|
||||
|
||||
/// Combines all the editable settings into one struct that is stored in the ecs
|
||||
pub struct EditableSettings {
|
||||
@ -204,69 +200,25 @@ impl EditableSettings {
|
||||
|
||||
pub fn singleplayer(data_dir: &Path) -> Self {
|
||||
let load = Self::load(data_dir);
|
||||
|
||||
let mut server_description = ServerDescription::default();
|
||||
*server_description = "Who needs friends anyway?".into();
|
||||
|
||||
let mut admins = Admins::default();
|
||||
// TODO: Let the player choose if they want to use admin commands or not
|
||||
admins.insert(
|
||||
crate::login_provider::derive_singleplayer_uuid(),
|
||||
AdminRecord {
|
||||
username_when_admined: Some("singleplayer".into()),
|
||||
date: Utc::now(),
|
||||
role: admin::Role::Admin,
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
server_description: ServerDescription("Who needs friends anyway?".into()),
|
||||
// TODO: Let the player choose if they want to use admin commands or not
|
||||
admins: Admins(
|
||||
std::iter::once(crate::login_provider::derive_singleplayer_uuid()).collect(),
|
||||
),
|
||||
server_description,
|
||||
admins,
|
||||
..load
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditableSetting for Whitelist {
|
||||
const FILENAME: &'static str = WHITELIST_FILENAME;
|
||||
}
|
||||
|
||||
impl EditableSetting for Banlist {
|
||||
const FILENAME: &'static str = BANLIST_FILENAME;
|
||||
}
|
||||
|
||||
impl EditableSetting for ServerDescription {
|
||||
const FILENAME: &'static str = SERVER_DESCRIPTION_FILENAME;
|
||||
}
|
||||
|
||||
impl EditableSetting for Admins {
|
||||
const FILENAME: &'static str = ADMINS_FILENAME;
|
||||
}
|
||||
|
||||
impl Deref for Whitelist {
|
||||
type Target = HashSet<Uuid>;
|
||||
|
||||
fn deref(&self) -> &Self::Target { &self.0 }
|
||||
}
|
||||
|
||||
impl DerefMut for Whitelist {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
|
||||
}
|
||||
|
||||
impl Deref for Banlist {
|
||||
type Target = HashMap<Uuid, BanRecord>;
|
||||
|
||||
fn deref(&self) -> &Self::Target { &self.0 }
|
||||
}
|
||||
|
||||
impl DerefMut for Banlist {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
|
||||
}
|
||||
|
||||
impl Deref for ServerDescription {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target { &self.0 }
|
||||
}
|
||||
|
||||
impl DerefMut for ServerDescription {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
|
||||
}
|
||||
|
||||
impl Deref for Admins {
|
||||
type Target = HashSet<Uuid>;
|
||||
|
||||
fn deref(&self) -> &Self::Target { &self.0 }
|
||||
}
|
||||
|
||||
impl DerefMut for Admins {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
|
||||
}
|
||||
|
257
server/src/settings/admin.rs
Normal file
257
server/src/settings/admin.rs
Normal 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))
|
||||
}
|
||||
} */
|
||||
}
|
662
server/src/settings/banlist.rs
Normal file
662
server/src/settings/banlist.rs
Normal 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))
|
||||
}
|
||||
} */
|
||||
}
|
@ -1,26 +1,136 @@
|
||||
use atomicwrites::{AtomicFile, Error as AtomicError, OverwriteBehavior};
|
||||
use core::{convert::TryInto, fmt};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tracing::{error, warn};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub trait EditableSetting: Serialize + DeserializeOwned + Default {
|
||||
#[derive(Debug)]
|
||||
/// Errors that can occur during edits to a settings file.
|
||||
pub enum Error<S: EditableSetting> {
|
||||
/// An error occurred validating the settings file.
|
||||
Integrity(S::Error),
|
||||
/// An IO error occurred when writing to the settings file.
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Same as Error, but carries the validated settings in the Io case.
|
||||
enum ErrorInternal<S: EditableSetting> {
|
||||
Integrity(S::Error),
|
||||
Io(std::io::Error, S),
|
||||
}
|
||||
|
||||
pub enum Version {
|
||||
/// This was an old version of the settings file, so overwrite with the
|
||||
/// modern config.
|
||||
Old,
|
||||
/// Latest version of the settings file.
|
||||
Latest,
|
||||
}
|
||||
|
||||
pub trait EditableSetting: Clone + Default {
|
||||
const FILENAME: &'static str;
|
||||
|
||||
/// Please use this error sparingly, since we ideally want to preserve
|
||||
/// forwards compatibility for all migrations. In particular, this
|
||||
/// error should be used to fail validation *of the original settings
|
||||
/// file* that cannot be caught with ordinary parsing, rather than used
|
||||
/// to signal errors that occurred during migrations.
|
||||
///
|
||||
/// The best error type is Infallible.
|
||||
type Error: fmt::Debug;
|
||||
|
||||
/// Into<Setting> is expected to migrate directly to the latest version,
|
||||
/// which can be implemented using "chaining". The use of `Into` here
|
||||
/// rather than TryInto is intended (together with the expected use of
|
||||
/// chaining) to prevent migrations from invalidating old save files
|
||||
/// without warning; there should always be a non-failing migration path
|
||||
/// from the oldest to latest format (if the migration path fails, we can
|
||||
/// panic).
|
||||
type Legacy: Serialize + DeserializeOwned + Into<Self>;
|
||||
|
||||
/// TryInto<(Version, Self)> is expected to migrate to the latest version
|
||||
/// from any older version, using "chaining" (see [super::banlist] for
|
||||
/// examples).
|
||||
///
|
||||
/// From<Self> is intended to construct the latest version of the
|
||||
/// configuratino file from Self, which we use to save the config file
|
||||
/// on migration or modification. Note that it should always be the
|
||||
/// case that if x is constructed from any of Self::clone, Self::default, or
|
||||
/// Setting::try_into, then Setting::try_from(Self::into(x)).is_ok() must be
|
||||
/// true!
|
||||
///
|
||||
/// The error should be used to fail validation *of the original settings
|
||||
/// file* that cannot be caught with parsing. If we can possibly avoid
|
||||
/// it, we should not create errors in valid settings files during
|
||||
/// migration, to ensure forwards compatibility.
|
||||
type Setting: Serialize
|
||||
+ DeserializeOwned
|
||||
+ TryInto<(Version, Self), Error = Self::Error>
|
||||
+ From<Self>;
|
||||
|
||||
fn load(data_dir: &Path) -> Self {
|
||||
let path = Self::get_path(data_dir);
|
||||
|
||||
if let Ok(file) = fs::File::open(&path) {
|
||||
match ron::de::from_reader(file) {
|
||||
Ok(setting) => setting,
|
||||
Err(e) => {
|
||||
if let Ok(mut file) = fs::File::open(&path) {
|
||||
match ron::de::from_reader(&mut file)
|
||||
.map(|setting: Self::Setting| setting.try_into())
|
||||
.or_else(|orig_err| {
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
ron::de::from_reader(file)
|
||||
.map(|legacy| Ok((Version::Old, Self::Legacy::into(legacy))))
|
||||
// When both legacy and nonlegacy have parse errors, prioritize the
|
||||
// nonlegacy one, since we can't tell which one is "right" and legacy
|
||||
// formats are simple, early, and uncommon enough that we expect
|
||||
// few parse errors in those.
|
||||
.or(Err(orig_err))
|
||||
})
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
?e,
|
||||
"Failed to parse setting file! Falling back to default and moving \
|
||||
existing file to a .invalid"
|
||||
);
|
||||
|
||||
})
|
||||
.and_then(|inner| {
|
||||
inner.map_err(|e| {
|
||||
warn!(
|
||||
?e,
|
||||
"Failed to parse setting file! Falling back to default and moving \
|
||||
existing file to a .invalid"
|
||||
);
|
||||
})
|
||||
}) {
|
||||
Ok((version, mut settings)) => {
|
||||
if matches!(version, Version::Old) {
|
||||
// Old version, which means we either performed a migration or there was
|
||||
// some needed update to the file. If this is the case, we preemptively
|
||||
// overwrite the settings file (not strictly needed, but useful for
|
||||
// people who do manual editing).
|
||||
info!("Settings were changed on load, updating file...");
|
||||
// We don't care if we encountered an error on saving updates to a
|
||||
// settings file that we just loaded (it's already logged and migrated).
|
||||
// However, we should crash if it reported an integrity failure, since we
|
||||
// supposedly just validated it.
|
||||
if let Err(Error::Integrity(err)) = settings
|
||||
.edit(data_dir, |_| Some(()))
|
||||
.expect("Some always returns Some")
|
||||
.1
|
||||
{
|
||||
panic!(
|
||||
"The identity conversion from a validated settings file must
|
||||
always be valid, but we found an integrity error: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
settings
|
||||
},
|
||||
Err(()) => {
|
||||
// Rename existing file to .invalid.ron
|
||||
let mut new_path = path.with_extension("invalid.ron");
|
||||
|
||||
@ -50,13 +160,58 @@ pub trait EditableSetting: Serialize + DeserializeOwned + Default {
|
||||
}
|
||||
}
|
||||
|
||||
fn edit<R>(&mut self, data_dir: &Path, f: impl FnOnce(&mut Self) -> R) -> R {
|
||||
/// If the result of calling f is None,we return None (this constitutes an
|
||||
/// early return and lets us abandon the in-progress edit). For
|
||||
/// example, this can be used to avoid adding a new ban entry if someone
|
||||
/// is already banned and the user didn't explicitly specify that they
|
||||
/// wanted to add a new ban record, even though it would be completely
|
||||
/// valid to attach one.
|
||||
///
|
||||
/// Otherwise (the result of calling f was Some(r)), we always return
|
||||
/// Some((r, res)), where:
|
||||
///
|
||||
/// If res is Ok(()), validation succeeded for the edited, and changes made
|
||||
/// inside the closure are applied both in memory (to self) and
|
||||
/// atomically on disk.
|
||||
///
|
||||
/// Otherwise (res is Err(e)), some step in the edit process failed.
|
||||
/// Specifically:
|
||||
///
|
||||
/// * If e is Integrity, validation failed and the settings were not
|
||||
/// updated.
|
||||
/// * If e is Io, validation succeeded and the settings were updated in
|
||||
/// memory, but they
|
||||
/// could not be saved to storage (and a warning was logged). The reason we
|
||||
/// return an error even though the operation was partially successful
|
||||
/// is so we can alert the player who ran the command about the failure,
|
||||
/// as they will often be an administrator who can usefully act upon that
|
||||
/// information.
|
||||
#[must_use]
|
||||
fn edit<R>(
|
||||
&mut self,
|
||||
data_dir: &Path,
|
||||
f: impl FnOnce(&mut Self) -> Option<R>,
|
||||
) -> Option<(R, Result<(), Error<Self>>)> {
|
||||
let path = Self::get_path(data_dir);
|
||||
|
||||
let r = f(self);
|
||||
save_to_file(&*self, &path)
|
||||
.unwrap_or_else(|err| warn!("Failed to save setting: {:?}", err));
|
||||
r
|
||||
// First, edit a copy.
|
||||
let mut copy = self.clone();
|
||||
let r = f(&mut copy)?;
|
||||
// Validate integrity of the raw data before saving (by making sure that
|
||||
// converting to and from the Settings format still produces a valid
|
||||
// file).
|
||||
Some((r, match save_to_file(copy, &path) {
|
||||
Ok(new_settings) => {
|
||||
*self = new_settings;
|
||||
Ok(())
|
||||
},
|
||||
Err(ErrorInternal::Io(err, new_settings)) => {
|
||||
warn!("Failed to save setting: {:?}", err);
|
||||
*self = new_settings;
|
||||
Err(Error::Io(err))
|
||||
},
|
||||
Err(ErrorInternal::Integrity(err)) => Err(Error::Integrity(err)),
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_path(data_dir: &Path) -> PathBuf {
|
||||
@ -66,24 +221,43 @@ pub trait EditableSetting: Serialize + DeserializeOwned + Default {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_to_file<S: Serialize>(setting: &S, path: &Path) -> std::io::Result<()> {
|
||||
fn save_to_file<S: EditableSetting>(setting: S, path: &Path) -> Result<S, ErrorInternal<S>> {
|
||||
let raw: <S as EditableSetting>::Setting = setting.into();
|
||||
let ron = ron::ser::to_string_pretty(&raw, ron::ser::PrettyConfig::default())
|
||||
.expect("RON does not throw any parse errors during serialization to string.");
|
||||
// This has the side effect of validating the copy, meaning it's safe to save
|
||||
// the file.
|
||||
let (_, settings): (Version, S) = raw.try_into().map_err(ErrorInternal::Integrity)?;
|
||||
// Create dir if it doesn't exist
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
if let Err(err) = fs::create_dir_all(dir) {
|
||||
return Err(ErrorInternal::Io(err, settings));
|
||||
}
|
||||
}
|
||||
// Atomically write the validated string to the settings file.
|
||||
let atomic_file = AtomicFile::new(path, OverwriteBehavior::AllowOverwrite);
|
||||
match atomic_file.write(|file| file.write_all(ron.as_bytes())) {
|
||||
Ok(()) => Ok(settings),
|
||||
Err(AtomicError::Internal(err)) | Err(AtomicError::User(err)) => {
|
||||
Err(ErrorInternal::Io(err, settings))
|
||||
},
|
||||
}
|
||||
|
||||
let ron = ron::ser::to_string_pretty(setting, ron::ser::PrettyConfig::default())
|
||||
.expect("Failed serialize setting.");
|
||||
|
||||
fs::write(path, ron.as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_and_save_default<S: EditableSetting>(path: &Path) -> S {
|
||||
let default = S::default();
|
||||
if let Err(e) = save_to_file(&default, path) {
|
||||
error!(?e, "Failed to create default setting file!");
|
||||
match save_to_file(default, path) {
|
||||
Ok(settings) => settings,
|
||||
Err(ErrorInternal::Io(e, settings)) => {
|
||||
error!(?e, "Failed to create default setting file!");
|
||||
settings
|
||||
},
|
||||
Err(ErrorInternal::Integrity(err)) => {
|
||||
panic!(
|
||||
"The default settings file must always be valid, but we found an integrity error: \
|
||||
{:?}",
|
||||
err
|
||||
);
|
||||
},
|
||||
}
|
||||
default
|
||||
}
|
||||
|
182
server/src/settings/server_description.rs
Normal file
182
server/src/settings/server_description.rs
Normal 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))
|
||||
}
|
||||
} */
|
||||
}
|
269
server/src/settings/whitelist.rs
Normal file
269
server/src/settings/whitelist.rs
Normal 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))
|
||||
}
|
||||
} */
|
||||
}
|
@ -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.
|
||||
}),
|
||||
)));
|
||||
|
@ -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"
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user