feat: add Spawn Protection
This commit is contained in:
parent
927b7fccd7
commit
25d1ecc65a
@ -28,9 +28,20 @@ impl Default for ChatConfig {
|
|||||||
pub struct AntiSpamConfig {
|
pub struct AntiSpamConfig {
|
||||||
/// Whether anti-spam protection is enabled.
|
/// Whether anti-spam protection is enabled.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// The spam counter threshold at which a player will be kicked.
|
/// Legacy tick-based spam threshold. Kept for compatibility.
|
||||||
/// Vanilla default is 200 ticks.
|
|
||||||
pub spam_threshold: u32,
|
pub spam_threshold: u32,
|
||||||
|
/// Chat spam threshold in seconds. Overrides the legacy tick threshold when set.
|
||||||
|
#[serde(
|
||||||
|
alias = "chat-spam-threshold-seconds",
|
||||||
|
alias = "chat_spam_threshold_seconds"
|
||||||
|
)]
|
||||||
|
pub chat_spam_threshold_seconds: Option<u32>,
|
||||||
|
/// Command spam threshold in seconds. Overrides the legacy tick threshold when set.
|
||||||
|
#[serde(
|
||||||
|
alias = "command-spam-threshold-seconds",
|
||||||
|
alias = "command_spam_threshold_seconds"
|
||||||
|
)]
|
||||||
|
pub command_spam_threshold_seconds: Option<u32>,
|
||||||
/// The amount added to the spam counter for each chat message or command sent.
|
/// The amount added to the spam counter for each chat message or command sent.
|
||||||
/// Vanilla default is 20 ticks.
|
/// Vanilla default is 20 ticks.
|
||||||
pub message_cost: u32,
|
pub message_cost: u32,
|
||||||
@ -41,11 +52,29 @@ pub struct AntiSpamConfig {
|
|||||||
pub ops_bypass: bool,
|
pub ops_bypass: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AntiSpamConfig {
|
||||||
|
/// Resolves the effective chat threshold in ticks.
|
||||||
|
#[must_use]
|
||||||
|
pub fn chat_threshold_ticks(&self) -> u32 {
|
||||||
|
self.chat_spam_threshold_seconds
|
||||||
|
.map_or(self.spam_threshold, |seconds| seconds.saturating_mul(20))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the effective command threshold in ticks.
|
||||||
|
#[must_use]
|
||||||
|
pub fn command_threshold_ticks(&self) -> u32 {
|
||||||
|
self.command_spam_threshold_seconds
|
||||||
|
.map_or(self.spam_threshold, |seconds| seconds.saturating_mul(20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AntiSpamConfig {
|
impl Default for AntiSpamConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
spam_threshold: 200,
|
spam_threshold: 200,
|
||||||
|
chat_spam_threshold_seconds: None,
|
||||||
|
command_spam_threshold_seconds: None,
|
||||||
message_cost: 20,
|
message_cost: 20,
|
||||||
decay_per_tick: 1,
|
decay_per_tick: 1,
|
||||||
ops_bypass: true,
|
ops_bypass: true,
|
||||||
@ -63,6 +92,8 @@ mod tests {
|
|||||||
assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}");
|
assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}");
|
||||||
assert!(config.anti_spam.enabled);
|
assert!(config.anti_spam.enabled);
|
||||||
assert_eq!(config.anti_spam.spam_threshold, 200);
|
assert_eq!(config.anti_spam.spam_threshold, 200);
|
||||||
|
assert_eq!(config.anti_spam.chat_spam_threshold_seconds, None);
|
||||||
|
assert_eq!(config.anti_spam.command_spam_threshold_seconds, None);
|
||||||
assert_eq!(config.anti_spam.message_cost, 20);
|
assert_eq!(config.anti_spam.message_cost, 20);
|
||||||
assert_eq!(config.anti_spam.decay_per_tick, 1);
|
assert_eq!(config.anti_spam.decay_per_tick, 1);
|
||||||
assert!(config.anti_spam.ops_bypass);
|
assert!(config.anti_spam.ops_bypass);
|
||||||
@ -75,6 +106,8 @@ mod tests {
|
|||||||
[anti_spam]
|
[anti_spam]
|
||||||
enabled = false
|
enabled = false
|
||||||
spam_threshold = 100
|
spam_threshold = 100
|
||||||
|
chat-spam-threshold-seconds = 5
|
||||||
|
command-spam-threshold-seconds = 7
|
||||||
message_cost = 10
|
message_cost = 10
|
||||||
decay_per_tick = 2
|
decay_per_tick = 2
|
||||||
ops_bypass = false
|
ops_bypass = false
|
||||||
@ -83,8 +116,12 @@ mod tests {
|
|||||||
assert_eq!(config.format, "<{NAME}> {MESSAGE}");
|
assert_eq!(config.format, "<{NAME}> {MESSAGE}");
|
||||||
assert!(!config.anti_spam.enabled);
|
assert!(!config.anti_spam.enabled);
|
||||||
assert_eq!(config.anti_spam.spam_threshold, 100);
|
assert_eq!(config.anti_spam.spam_threshold, 100);
|
||||||
|
assert_eq!(config.anti_spam.chat_spam_threshold_seconds, Some(5));
|
||||||
|
assert_eq!(config.anti_spam.command_spam_threshold_seconds, Some(7));
|
||||||
assert_eq!(config.anti_spam.message_cost, 10);
|
assert_eq!(config.anti_spam.message_cost, 10);
|
||||||
assert_eq!(config.anti_spam.decay_per_tick, 2);
|
assert_eq!(config.anti_spam.decay_per_tick, 2);
|
||||||
assert!(!config.anti_spam.ops_bypass);
|
assert!(!config.anti_spam.ops_bypass);
|
||||||
|
assert_eq!(config.anti_spam.chat_threshold_ticks(), 100);
|
||||||
|
assert_eq!(config.anti_spam.command_threshold_ticks(), 140);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -216,6 +216,13 @@ pub struct BasicConfiguration {
|
|||||||
pub white_list: bool,
|
pub white_list: bool,
|
||||||
/// Whether to enforce the whitelist.
|
/// Whether to enforce the whitelist.
|
||||||
pub enforce_whitelist: bool,
|
pub enforce_whitelist: bool,
|
||||||
|
/// Whether this server accepts incoming transfers from other servers.
|
||||||
|
#[serde(alias = "accepts-transfers", alias = "accepts_transfers")]
|
||||||
|
pub accepts_transfers: bool,
|
||||||
|
/// The radius of the spawn protection area around the world spawn point.
|
||||||
|
/// Players without operator privileges cannot break or place blocks within this radius.
|
||||||
|
/// Set to 0 to disable spawn protection.
|
||||||
|
pub spawn_protection: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BasicConfiguration {
|
impl Default for BasicConfiguration {
|
||||||
@ -237,6 +244,8 @@ impl Default for BasicConfiguration {
|
|||||||
allow_chat_reports: false,
|
allow_chat_reports: false,
|
||||||
white_list: false,
|
white_list: false,
|
||||||
enforce_whitelist: false,
|
enforce_whitelist: false,
|
||||||
|
accepts_transfers: true,
|
||||||
|
spawn_protection: 16,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -279,6 +288,7 @@ pub trait LoadConfiguration {
|
|||||||
debug!("creating new config root folder");
|
debug!("creating new config root folder");
|
||||||
let _ = fs::create_dir(config_dir);
|
let _ = fs::create_dir(config_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = config_dir.join(Self::get_path());
|
let path = config_dir.join(Self::get_path());
|
||||||
|
|
||||||
let config = if path.exists() {
|
let config = if path.exists() {
|
||||||
@ -424,3 +434,25 @@ pub trait LoadConfiguration {
|
|||||||
/// Validates the configuration after loading or merging.
|
/// Validates the configuration after loading or merging.
|
||||||
fn validate(&self);
|
fn validate(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use toml::from_str;
|
||||||
|
|
||||||
|
use super::BasicConfiguration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_transfers_defaults_to_true() {
|
||||||
|
let config: BasicConfiguration = BasicConfiguration::default();
|
||||||
|
assert!(config.accepts_transfers);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_transfers_reads_vanilla_aliases() {
|
||||||
|
let config: BasicConfiguration = from_str("accepts-transfers = false").unwrap();
|
||||||
|
assert!(!config.accepts_transfers);
|
||||||
|
|
||||||
|
let config: BasicConfiguration = from_str("accepts_transfers = false").unwrap();
|
||||||
|
assert!(!config.accepts_transfers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -711,6 +711,17 @@ impl BlockRegistry {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if world.is_in_spawn_protection(player, &final_block_pos) {
|
||||||
|
player.send_system_message(&pumpkin_util::text::TextComponent::translate_cross(
|
||||||
|
pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION,
|
||||||
|
pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION,
|
||||||
|
[pumpkin_util::text::TextComponent::text(
|
||||||
|
player.gameprofile.name.clone(),
|
||||||
|
)],
|
||||||
|
));
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
if !self.can_place_at(
|
if !self.can_place_at(
|
||||||
Some(server),
|
Some(server),
|
||||||
Some(&*world),
|
Some(&*world),
|
||||||
|
|||||||
@ -388,6 +388,12 @@ pub enum PlayerWeather {
|
|||||||
Downfall,
|
Downfall,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SpamType {
|
||||||
|
Chat,
|
||||||
|
Command,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
/// The underlying living entity object that represents the player.
|
/// The underlying living entity object that represents the player.
|
||||||
pub living_entity: LivingEntity,
|
pub living_entity: LivingEntity,
|
||||||
@ -4006,11 +4012,7 @@ impl Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks whether sending a chat message or command constitutes spam.
|
/// Checks whether sending a chat message or command constitutes spam.
|
||||||
///
|
pub fn check_chat_spam(&self, server: &Server, spam_type: SpamType) -> bool {
|
||||||
/// Increments the player's spam counter by `message_cost`. If the counter
|
|
||||||
/// exceeds `spam_threshold`, the player is kicked with the vanilla
|
|
||||||
/// `disconnect.spam` message and this method returns `true`.
|
|
||||||
pub fn check_chat_spam(&self, server: &Server) -> bool {
|
|
||||||
let anti_spam = &server.advanced_config.chat.anti_spam;
|
let anti_spam = &server.advanced_config.chat.anti_spam;
|
||||||
if !anti_spam.enabled {
|
if !anti_spam.enabled {
|
||||||
return false;
|
return false;
|
||||||
@ -4020,15 +4022,20 @@ impl Player {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let threshold = match spam_type {
|
||||||
|
SpamType::Chat => anti_spam.chat_threshold_ticks(),
|
||||||
|
SpamType::Command => anti_spam.command_threshold_ticks(),
|
||||||
|
};
|
||||||
|
|
||||||
let new_count = self
|
let new_count = self
|
||||||
.chat_spam_tick_count
|
.chat_spam_tick_count
|
||||||
.fetch_add(anti_spam.message_cost, Ordering::SeqCst)
|
.fetch_add(anti_spam.message_cost, Ordering::SeqCst)
|
||||||
+ anti_spam.message_cost;
|
+ anti_spam.message_cost;
|
||||||
|
|
||||||
if new_count > anti_spam.spam_threshold {
|
if new_count > threshold {
|
||||||
warn!(
|
warn!(
|
||||||
"Player {} kicked for spamming (spam score: {}/{})",
|
"Player {} kicked for spamming (spam score: {}/{})",
|
||||||
self.gameprofile.name, new_count, anti_spam.spam_threshold
|
self.gameprofile.name, new_count, threshold
|
||||||
);
|
);
|
||||||
self.kick(
|
self.kick(
|
||||||
DisconnectReason::Kicked,
|
DisconnectReason::Kicked,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ impl BedrockClient {
|
|||||||
packet: SCommandRequest<'_>,
|
packet: SCommandRequest<'_>,
|
||||||
) {
|
) {
|
||||||
player.update_last_action_time();
|
player.update_last_action_time();
|
||||||
if player.check_chat_spam(server) {
|
if player.check_chat_spam(server, crate::entity::player::SpamType::Command) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let command = packet.command.strip_prefix('/').unwrap_or(&packet.command);
|
let command = packet.command.strip_prefix('/').unwrap_or(&packet.command);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ impl BedrockClient {
|
|||||||
packet: SText<'_>,
|
packet: SText<'_>,
|
||||||
) {
|
) {
|
||||||
player.update_last_action_time();
|
player.update_last_action_time();
|
||||||
if player.check_chat_spam(server) {
|
if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let gameprofile = &player.gameprofile;
|
let gameprofile = &player.gameprofile;
|
||||||
|
|||||||
@ -6,10 +6,11 @@ use pumpkin_protocol::{ConnectionState, java::server::handshake::SHandShake};
|
|||||||
use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion};
|
use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::net::java::pending::PendingConnection;
|
use crate::{net::java::pending::PendingConnection, server::Server};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
impl PendingConnection {
|
impl PendingConnection {
|
||||||
pub async fn handle_handshake(&mut self, handshake: SHandShake) {
|
pub async fn handle_handshake(&mut self, server: &Arc<Server>, handshake: SHandShake) {
|
||||||
let version = handshake.protocol_version.0 as u32;
|
let version = handshake.protocol_version.0 as u32;
|
||||||
self.server_address = handshake.server_address.to_string();
|
self.server_address = handshake.server_address.to_string();
|
||||||
self.version
|
self.version
|
||||||
@ -17,6 +18,17 @@ impl PendingConnection {
|
|||||||
|
|
||||||
debug!("Handshake: next state is {:?}", &handshake.next_state);
|
debug!("Handshake: next state is {:?}", &handshake.next_state);
|
||||||
self.connection_state.store(handshake.next_state);
|
self.connection_state.store(handshake.next_state);
|
||||||
|
if handshake.next_state == ConnectionState::Transfer
|
||||||
|
&& !server.basic_config.accepts_transfers
|
||||||
|
{
|
||||||
|
self.kick(TextComponent::translate_cross(
|
||||||
|
translation::java::MULTIPLAYER_DISCONNECT_TRANSFERS_DISABLED,
|
||||||
|
translation::java::MULTIPLAYER_DISCONNECT_TRANSFERS_DISABLED,
|
||||||
|
[],
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.connection_state.load() != ConnectionState::Status {
|
if self.connection_state.load() != ConnectionState::Status {
|
||||||
let protocol = version;
|
let protocol = version;
|
||||||
if protocol < LOWEST_SUPPORTED_MC_VERSION.protocol_version() as u32 {
|
if protocol < LOWEST_SUPPORTED_MC_VERSION.protocol_version() as u32 {
|
||||||
|
|||||||
@ -248,7 +248,7 @@ impl PendingConnection {
|
|||||||
packet: &RawPacket,
|
packet: &RawPacket,
|
||||||
) -> Result<Option<PacketHandlerResult>, ReadingError> {
|
) -> Result<Option<PacketHandlerResult>, ReadingError> {
|
||||||
match self.connection_state.load() {
|
match self.connection_state.load() {
|
||||||
ConnectionState::HandShake => self.handle_handshake_packet(packet).await,
|
ConnectionState::HandShake => self.handle_handshake_packet(server, packet).await,
|
||||||
ConnectionState::Status => self.handle_status_packet(server, packet).await,
|
ConnectionState::Status => self.handle_status_packet(server, packet).await,
|
||||||
ConnectionState::Login | ConnectionState::Transfer => {
|
ConnectionState::Login | ConnectionState::Transfer => {
|
||||||
self.handle_login_packet(server, packet).await
|
self.handle_login_packet(server, packet).await
|
||||||
@ -260,16 +260,20 @@ impl PendingConnection {
|
|||||||
|
|
||||||
async fn handle_handshake_packet(
|
async fn handle_handshake_packet(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
server: &Arc<Server>,
|
||||||
packet: &RawPacket,
|
packet: &RawPacket,
|
||||||
) -> Result<Option<PacketHandlerResult>, ReadingError> {
|
) -> Result<Option<PacketHandlerResult>, ReadingError> {
|
||||||
debug!("Handling handshake group");
|
debug!("Handling handshake group");
|
||||||
let mut payload = &packet.payload[..];
|
let mut payload = &packet.payload[..];
|
||||||
match packet.id {
|
match packet.id {
|
||||||
0 => {
|
0 => {
|
||||||
self.handle_handshake(pumpkin_protocol::java::server::handshake::SHandShake::read(
|
self.handle_handshake(
|
||||||
|
server,
|
||||||
|
pumpkin_protocol::java::server::handshake::SHandShake::read(
|
||||||
&mut payload,
|
&mut payload,
|
||||||
&self.version.load(),
|
&self.version.load(),
|
||||||
)?)
|
)?,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ impl JavaClient {
|
|||||||
command: &SChatCommand<'_>,
|
command: &SChatCommand<'_>,
|
||||||
) {
|
) {
|
||||||
player.update_last_action_time();
|
player.update_last_action_time();
|
||||||
if player.check_chat_spam(server) {
|
if player.check_chat_spam(server, crate::entity::player::SpamType::Command) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let command_str = command.command.strip_prefix('/').unwrap_or(command.command);
|
let command_str = command.command.strip_prefix('/').unwrap_or(command.command);
|
||||||
|
|||||||
@ -36,7 +36,7 @@ impl JavaClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if player.check_chat_spam(server) {
|
if player.check_chat_spam(server, crate::entity::player::SpamType::Chat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -547,6 +547,29 @@ impl World {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_in_spawn_protection(&self, player: &Player, position: &BlockPos) -> bool {
|
||||||
|
if player.permission_lvl.load() == pumpkin_util::permission::PermissionLvl::Four {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(server) = self.server.upgrade() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let radius = server.basic_config.spawn_protection;
|
||||||
|
if radius == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = i32::try_from(radius).unwrap_or(i32::MAX);
|
||||||
|
let spawn = self.get_spawn_location().0;
|
||||||
|
let dx = (spawn.0.x - position.0.x).abs();
|
||||||
|
let dz = (spawn.0.z - position.0.z).abs();
|
||||||
|
|
||||||
|
dx <= radius && dz <= radius
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
for entity in self.entities.load().iter() {
|
for entity in self.entities.load().iter() {
|
||||||
self.save_entity(entity).await;
|
self.save_entity(entity).await;
|
||||||
@ -5516,6 +5539,17 @@ impl World {
|
|||||||
cause: Option<&Arc<Player>>,
|
cause: Option<&Arc<Player>>,
|
||||||
flags: BlockFlags,
|
flags: BlockFlags,
|
||||||
) -> Option<BlockStateId> {
|
) -> Option<BlockStateId> {
|
||||||
|
if let Some(player) = cause
|
||||||
|
&& self.is_in_spawn_protection(player, position)
|
||||||
|
{
|
||||||
|
player.send_system_message(&TextComponent::translate_cross(
|
||||||
|
pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION,
|
||||||
|
pumpkin_data::translation::java::BUILD_SPAWN_PROTECTION,
|
||||||
|
[TextComponent::text(player.gameprofile.name.clone())],
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let (broken_block, broken_block_state) = self.get_block_and_state(position);
|
let (broken_block, broken_block_state) = self.get_block_and_state(position);
|
||||||
if broken_block_state.is_air() {
|
if broken_block_state.is_air() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user