From 423522f33e565079d7ed2f7b00668a098644ac04 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Tue, 18 Aug 2026 14:59:59 +0200 Subject: [PATCH] feat: chat spam prevention --- crates/pumpkin-config/src/chat.rs | 72 ++++++++++- crates/pumpkin-config/src/lib.rs | 2 +- .../java/client/login/encryption_request.rs | 47 ++++++++ .../src/java/client/login/set_compression.rs | 30 +++++ .../src/java/client/play/keep_alive.rs | 30 +++++ crates/pumpkin/src/entity/player.rs | 91 ++++++++++++++ crates/pumpkin/src/lib.rs | 31 +++-- crates/pumpkin/src/logging.rs | 52 ++++++++ crates/pumpkin/src/net/bedrock/nethernet.rs | 8 +- .../src/net/bedrock/play/chat_command.rs | 4 + .../src/net/bedrock/play/chat_message.rs | 4 + .../pumpkin/src/net/java/play/chat_command.rs | 3 + .../pumpkin/src/net/java/play/chat_message.rs | 4 + crates/pumpkin/src/server/mod.rs | 114 +++++++++++------- 14 files changed, 424 insertions(+), 68 deletions(-) diff --git a/crates/pumpkin-config/src/chat.rs b/crates/pumpkin-config/src/chat.rs index c4e4daf0b..c7bebfa56 100644 --- a/crates/pumpkin-config/src/chat.rs +++ b/crates/pumpkin-config/src/chat.rs @@ -2,19 +2,89 @@ use serde::{Deserialize, Serialize}; /// Configuration for in-game chat behaviour. /// -/// Controls chat formatting and display. +/// Controls chat formatting, display, and anti-spam protection. #[derive(Deserialize, Serialize)] #[serde(default)] pub struct ChatConfig { /// The custom chat format. /// `Note`: it does not apply when secure chat is enabled. pub format: String, + /// Anti-spam protection settings for player chat and commands. + pub anti_spam: AntiSpamConfig, } impl Default for ChatConfig { fn default() -> Self { Self { format: "<{DISPLAYNAME}> {MESSAGE}".to_string(), + anti_spam: AntiSpamConfig::default(), } } } + +/// Configuration for chat and command anti-spam protection. +#[derive(Deserialize, Serialize)] +#[serde(default)] +pub struct AntiSpamConfig { + /// Whether anti-spam protection is enabled. + pub enabled: bool, + /// The spam counter threshold at which a player will be kicked. + /// Vanilla default is 200 ticks. + pub spam_threshold: u32, + /// The amount added to the spam counter for each chat message or command sent. + /// Vanilla default is 20 ticks. + pub message_cost: u32, + /// The amount decayed from the spam counter per server tick. + /// Vanilla default is 1 tick. + pub decay_per_tick: u32, + /// Whether operators/admins bypass the anti-spam check. + pub ops_bypass: bool, +} + +impl Default for AntiSpamConfig { + fn default() -> Self { + Self { + enabled: true, + spam_threshold: 200, + message_cost: 20, + decay_per_tick: 1, + ops_bypass: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_chat_config() { + let config = ChatConfig::default(); + assert_eq!(config.format, "<{DISPLAYNAME}> {MESSAGE}"); + assert!(config.anti_spam.enabled); + assert_eq!(config.anti_spam.spam_threshold, 200); + assert_eq!(config.anti_spam.message_cost, 20); + assert_eq!(config.anti_spam.decay_per_tick, 1); + assert!(config.anti_spam.ops_bypass); + } + + #[test] + fn deserialize_custom_anti_spam() { + let toml_str = r#" + format = "<{NAME}> {MESSAGE}" + [anti_spam] + enabled = false + spam_threshold = 100 + message_cost = 10 + decay_per_tick = 2 + ops_bypass = false + "#; + let config: ChatConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.format, "<{NAME}> {MESSAGE}"); + assert!(!config.anti_spam.enabled); + assert_eq!(config.anti_spam.spam_threshold, 100); + assert_eq!(config.anti_spam.message_cost, 10); + assert_eq!(config.anti_spam.decay_per_tick, 2); + assert!(!config.anti_spam.ops_bypass); + } +} diff --git a/crates/pumpkin-config/src/lib.rs b/crates/pumpkin-config/src/lib.rs index c98f25262..216282b8d 100644 --- a/crates/pumpkin-config/src/lib.rs +++ b/crates/pumpkin-config/src/lib.rs @@ -29,7 +29,7 @@ pub mod recipe; /// Resource pack configuration options. pub mod resource_pack; -pub use chat::ChatConfig; +pub use chat::{AntiSpamConfig, ChatConfig}; pub use commands::{CommandOverride, CommandsConfig}; pub use networking::auth::AuthenticationConfig; pub use networking::bedrock::BedrockConfig; diff --git a/crates/pumpkin-protocol/src/java/client/login/encryption_request.rs b/crates/pumpkin-protocol/src/java/client/login/encryption_request.rs index 53a2c528b..1f4e94677 100644 --- a/crates/pumpkin-protocol/src/java/client/login/encryption_request.rs +++ b/crates/pumpkin-protocol/src/java/client/login/encryption_request.rs @@ -59,3 +59,50 @@ impl ClientPacket for CEncryptionRequest<'_> { Ok(()) } } + +impl<'a> crate::ServerPacket<'a> for CEncryptionRequest<'a> { + fn read( + read: &mut &'a [u8], + version: &JavaMinecraftVersion, + ) -> Result { + use crate::ser::{NetworkReadExt, NetworkReadSliceExt}; + let server_id = read.get_str_bounded_borrowed(20)?; + let public_key_len = read.get_var_int()?.0 as usize; + let public_key = read.read_slice_borrowed(public_key_len)?; + let verify_token_len = read.get_var_int()?.0 as usize; + let verify_token = read.read_slice_borrowed(verify_token_len)?; + let should_authenticate = if version >= &JavaMinecraftVersion::V_1_19 { + read.get_bool()? + } else { + true + }; + Ok(Self { + server_id, + public_key, + verify_token, + should_authenticate, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ServerPacket; + + #[test] + fn encryption_request_roundtrip() { + let packet = + CEncryptionRequest::new("test_server", b"public_key_bytes", b"verify_1234", true); + let mut buf = Vec::new(); + let version = JavaMinecraftVersion::V_1_21_4; + packet.write_packet_data(&mut buf, &version).unwrap(); + + let mut slice = buf.as_slice(); + let read_packet = CEncryptionRequest::read(&mut slice, &version).unwrap(); + assert_eq!(read_packet.server_id, packet.server_id); + assert_eq!(read_packet.public_key, packet.public_key); + assert_eq!(read_packet.verify_token, packet.verify_token); + assert_eq!(read_packet.should_authenticate, packet.should_authenticate); + } +} diff --git a/crates/pumpkin-protocol/src/java/client/login/set_compression.rs b/crates/pumpkin-protocol/src/java/client/login/set_compression.rs index 34715974f..51cc674a6 100644 --- a/crates/pumpkin-protocol/src/java/client/login/set_compression.rs +++ b/crates/pumpkin-protocol/src/java/client/login/set_compression.rs @@ -36,3 +36,33 @@ impl ClientPacket for CSetCompression { Ok(()) } } + +impl<'a> crate::ServerPacket<'a> for CSetCompression { + fn read( + read: &mut &'a [u8], + _version: &JavaMinecraftVersion, + ) -> Result { + use crate::ser::NetworkReadExt; + Ok(Self { + threshold: read.get_var_int()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ServerPacket; + + #[test] + fn set_compression_roundtrip() { + let packet = CSetCompression::new(crate::VarInt(256)); + let mut buf = Vec::new(); + let version = JavaMinecraftVersion::V_1_21_4; + packet.write_packet_data(&mut buf, &version).unwrap(); + + let mut slice = buf.as_slice(); + let read_packet = CSetCompression::read(&mut slice, &version).unwrap(); + assert_eq!(read_packet.threshold.0, 256); + } +} diff --git a/crates/pumpkin-protocol/src/java/client/play/keep_alive.rs b/crates/pumpkin-protocol/src/java/client/play/keep_alive.rs index 3b0c5eb1a..f2648e78e 100644 --- a/crates/pumpkin-protocol/src/java/client/play/keep_alive.rs +++ b/crates/pumpkin-protocol/src/java/client/play/keep_alive.rs @@ -35,3 +35,33 @@ impl ClientPacket for CKeepAlive { Ok(()) } } + +impl<'a> crate::ServerPacket<'a> for CKeepAlive { + fn read( + read: &mut &'a [u8], + _version: &JavaMinecraftVersion, + ) -> Result { + use crate::ser::NetworkReadExt; + Ok(Self { + keep_alive_id: read.get_i64_be()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ServerPacket; + + #[test] + fn keep_alive_roundtrip() { + let packet = CKeepAlive::new(1234567890123456789); + let mut buf = Vec::new(); + let version = JavaMinecraftVersion::V_1_21_4; + packet.write_packet_data(&mut buf, &version).unwrap(); + + let mut slice = buf.as_slice(); + let read_packet = CKeepAlive::read(&mut slice, &version).unwrap(); + assert_eq!(read_packet.keep_alive_id, 1234567890123456789); + } +} diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index 87938e03f..ee2138d4b 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -755,6 +755,8 @@ pub struct Player { pub bedrock_spawned: AtomicBool, /// The amount of time (in ticks) the client has to report having finished loading before being timed out. pub client_loaded_timeout: AtomicU32, + /// Counter for tracking chat and command spam. Decays each server tick. + pub chat_spam_tick_count: AtomicU32, /// Item usage tracking for bows, crossbows, etc. pub using_item: AtomicBool, pub item_use_start_time: AtomicI32, @@ -995,6 +997,7 @@ impl Player { client_loaded: AtomicBool::new(false), bedrock_spawned: AtomicBool::new(false), client_loaded_timeout: AtomicU32::new(60), + chat_spam_tick_count: AtomicU32::new(0), // Item usage tracking using_item: AtomicBool::new(false), item_use_start_time: AtomicI32::new(0), @@ -2498,6 +2501,16 @@ impl Player { self.tick_health().await; self.tick_maps(server).await; + // Anti-spam counter decay + let anti_spam = &server.advanced_config.chat.anti_spam; + if anti_spam.enabled && anti_spam.decay_per_tick > 0 { + let _ = self.chat_spam_tick_count.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |count| Some(count.saturating_sub(anti_spam.decay_per_tick)), + ); + } + // Timeout/keep alive handling self.tick_client_load_timeout(); // Idle timeout handling @@ -3503,6 +3516,46 @@ impl Player { self.last_action_time.store(std::time::Instant::now()); } + /// Checks whether sending a chat message or command constitutes spam. + /// + /// 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 async fn check_chat_spam(&self, server: &Server) -> bool { + let anti_spam = &server.advanced_config.chat.anti_spam; + if !anti_spam.enabled { + return false; + } + + if anti_spam.ops_bypass && self.permission_lvl.load() > PermissionLvl::Zero { + return false; + } + + let new_count = self + .chat_spam_tick_count + .fetch_add(anti_spam.message_cost, Ordering::SeqCst) + + anti_spam.message_cost; + + if new_count > anti_spam.spam_threshold { + warn!( + "Player {} kicked for spamming (spam score: {}/{})", + self.gameprofile.name, new_count, anti_spam.spam_threshold + ); + self.kick( + DisconnectReason::Kicked, + TextComponent::translate_cross( + translation::java::DISCONNECT_SPAM, + translation::bedrock::DISCONNECT_SPAM, + [], + ), + ) + .await; + return true; + } + + false + } + pub fn can_food_heal(&self) -> bool { let health = self.living_entity.health.load(); let max_health = self.living_entity.get_max_health(); @@ -6698,4 +6751,42 @@ mod tests { assert_eq!(read_root_vehicle(&nbt), Some(expected)); } + + #[test] + fn anti_spam_counter_decay_and_threshold() { + use std::sync::atomic::{AtomicU32, Ordering}; + let counter = AtomicU32::new(0); + let message_cost = 20u32; + let spam_threshold = 200u32; + let decay_per_tick = 1u32; + + // 10 messages -> count = 200 (within threshold) + for _ in 0..10 { + counter.fetch_add(message_cost, Ordering::SeqCst); + } + assert_eq!(counter.load(Ordering::SeqCst), 200); + assert!(counter.load(Ordering::SeqCst) <= spam_threshold); + + // 11th message -> count = 220 (exceeds threshold) + counter.fetch_add(message_cost, Ordering::SeqCst); + assert_eq!(counter.load(Ordering::SeqCst), 220); + assert!(counter.load(Ordering::SeqCst) > spam_threshold); + + // Simulate 25 ticks of decay + for _ in 0..25 { + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| { + Some(count.saturating_sub(decay_per_tick)) + }); + } + assert_eq!(counter.load(Ordering::SeqCst), 195); + assert!(counter.load(Ordering::SeqCst) <= spam_threshold); + + // Simulate decay down past 0 (saturating at 0) + for _ in 0..250 { + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| { + Some(count.saturating_sub(decay_per_tick)) + }); + } + assert_eq!(counter.load(Ordering::SeqCst), 0); + } } diff --git a/crates/pumpkin/src/lib.rs b/crates/pumpkin/src/lib.rs index a389ae83a..8df557a97 100644 --- a/crates/pumpkin/src/lib.rs +++ b/crates/pumpkin/src/lib.rs @@ -8,7 +8,9 @@ extern crate pumpkin_macros; use crate::crash::CrashReport; use crate::data::VanillaData; -use crate::logging::{GzipRollingLogger, PumpkinCommandCompleter, ReadlineLogWrapper}; +use crate::logging::{ + ConsoleWriter, GzipRollingLogger, PumpkinCommandCompleter, ReadlineLogWrapper, +}; use crate::net::bedrock::{ BedrockClient, nethernet::{NetherNetListener, load_or_create_identity_key}, @@ -69,7 +71,7 @@ pub type LoggerOption = Option<(ReadlineLogWrapper, LevelFilter, LoggingConfig)> pub static LOGGER_IMPL: LazyLock>> = LazyLock::new(|| Arc::new(OnceLock::new())); -#[expect(clippy::print_stderr)] +#[expect(clippy::print_stderr, clippy::too_many_lines)] pub fn init_logger(advanced_config: &AdvancedConfiguration) { use tracing_subscriber::EnvFilter; use tracing_subscriber::fmt; @@ -107,7 +109,7 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { }; let (logger, rl): ( - Box, + ConsoleWriter, Option>, ) = if advanced_config.commands.use_tty && stdin().is_terminal() { let rl_config = Config::builder() @@ -120,17 +122,21 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) { match Editor::with_config(rl_config) { Ok(mut rl) => { rl.set_helper(Some(helper)); - (Box::new(std::io::stdout()), Some(rl)) + let printer = rl.create_external_printer().ok().map(|p| { + let boxed: Box = Box::new(p); + boxed + }); + (ConsoleWriter::new(printer), Some(rl)) } Err(e) => { eprintln!( "Failed to initialize console input ({e}); falling back to simple logger" ); - (Box::new(std::io::stdout()), None) + (ConsoleWriter::new(None), None) } } } else { - (Box::new(std::io::stdout()), None) + (ConsoleWriter::new(None), None) }; let fmt_layer = fmt::layer() @@ -336,15 +342,8 @@ impl PumpkinServer { } }; let _ = server.bedrock_private_key.set(identity_key.clone()); - let oidc_verifier = if config.online_mode && config.authentication.enabled { - let Some((issuer, keys)) = server.bedrock_oidc_keys.get() else { - error!("Bedrock OIDC keys should be initialized before binding NetherNet"); - return None; - }; - Some(Arc::new((issuer.clone(), keys.clone()))) - } else { - None - }; + let oidc_verifier = (config.online_mode && config.authentication.enabled) + .then(|| server.bedrock_oidc_keys.clone()); match NetherNetListener::bind( config.nethernet.address, ice_socket, @@ -791,8 +790,8 @@ fn setup_console(mut rl: Editor, server: A &line, ) .await; - let _ = tx_reply.send(1).await; } + let _ = tx_reply.send(1).await; } else { break; } diff --git a/crates/pumpkin/src/logging.rs b/crates/pumpkin/src/logging.rs index 0571d0174..08c48293f 100644 --- a/crates/pumpkin/src/logging.rs +++ b/crates/pumpkin/src/logging.rs @@ -90,6 +90,58 @@ pub struct ReadlineLogWrapper { readline: std::sync::Mutex>>, } +pub struct ConsoleWriter { + printer: Option>, + buffer: Vec, +} + +impl ConsoleWriter { + #[must_use] + pub fn new(printer: Option>) -> Self { + Self { + printer, + buffer: Vec::new(), + } + } +} + +impl Write for ConsoleWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if let Some(ref mut printer) = self.printer { + self.buffer.extend_from_slice(buf); + while let Some(pos) = self.buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = self.buffer.drain(..=pos).collect(); + let msg = String::from_utf8_lossy(&line_bytes).into_owned(); + if printer.print(msg).is_err() { + let mut stdout = io::stdout().lock(); + let _ = stdout.write_all(line_bytes.as_slice()); + let _ = stdout.flush(); + } + } + Ok(buf.len()) + } else { + io::stdout().write(buf) + } + } + + fn flush(&mut self) -> io::Result<()> { + if let Some(ref mut printer) = self.printer { + if !self.buffer.is_empty() { + let buffer = std::mem::take(&mut self.buffer); + let msg = String::from_utf8_lossy(&buffer).into_owned(); + if printer.print(msg).is_err() { + let mut stdout = io::stdout().lock(); + let _ = stdout.write_all(buffer.as_slice()); + let _ = stdout.flush(); + } + } + Ok(()) + } else { + io::stdout().flush() + } + } +} + struct GzipRollingLoggerData { pub current_day_of_month: u8, pub last_rotate_time: time::OffsetDateTime, diff --git a/crates/pumpkin/src/net/bedrock/nethernet.rs b/crates/pumpkin/src/net/bedrock/nethernet.rs index a33e4b912..954f7a6bd 100644 --- a/crates/pumpkin/src/net/bedrock/nethernet.rs +++ b/crates/pumpkin/src/net/bedrock/nethernet.rs @@ -36,7 +36,7 @@ use pumpkin_util::p384::{ use serde_json::{Value, json}; use tokio::{ net::TcpListener, - sync::{Mutex, RwLock, mpsc}, + sync::{Mutex, OnceCell, RwLock, mpsc}, }; use tokio_util::sync::CancellationToken; use tracing::{debug, info, trace, warn}; @@ -83,7 +83,7 @@ struct EndpointState { incoming: mpsc::Sender, identity_key: Arc, require_client_identity: bool, - oidc_verifier: Option>, + oidc_verifier: Option>>, stun_servers: Arc<[String]>, ice_local_addr: SocketAddr, external_ip: Option, @@ -97,7 +97,7 @@ impl NetherNetListener { external_ip: Option, identity_key: Arc, require_client_identity: bool, - oidc_verifier: Option>, + oidc_verifier: Option>>, stun_servers: Vec, ) -> std::io::Result { let listener = TcpListener::bind(address).await?; @@ -269,7 +269,7 @@ async fn negotiate_inner( let (offer, client_public_key) = authenticate_client_offer( offer, state.require_client_identity, - state.oidc_verifier.as_deref(), + state.oidc_verifier.as_ref().and_then(|c| c.get()), )?; trace!( %address, diff --git a/crates/pumpkin/src/net/bedrock/play/chat_command.rs b/crates/pumpkin/src/net/bedrock/play/chat_command.rs index 961927cae..48801db05 100644 --- a/crates/pumpkin/src/net/bedrock/play/chat_command.rs +++ b/crates/pumpkin/src/net/bedrock/play/chat_command.rs @@ -8,6 +8,10 @@ impl BedrockClient { server: &Arc, packet: SCommandRequest<'_>, ) { + player.update_last_action_time(); + if player.check_chat_spam(server).await { + return; + } let player_clone = player.clone(); let server_clone = server.clone(); let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); diff --git a/crates/pumpkin/src/net/bedrock/play/chat_message.rs b/crates/pumpkin/src/net/bedrock/play/chat_message.rs index f209ca581..d710b2d4c 100644 --- a/crates/pumpkin/src/net/bedrock/play/chat_message.rs +++ b/crates/pumpkin/src/net/bedrock/play/chat_message.rs @@ -8,6 +8,10 @@ impl BedrockClient { player: &Arc, packet: SText<'_>, ) { + player.update_last_action_time(); + if player.check_chat_spam(server).await { + return; + } let gameprofile = &player.gameprofile; send_cancellable! {{ diff --git a/crates/pumpkin/src/net/java/play/chat_command.rs b/crates/pumpkin/src/net/java/play/chat_command.rs index 166894f9e..ceb793e13 100644 --- a/crates/pumpkin/src/net/java/play/chat_command.rs +++ b/crates/pumpkin/src/net/java/play/chat_command.rs @@ -9,6 +9,9 @@ impl JavaClient { command: &SChatCommand<'_>, ) { player.update_last_action_time(); + if player.check_chat_spam(server).await { + return; + } let player_clone = player.clone(); let server_clone = server.clone(); send_cancellable! {{ diff --git a/crates/pumpkin/src/net/java/play/chat_message.rs b/crates/pumpkin/src/net/java/play/chat_message.rs index c9aa75452..e9b557b12 100644 --- a/crates/pumpkin/src/net/java/play/chat_message.rs +++ b/crates/pumpkin/src/net/java/play/chat_message.rs @@ -30,6 +30,10 @@ impl JavaClient { return; } + if player.check_chat_spam(server).await { + return; + } + send_cancellable! {{ server; PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]); diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs index f70f55b69..9b6654a35 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -86,7 +86,7 @@ pub struct Server { /// Handles cryptographic keys for secure communication. key_store: OnceCell>, /// Bedrock OIDC provider keys, fetched on startup for 1.26.10+ token validation. - pub bedrock_oidc_keys: OnceCell<(String, pumpkin_util::jwt::Jwks)>, + pub bedrock_oidc_keys: Arc>, /// Cached Bedrock server private key (process-lifetime). Generated on first Bedrock login and reused. pub bedrock_private_key: OnceCell>, /// Manages server status information. @@ -241,21 +241,6 @@ impl Server { let tick_rate_manager = Arc::new(ServerTickRateManager::new(basic_config.tps)); - let mojang_keys_task = tokio::spawn({ - let auth_config = advanced_config.networking.java.authentication.clone(); - let allow_chat = basic_config.allow_chat_reports; - async move { - if allow_chat { - fetch_mojang_public_keys(&auth_config).unwrap_or_else(|e| { - error!("Failed to fetch Mojang keys: {e}"); - Vec::new() - }) - } else { - Vec::new() - } - } - }); - let dimensions = { let mut dimensions = vec![Dimension::OVERWORLD]; if basic_config.allow_nether { @@ -293,7 +278,7 @@ impl Server { block_registry: block_registry.clone(), item_registry: super::item::items::default_registry(), key_store: OnceCell::new(), - bedrock_oidc_keys: OnceCell::new(), + bedrock_oidc_keys: Arc::new(OnceCell::new()), bedrock_private_key: OnceCell::new(), listing, branding: CachedBranding::new(), @@ -328,14 +313,75 @@ impl Server { }), ); + // Fetch / generate keys in background tasks to avoid blocking startup let server_clone = server.clone(); tokio::spawn(async move { - server_clone - .key_store - .get_or_init(|| async { Arc::new(KeyStore::new()) }) - .await; + let key_store = tokio::task::spawn_blocking(|| Arc::new(KeyStore::new())) + .await + .unwrap_or_else(|_| Arc::new(KeyStore::new())); + let _ = server_clone.key_store.set(key_store); }); + if server.basic_config.allow_chat_reports { + let server_clone = server.clone(); + tokio::spawn(async move { + let auth_config = server_clone + .advanced_config + .networking + .java + .authentication + .clone(); + let keys = tokio::task::spawn_blocking(move || { + fetch_mojang_public_keys(&auth_config).unwrap_or_else(|e| { + error!("Failed to fetch Mojang keys: {e}"); + Vec::new() + }) + }) + .await + .unwrap_or_default(); + server_clone.mojang_public_keys.store(Arc::new(keys)); + }); + } + + if server.advanced_config.networking.bedrock.online_mode + && server + .advanced_config + .networking + .bedrock + .authentication + .enabled + { + let server_clone = server.clone(); + tokio::spawn(async move { + let auth = server_clone + .advanced_config + .networking + .bedrock + .authentication + .clone(); + let keys = match tokio::task::spawn_blocking(move || { + pumpkin_util::jwt::fetch_oidc_jwks( + auth.url.as_deref(), + auth.connect_timeout, + auth.read_timeout, + ) + }) + .await + { + Ok(Ok(keys)) => keys, + Ok(Err(error)) => { + error!("Failed to fetch Bedrock OIDC keys: {error}"); + (String::new(), pumpkin_util::jwt::Jwks { keys: Vec::new() }) + } + Err(join_err) => { + error!("Bedrock OIDC key task failed: {join_err}"); + (String::new(), pumpkin_util::jwt::Jwks { keys: Vec::new() }) + } + }; + let _ = server_clone.bedrock_oidc_keys.set(keys); + }); + } + let world_loader = |dim: Dimension| { let path = world_path.clone(); let registry = block_registry.clone(); @@ -365,8 +411,7 @@ impl Server { world_futures.push(world_loader(dim.clone())); } - let (worlds_results, keys) = - tokio::join!(futures::future::join_all(world_futures), mojang_keys_task); + let worlds_results = futures::future::join_all(world_futures).await; let mut worlds_vec = Vec::new(); for world in worlds_results.into_iter().flatten() { @@ -390,31 +435,8 @@ impl Server { } server.worlds.store(Arc::new(worlds_vec)); - if let Ok(k) = keys { - server.mojang_public_keys.store(Arc::new(k)); - } info!("All worlds loaded successfully."); - - if server.advanced_config.networking.bedrock.online_mode { - server - .bedrock_oidc_keys - .get_or_init(|| async { - tokio::task::block_in_place(|| { - let auth = &server.advanced_config.networking.bedrock.authentication; - pumpkin_util::jwt::fetch_oidc_jwks( - auth.url.as_deref(), - auth.connect_timeout, - auth.read_timeout, - ) - }) - .unwrap_or_else(|error| { - error!("Failed to fetch Bedrock OIDC keys: {error}"); - (String::new(), pumpkin_util::jwt::Jwks { keys: Vec::new() }) - }) - }) - .await; - } server }