Added native operator permission management (#348)

* Starting work on Operator permission system

This commit adds the ops.json configuration found in vanilla
servers, and updates the basic configuration to handle the default
permission level.

Server now uses ops file and defaults players to op level 0

The /op command has been added but needs players to rejoin for now.

* Clippy Fix + need to Rejoin after /op removed

I found the source of the DeadLock. Updated set permission function.

Fix cargo formatting issues and clippy issues.

* Remove temp warn message

* Move OperatorConfig to server

As Snowiii pointed out, OperatorConfig is runtime data.

Revert most changes in pumpkin-config.

op_permission_level must say in the basic configuration for
parity with Minecraft.

* cargo fmt + cargo clippy

* Sync permission change with client

* Fixs issues @Commandcracker found in review

- Move PermissionLvl to core and removed OpLevel
- Move ops.json to /data/ops.json
- Fix permissions issue with /op command
- Shorten /op command description

* refactor into `data` folder

* create data dir when needed

* add to readme

* fix: conflicts

---------

Co-authored-by: Alexander Medvedev <lilalexmed@proton.me>
This commit is contained in:
Kyle Davis
2024-12-27 11:22:19 -05:00
committed by GitHub
parent c521ef8bb0
commit 6a5add8de5
25 changed files with 346 additions and 60 deletions

View File

@@ -79,6 +79,7 @@ and customizable experience. It prioritizes performance and player enjoyment whi
- [x] Particles
- [x] Chat
- [x] Commands
- [x] OP Permission
- Proxy
- [x] Bungeecord
- [x] Velocity

View File

@@ -2,6 +2,8 @@ services:
pumpkin:
build: .
ports:
- 25565:25565
- "25565:25565"
volumes:
- ./data:/pumpkin
stdin_open: true
tty: true

View File

@@ -7,5 +7,6 @@ edition.workspace = true
pumpkin-core = { path = "../pumpkin-core" }
serde.workspace = true
log.workspace = true
uuid.workspace = true
toml = "0.8"

View File

@@ -1,6 +1,6 @@
use log::warn;
use logging::LoggingConfig;
use pumpkin_core::{Difficulty, GameMode};
use pumpkin_core::{Difficulty, GameMode, PermissionLvl};
use query::QueryConfig;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
@@ -29,6 +29,7 @@ pub use server_links::ServerLinksConfig;
mod commands;
pub mod compression;
mod lan_broadcast;
pub mod op;
mod pvp;
mod rcon;
mod server_links;
@@ -79,6 +80,8 @@ pub struct BasicConfiguration {
pub simulation_distance: NonZeroU8,
/// The default game difficulty.
pub default_difficulty: Difficulty,
/// The op level assign by the /op command
pub op_permission_level: PermissionLvl,
/// Whether the Nether dimension is enabled.
pub allow_nether: bool,
/// Whether the server is in hardcore mode.
@@ -109,6 +112,7 @@ impl Default for BasicConfiguration {
view_distance: NonZeroU8::new(10).unwrap(),
simulation_distance: NonZeroU8::new(10).unwrap(),
default_difficulty: Difficulty::Normal,
op_permission_level: PermissionLvl::Four,
allow_nether: true,
hardcore: false,
online_mode: true,

27
pumpkin-config/src/op.rs Normal file
View File

@@ -0,0 +1,27 @@
use pumpkin_core::permission::PermissionLvl;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Op {
pub uuid: Uuid,
pub name: String,
pub level: PermissionLvl,
pub bypasses_player_limit: bool,
}
impl Op {
pub fn new(
uuid: Uuid,
name: String,
level: PermissionLvl,
bypasses_player_limit: bool,
) -> Self {
Self {
uuid,
name,
level,
bypasses_player_limit,
}
}
}

View File

@@ -1,9 +1,11 @@
pub mod gamemode;
pub mod math;
pub mod permission;
pub mod random;
pub mod text;
pub use gamemode::GameMode;
pub use permission::PermissionLvl;
use serde::{Deserialize, Serialize};

View File

@@ -0,0 +1,56 @@
use num_derive::{FromPrimitive, ToPrimitive};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Represents the player's permission level
///
/// Permission levels determine the player's access to commands and server operations.
/// Each numeric level corresponds to a specific role:
/// - `Zero`: `normal`: Player can use basic commands.
/// - `One`: `moderator`: Player can bypass spawn protection.
/// - `Two`: `gamemaster`: Player or executor can use more commands and player can use command blocks.
/// - `Three`: `admin`: Player or executor can use commands related to multiplayer management.
/// - `Four`: `owner`: Player or executor can use all of the commands, including commands related to server management.
#[derive(FromPrimitive, ToPrimitive, Clone, Copy, Default, PartialEq, Eq)]
#[repr(i8)]
pub enum PermissionLvl {
#[default]
Zero = 0,
One = 1,
Two = 2,
Three = 3,
Four = 4,
}
impl PartialOrd for PermissionLvl {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
(*self as u8).partial_cmp(&(*other as u8))
}
}
impl Serialize for PermissionLvl {
fn serialize<S>(&self, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where
S: Serializer,
{
serializer.serialize_u8(*self as u8)
}
}
impl<'de> Deserialize<'de> for PermissionLvl {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
match value {
0 => Ok(PermissionLvl::Zero),
2 => Ok(PermissionLvl::Two),
3 => Ok(PermissionLvl::Three),
4 => Ok(PermissionLvl::Four),
_ => Err(serde::de::Error::custom(format!(
"Invalid value for OpLevel: {}",
value
))),
}
}
}

View File

@@ -4,10 +4,11 @@ use crate::command::args::{ConsumedArgs, FindArg};
use crate::command::tree::CommandTree;
use crate::command::tree_builder::{argument, literal, require};
use crate::command::{CommandError, CommandExecutor, CommandSender};
use crate::entity::player::PermissionLvl;
use async_trait::async_trait;
use pumpkin_core::math::position::WorldPosition;
use pumpkin_core::math::vector3::Vector3;
use pumpkin_core::permission::PermissionLvl;
use pumpkin_core::text::TextComponent;
const NAMES: [&str; 1] = ["fill"];

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use crate::command::args::arg_gamemode::GamemodeArgumentConsumer;
use crate::command::args::GetCloned;
use crate::entity::player::PermissionLvl;
use crate::TextComponent;
use pumpkin_core::permission::PermissionLvl;
use crate::command::args::arg_players::PlayersArgumentConsumer;

View File

@@ -9,7 +9,7 @@ use crate::command::args::{ConsumedArgs, FindArg, FindArgDefaultName};
use crate::command::tree::CommandTree;
use crate::command::tree_builder::{argument, argument_default_name, require};
use crate::command::{CommandError, CommandExecutor, CommandSender};
use crate::entity::player::PermissionLvl;
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 1] = ["give"];

View File

@@ -49,6 +49,7 @@ impl CommandExecutor for KickExecutor {
}
}
// TODO: Permission
pub fn init_command_tree() -> CommandTree {
CommandTree::new(NAMES, DESCRIPTION)
.with_child(argument(ARG_TARGET, PlayersArgumentConsumer).execute(KickExecutor))

View File

@@ -0,0 +1,80 @@
use crate::{
command::{
args::{arg_players::PlayersArgumentConsumer, Arg, ConsumedArgs},
tree::CommandTree,
tree_builder::{argument, require},
CommandError, CommandExecutor, CommandSender,
},
data::{op_data::OPERATOR_CONFIG, SaveJSONConfiguration},
};
use async_trait::async_trait;
use pumpkin_config::{op::Op, BASIC_CONFIG};
use pumpkin_core::permission::PermissionLvl;
use pumpkin_core::text::TextComponent;
use CommandError::InvalidConsumption;
const NAMES: [&str; 1] = ["op"];
const DESCRIPTION: &str = "Grants operator status to a player.";
const ARG_TARGET: &str = "player";
struct OpExecutor;
#[async_trait]
impl CommandExecutor for OpExecutor {
async fn execute<'a>(
&self,
sender: &mut CommandSender<'a>,
server: &crate::server::Server,
args: &ConsumedArgs<'a>,
) -> Result<(), CommandError> {
let mut config = OPERATOR_CONFIG.write().await;
let Some(Arg::Players(targets)) = args.get(&ARG_TARGET) else {
return Err(InvalidConsumption(Some(ARG_TARGET.into())));
};
// log each player to the console.
for player in targets {
let new_level = if BASIC_CONFIG.op_permission_level > sender.permission_lvl() {
sender.permission_lvl()
} else {
BASIC_CONFIG.op_permission_level
};
let op_entry = Op::new(
player.gameprofile.id,
player.gameprofile.name.clone(),
new_level,
false,
);
if let Some(op) = config
.ops
.iter_mut()
.find(|o| o.uuid == player.gameprofile.id)
{
op.level = new_level;
} else {
config.ops.push(op_entry);
}
config.save();
player
.set_permission_lvl(new_level, &server.command_dispatcher)
.await;
let player_name = player.gameprofile.name.clone();
let message = format!("Made {player_name} a server operator.");
let msg = TextComponent::text(&message);
sender.send_message(msg).await;
}
Ok(())
}
}
pub fn init_command_tree() -> CommandTree {
CommandTree::new(NAMES, DESCRIPTION).with_child(
require(|sender| sender.has_permission_lvl(PermissionLvl::Three))
.with_child(argument(ARG_TARGET, PlayersArgumentConsumer).execute(OpExecutor)),
)
}

View File

@@ -2,15 +2,13 @@ use async_trait::async_trait;
use pumpkin_core::text::TextComponent;
use pumpkin_protocol::client::play::CSystemChatMessage;
use crate::{
command::{
args::{arg_message::MsgArgConsumer, Arg, ConsumedArgs},
tree::CommandTree,
tree_builder::{argument, require},
CommandError, CommandExecutor, CommandSender,
},
entity::player::PermissionLvl,
use crate::command::{
args::{arg_message::MsgArgConsumer, Arg, ConsumedArgs},
tree::CommandTree,
tree_builder::{argument, require},
CommandError, CommandExecutor, CommandSender,
};
use pumpkin_core::permission::PermissionLvl;
use CommandError::InvalidConsumption;
const NAMES: [&str; 1] = ["say"];

View File

@@ -2,8 +2,8 @@ use crate::command::tree_builder::require;
use crate::command::{
args::ConsumedArgs, tree::CommandTree, CommandError, CommandExecutor, CommandSender,
};
use crate::entity::player::PermissionLvl;
use async_trait::async_trait;
use pumpkin_core::permission::PermissionLvl;
use pumpkin_core::text::click::ClickEvent;
use pumpkin_core::text::hover::HoverEvent;
use pumpkin_core::text::{color::NamedColor, TextComponent};

View File

@@ -8,7 +8,7 @@ use crate::command::args::{ConsumedArgs, FindArg};
use crate::command::tree::CommandTree;
use crate::command::tree_builder::{argument, literal, require};
use crate::command::{CommandError, CommandExecutor, CommandSender};
use crate::entity::player::PermissionLvl;
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 1] = ["setblock"];

View File

@@ -6,7 +6,7 @@ use crate::command::args::ConsumedArgs;
use crate::command::tree::CommandTree;
use crate::command::tree_builder::require;
use crate::command::{CommandError, CommandExecutor, CommandSender};
use crate::entity::player::PermissionLvl;
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 1] = ["stop"];

View File

@@ -12,7 +12,7 @@ use crate::command::tree::CommandTree;
use crate::command::tree_builder::{argument, literal, require};
use crate::command::CommandError;
use crate::command::{CommandExecutor, CommandSender};
use crate::entity::player::PermissionLvl;
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 2] = ["teleport", "tp"];
const DESCRIPTION: &str = "Teleports entities, including players."; // todo

View File

@@ -5,13 +5,11 @@ use pumpkin_core::text::TextComponent;
use crate::command::args::arg_bounded_num::BoundedNumArgumentConsumer;
use crate::command::args::FindArgDefaultName;
use crate::command::tree_builder::{argument_default_name, literal};
use crate::{
command::{
tree::CommandTree, tree_builder::require, CommandError, CommandExecutor, CommandSender,
ConsumedArgs,
},
entity::player::PermissionLvl,
use crate::command::{
tree::CommandTree, tree_builder::require, CommandError, CommandExecutor, CommandSender,
ConsumedArgs,
};
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 1] = ["time"];

View File

@@ -13,7 +13,7 @@ use crate::command::tree_builder::{argument, argument_default_name, require};
use crate::command::{
args::ConsumedArgs, tree::CommandTree, CommandError, CommandExecutor, CommandSender,
};
use crate::entity::player::PermissionLvl;
use pumpkin_core::permission::PermissionLvl;
const NAMES: [&str; 1] = ["transfer"];

View File

@@ -7,6 +7,7 @@ pub mod cmd_help;
pub mod cmd_kick;
pub mod cmd_kill;
pub mod cmd_list;
pub mod cmd_op;
pub mod cmd_pumpkin;
pub mod cmd_say;
pub mod cmd_seed;

View File

@@ -4,17 +4,19 @@ use std::sync::Arc;
use crate::command::commands::cmd_seed;
use crate::command::commands::{cmd_bossbar, cmd_transfer};
use crate::command::dispatcher::CommandDispatcher;
use crate::entity::player::{PermissionLvl, Player};
use crate::entity::player::Player;
use crate::server::Server;
use crate::world::World;
use args::ConsumedArgs;
use async_trait::async_trait;
use commands::cmd_op;
use commands::{
cmd_clear, cmd_fill, cmd_gamemode, cmd_give, cmd_help, cmd_kick, cmd_kill, cmd_list,
cmd_pumpkin, cmd_say, cmd_setblock, cmd_stop, cmd_teleport, cmd_time, cmd_worldborder,
};
use dispatcher::CommandError;
use pumpkin_core::math::vector3::Vector3;
use pumpkin_core::permission::PermissionLvl;
use pumpkin_core::text::TextComponent;
pub mod args;
@@ -76,7 +78,7 @@ impl<'a> CommandSender<'a> {
pub fn permission_lvl(&self) -> PermissionLvl {
match self {
CommandSender::Console | CommandSender::Rcon(_) => PermissionLvl::Four,
CommandSender::Player(p) => p.permission_lvl(),
CommandSender::Player(p) => p.permission_lvl.load(),
}
}
@@ -84,7 +86,7 @@ impl<'a> CommandSender<'a> {
pub fn has_permission_lvl(&self, lvl: PermissionLvl) -> bool {
match self {
CommandSender::Console | CommandSender::Rcon(_) => true,
CommandSender::Player(p) => (p.permission_lvl() as i8) >= (lvl as i8),
CommandSender::Player(p) => p.permission_lvl.load().ge(&lvl),
}
}
@@ -128,6 +130,7 @@ pub fn default_dispatcher() -> CommandDispatcher {
dispatcher.register(cmd_seed::init_command_tree());
dispatcher.register(cmd_transfer::init_command_tree());
dispatcher.register(cmd_fill::init_command_tree());
dispatcher.register(cmd_op::init_command_tree());
dispatcher
}

88
pumpkin/src/data/mod.rs Normal file
View File

@@ -0,0 +1,88 @@
use std::{env, fs, path::Path};
use serde::{Deserialize, Serialize};
const DATA_FOLDER: &str = "data/";
pub mod op_data;
pub trait LoadJSONConfiguration {
#[must_use]
fn load() -> Self
where
Self: Sized + Default + Serialize + for<'de> Deserialize<'de>,
{
let exe_dir = env::current_dir().unwrap();
let data_dir = exe_dir.join(DATA_FOLDER);
if !data_dir.exists() {
log::debug!("creating new data root folder");
fs::create_dir(&data_dir).expect("Failed to create data root folder");
}
let path = data_dir.join(Self::get_path());
let config = if path.exists() {
let file_content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Couldn't read configuration file at {path:?}"));
serde_json::from_str(&file_content).unwrap_or_else(|err| {
panic!(
"Couldn't parse data config at {path:?}. Reason: {err}. This is probably caused by a config update. Just delete the old data config and restart.",
)
})
} else {
let content = Self::default();
if let Err(err) = fs::write(&path, serde_json::to_string_pretty(&content).unwrap()) {
log::error!(
"Couldn't write default data config to {path:?}. Reason: {err}. This is probably caused by a config update. Just delete the old data config and restart.",
);
}
content
};
config.validate();
config
}
fn get_path() -> &'static Path;
fn validate(&self);
}
pub trait SaveJSONConfiguration: LoadJSONConfiguration {
// suppress clippy warning
fn save(&self)
where
Self: Sized + Default + Serialize + for<'de> Deserialize<'de>,
{
let exe_dir = env::current_dir().unwrap();
let data_dir = exe_dir.join(DATA_FOLDER);
if !data_dir.exists() {
log::debug!("creating new data root folder");
fs::create_dir(&data_dir).expect("Failed to create data root folder");
}
let path = data_dir.join(Self::get_path());
let content = match serde_json::to_string_pretty(self) {
Ok(content) => content,
Err(err) => {
log::warn!(
"Couldn't serialize operator data config to {:?}. Reason: {}",
path,
err
);
return;
}
};
if let Err(err) = std::fs::write(&path, content) {
log::warn!(
"Couldn't write operator config to {:?}. Reason: {}",
path,
err
);
}
}
}

View File

@@ -0,0 +1,26 @@
use std::{path::Path, sync::LazyLock};
use pumpkin_config::op;
use serde::{Deserialize, Serialize};
use super::{LoadJSONConfiguration, SaveJSONConfiguration};
pub static OPERATOR_CONFIG: LazyLock<tokio::sync::RwLock<OperatorConfig>> =
LazyLock::new(|| tokio::sync::RwLock::new(OperatorConfig::load()));
#[derive(Deserialize, Serialize, Default)]
#[serde(transparent)]
pub struct OperatorConfig {
pub ops: Vec<op::Op>,
}
impl LoadJSONConfiguration for OperatorConfig {
fn get_path() -> &'static Path {
Path::new("ops.json")
}
fn validate(&self) {
// TODO: Validate the operator configuration
}
}
impl SaveJSONConfiguration for OperatorConfig {}

View File

@@ -8,7 +8,7 @@ use std::{
};
use crossbeam::atomic::AtomicCell;
use num_derive::{FromPrimitive, ToPrimitive};
use num_derive::FromPrimitive;
use num_traits::Pow;
use pumpkin_config::{ADVANCED_CONFIG, BASIC_CONFIG};
use pumpkin_core::{
@@ -18,6 +18,7 @@ use pumpkin_core::{
vector2::Vector2,
vector3::Vector3,
},
permission::PermissionLvl,
text::TextComponent,
GameMode,
};
@@ -54,11 +55,12 @@ use pumpkin_world::{
ItemStack,
},
};
use tokio::sync::{Mutex, Notify};
use tokio::sync::{Mutex, Notify, RwLock};
use super::Entity;
use crate::{error::PumpkinError, net::GameProfile};
use crate::{
command::{client_cmd_suggestions, dispatcher::CommandDispatcher},
data::op_data::OPERATOR_CONFIG,
net::{
combat::{self, player_attack_sound, AttackType},
Client, PlayerConfig,
@@ -66,6 +68,7 @@ use crate::{
server::Server,
world::World,
};
use crate::{error::PumpkinError, net::GameProfile};
use super::living::LivingEntity;
@@ -116,12 +119,10 @@ pub struct Player {
pub last_keep_alive_time: AtomicCell<Instant>,
/// Amount of ticks since last attack
pub last_attacked_ticks: AtomicU32,
/// The players op permission level
pub permission_lvl: AtomicCell<PermissionLvl>,
/// Tell tasks to stop if we are closing
cancel_tasks: Notify,
/// the players op permission level
permission_lvl: PermissionLvl,
}
impl Player {
@@ -143,6 +144,8 @@ impl Player {
},
|profile| profile,
);
let gameprofile_clone = gameprofile.clone();
let config = client.config.lock().await.clone().unwrap_or_default();
let bounding_box_size = BoundingBoxSize {
width: 0.6,
@@ -186,8 +189,17 @@ impl Player {
last_keep_alive_time: AtomicCell::new(std::time::Instant::now()),
last_attacked_ticks: AtomicU32::new(0),
cancel_tasks: Notify::new(),
// TODO: change this
permission_lvl: PermissionLvl::Four,
// Minecraft has no why to change the default permission level of new players.
// Minecrafts default permission level is 0
permission_lvl: OPERATOR_CONFIG
.read()
.await
.ops
.iter()
.find(|op| op.uuid == gameprofile_clone.id)
.map_or(AtomicCell::new(PermissionLvl::Zero), |op| {
AtomicCell::new(op.level)
}),
}
}
@@ -431,20 +443,20 @@ impl Player {
self.client
.send_packet(&CEntityStatus::new(
self.entity_id(),
24 + self.permission_lvl as i8,
24 + self.permission_lvl.load() as i8,
))
.await;
}
/// sets the players permission level and syncs it with the client
pub async fn set_permission_lvl(&mut self, lvl: PermissionLvl) {
self.permission_lvl = lvl;
pub async fn set_permission_lvl(
self: &Arc<Self>,
lvl: PermissionLvl,
command_dispatcher: &RwLock<CommandDispatcher>,
) {
self.permission_lvl.store(lvl);
self.send_permission_lvl_update().await;
}
/// get the players permission level
pub fn permission_lvl(&self) -> PermissionLvl {
self.permission_lvl
client_cmd_suggestions::send_c_commands_packet(self, command_dispatcher).await;
}
/// Sends the world time to just the player.
@@ -821,19 +833,3 @@ pub enum ChatMode {
/// All messages should be hidden
Hidden,
}
/// the player's permission level
#[derive(Debug, FromPrimitive, ToPrimitive, Clone, Copy)]
#[repr(i8)]
pub enum PermissionLvl {
/// `normal`: Player can use basic commands.
Zero = 0,
/// `moderator`: Player can bypass spawn protection.
One = 1,
/// `gamemaster`: Player or executor can use more commands and player can use command blocks.
Two = 2,
/// `admin`: Player or executor can use commands related to multiplayer management.
Three = 3,
/// `owner`: Player or executor can use all of the commands, including commands related to server management.
Four = 4,
}

View File

@@ -55,6 +55,7 @@ use std::time::Instant;
pub mod block;
pub mod command;
pub mod data;
pub mod entity;
pub mod error;
pub mod net;