feat: chat spam prevention

This commit is contained in:
Alexander Medvedev 2026-08-18 14:59:59 +02:00
parent 2816d7162e
commit 423522f33e
14 changed files with 424 additions and 68 deletions

View File

@ -2,19 +2,89 @@ use serde::{Deserialize, Serialize};
/// Configuration for in-game chat behaviour. /// Configuration for in-game chat behaviour.
/// ///
/// Controls chat formatting and display. /// Controls chat formatting, display, and anti-spam protection.
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct ChatConfig { pub struct ChatConfig {
/// The custom chat format. /// The custom chat format.
/// `Note`: it does not apply when secure chat is enabled. /// `Note`: it does not apply when secure chat is enabled.
pub format: String, pub format: String,
/// Anti-spam protection settings for player chat and commands.
pub anti_spam: AntiSpamConfig,
} }
impl Default for ChatConfig { impl Default for ChatConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
format: "<{DISPLAYNAME}> {MESSAGE}".to_string(), 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);
}
}

View File

@ -29,7 +29,7 @@ pub mod recipe;
/// Resource pack configuration options. /// Resource pack configuration options.
pub mod resource_pack; pub mod resource_pack;
pub use chat::ChatConfig; pub use chat::{AntiSpamConfig, ChatConfig};
pub use commands::{CommandOverride, CommandsConfig}; pub use commands::{CommandOverride, CommandsConfig};
pub use networking::auth::AuthenticationConfig; pub use networking::auth::AuthenticationConfig;
pub use networking::bedrock::BedrockConfig; pub use networking::bedrock::BedrockConfig;

View File

@ -59,3 +59,50 @@ impl ClientPacket for CEncryptionRequest<'_> {
Ok(()) Ok(())
} }
} }
impl<'a> crate::ServerPacket<'a> for CEncryptionRequest<'a> {
fn read(
read: &mut &'a [u8],
version: &JavaMinecraftVersion,
) -> Result<Self, crate::ReadingError> {
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);
}
}

View File

@ -36,3 +36,33 @@ impl ClientPacket for CSetCompression {
Ok(()) Ok(())
} }
} }
impl<'a> crate::ServerPacket<'a> for CSetCompression {
fn read(
read: &mut &'a [u8],
_version: &JavaMinecraftVersion,
) -> Result<Self, crate::ReadingError> {
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);
}
}

View File

@ -35,3 +35,33 @@ impl ClientPacket for CKeepAlive {
Ok(()) Ok(())
} }
} }
impl<'a> crate::ServerPacket<'a> for CKeepAlive {
fn read(
read: &mut &'a [u8],
_version: &JavaMinecraftVersion,
) -> Result<Self, crate::ReadingError> {
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);
}
}

View File

@ -755,6 +755,8 @@ pub struct Player {
pub bedrock_spawned: AtomicBool, pub bedrock_spawned: AtomicBool,
/// The amount of time (in ticks) the client has to report having finished loading before being timed out. /// The amount of time (in ticks) the client has to report having finished loading before being timed out.
pub client_loaded_timeout: AtomicU32, 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. /// Item usage tracking for bows, crossbows, etc.
pub using_item: AtomicBool, pub using_item: AtomicBool,
pub item_use_start_time: AtomicI32, pub item_use_start_time: AtomicI32,
@ -995,6 +997,7 @@ impl Player {
client_loaded: AtomicBool::new(false), client_loaded: AtomicBool::new(false),
bedrock_spawned: AtomicBool::new(false), bedrock_spawned: AtomicBool::new(false),
client_loaded_timeout: AtomicU32::new(60), client_loaded_timeout: AtomicU32::new(60),
chat_spam_tick_count: AtomicU32::new(0),
// Item usage tracking // Item usage tracking
using_item: AtomicBool::new(false), using_item: AtomicBool::new(false),
item_use_start_time: AtomicI32::new(0), item_use_start_time: AtomicI32::new(0),
@ -2498,6 +2501,16 @@ impl Player {
self.tick_health().await; self.tick_health().await;
self.tick_maps(server).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 // Timeout/keep alive handling
self.tick_client_load_timeout(); self.tick_client_load_timeout();
// Idle timeout handling // Idle timeout handling
@ -3503,6 +3516,46 @@ impl Player {
self.last_action_time.store(std::time::Instant::now()); 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 { pub fn can_food_heal(&self) -> bool {
let health = self.living_entity.health.load(); let health = self.living_entity.health.load();
let max_health = self.living_entity.get_max_health(); let max_health = self.living_entity.get_max_health();
@ -6698,4 +6751,42 @@ mod tests {
assert_eq!(read_root_vehicle(&nbt), Some(expected)); 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);
}
} }

View File

@ -8,7 +8,9 @@ extern crate pumpkin_macros;
use crate::crash::CrashReport; use crate::crash::CrashReport;
use crate::data::VanillaData; use crate::data::VanillaData;
use crate::logging::{GzipRollingLogger, PumpkinCommandCompleter, ReadlineLogWrapper}; use crate::logging::{
ConsoleWriter, GzipRollingLogger, PumpkinCommandCompleter, ReadlineLogWrapper,
};
use crate::net::bedrock::{ use crate::net::bedrock::{
BedrockClient, BedrockClient,
nethernet::{NetherNetListener, load_or_create_identity_key}, nethernet::{NetherNetListener, load_or_create_identity_key},
@ -69,7 +71,7 @@ pub type LoggerOption = Option<(ReadlineLogWrapper, LevelFilter, LoggingConfig)>
pub static LOGGER_IMPL: LazyLock<Arc<OnceLock<LoggerOption>>> = pub static LOGGER_IMPL: LazyLock<Arc<OnceLock<LoggerOption>>> =
LazyLock::new(|| Arc::new(OnceLock::new())); 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) { pub fn init_logger(advanced_config: &AdvancedConfiguration) {
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt; use tracing_subscriber::fmt;
@ -107,7 +109,7 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) {
}; };
let (logger, rl): ( let (logger, rl): (
Box<dyn std::io::Write + Send + 'static>, ConsoleWriter,
Option<Editor<PumpkinCommandCompleter, FileHistory>>, Option<Editor<PumpkinCommandCompleter, FileHistory>>,
) = if advanced_config.commands.use_tty && stdin().is_terminal() { ) = if advanced_config.commands.use_tty && stdin().is_terminal() {
let rl_config = Config::builder() let rl_config = Config::builder()
@ -120,17 +122,21 @@ pub fn init_logger(advanced_config: &AdvancedConfiguration) {
match Editor::with_config(rl_config) { match Editor::with_config(rl_config) {
Ok(mut rl) => { Ok(mut rl) => {
rl.set_helper(Some(helper)); rl.set_helper(Some(helper));
(Box::new(std::io::stdout()), Some(rl)) let printer = rl.create_external_printer().ok().map(|p| {
let boxed: Box<dyn rustyline::ExternalPrinter + Send> = Box::new(p);
boxed
});
(ConsoleWriter::new(printer), Some(rl))
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"Failed to initialize console input ({e}); falling back to simple logger" "Failed to initialize console input ({e}); falling back to simple logger"
); );
(Box::new(std::io::stdout()), None) (ConsoleWriter::new(None), None)
} }
} }
} else { } else {
(Box::new(std::io::stdout()), None) (ConsoleWriter::new(None), None)
}; };
let fmt_layer = fmt::layer() let fmt_layer = fmt::layer()
@ -336,15 +342,8 @@ impl PumpkinServer {
} }
}; };
let _ = server.bedrock_private_key.set(identity_key.clone()); let _ = server.bedrock_private_key.set(identity_key.clone());
let oidc_verifier = if config.online_mode && config.authentication.enabled { let oidc_verifier = (config.online_mode && config.authentication.enabled)
let Some((issuer, keys)) = server.bedrock_oidc_keys.get() else { .then(|| server.bedrock_oidc_keys.clone());
error!("Bedrock OIDC keys should be initialized before binding NetherNet");
return None;
};
Some(Arc::new((issuer.clone(), keys.clone())))
} else {
None
};
match NetherNetListener::bind( match NetherNetListener::bind(
config.nethernet.address, config.nethernet.address,
ice_socket, ice_socket,
@ -791,8 +790,8 @@ fn setup_console(mut rl: Editor<PumpkinCommandCompleter, FileHistory>, server: A
&line, &line,
) )
.await; .await;
let _ = tx_reply.send(1).await;
} }
let _ = tx_reply.send(1).await;
} else { } else {
break; break;
} }

View File

@ -90,6 +90,58 @@ pub struct ReadlineLogWrapper {
readline: std::sync::Mutex<Option<Editor<PumpkinCommandCompleter, FileHistory>>>, readline: std::sync::Mutex<Option<Editor<PumpkinCommandCompleter, FileHistory>>>,
} }
pub struct ConsoleWriter {
printer: Option<Box<dyn rustyline::ExternalPrinter + Send>>,
buffer: Vec<u8>,
}
impl ConsoleWriter {
#[must_use]
pub fn new(printer: Option<Box<dyn rustyline::ExternalPrinter + Send>>) -> Self {
Self {
printer,
buffer: Vec::new(),
}
}
}
impl Write for ConsoleWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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<u8> = 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 { struct GzipRollingLoggerData {
pub current_day_of_month: u8, pub current_day_of_month: u8,
pub last_rotate_time: time::OffsetDateTime, pub last_rotate_time: time::OffsetDateTime,

View File

@ -36,7 +36,7 @@ use pumpkin_util::p384::{
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::{ use tokio::{
net::TcpListener, net::TcpListener,
sync::{Mutex, RwLock, mpsc}, sync::{Mutex, OnceCell, RwLock, mpsc},
}; };
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, info, trace, warn}; use tracing::{debug, info, trace, warn};
@ -83,7 +83,7 @@ struct EndpointState {
incoming: mpsc::Sender<IncomingSession>, incoming: mpsc::Sender<IncomingSession>,
identity_key: Arc<SigningKey>, identity_key: Arc<SigningKey>,
require_client_identity: bool, require_client_identity: bool,
oidc_verifier: Option<Arc<(String, Jwks)>>, oidc_verifier: Option<Arc<OnceCell<(String, Jwks)>>>,
stun_servers: Arc<[String]>, stun_servers: Arc<[String]>,
ice_local_addr: SocketAddr, ice_local_addr: SocketAddr,
external_ip: Option<IpAddr>, external_ip: Option<IpAddr>,
@ -97,7 +97,7 @@ impl NetherNetListener {
external_ip: Option<IpAddr>, external_ip: Option<IpAddr>,
identity_key: Arc<SigningKey>, identity_key: Arc<SigningKey>,
require_client_identity: bool, require_client_identity: bool,
oidc_verifier: Option<Arc<(String, Jwks)>>, oidc_verifier: Option<Arc<OnceCell<(String, Jwks)>>>,
stun_servers: Vec<String>, stun_servers: Vec<String>,
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let listener = TcpListener::bind(address).await?; let listener = TcpListener::bind(address).await?;
@ -269,7 +269,7 @@ async fn negotiate_inner(
let (offer, client_public_key) = authenticate_client_offer( let (offer, client_public_key) = authenticate_client_offer(
offer, offer,
state.require_client_identity, state.require_client_identity,
state.oidc_verifier.as_deref(), state.oidc_verifier.as_ref().and_then(|c| c.get()),
)?; )?;
trace!( trace!(
%address, %address,

View File

@ -8,6 +8,10 @@ impl BedrockClient {
server: &Arc<Server>, server: &Arc<Server>,
packet: SCommandRequest<'_>, packet: SCommandRequest<'_>,
) { ) {
player.update_last_action_time();
if player.check_chat_spam(server).await {
return;
}
let player_clone = player.clone(); let player_clone = player.clone();
let server_clone = server.clone(); let server_clone = server.clone();
let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); let command = packet.command.strip_prefix('/').unwrap_or(&packet.command);

View File

@ -8,6 +8,10 @@ impl BedrockClient {
player: &Arc<Player>, player: &Arc<Player>,
packet: SText<'_>, packet: SText<'_>,
) { ) {
player.update_last_action_time();
if player.check_chat_spam(server).await {
return;
}
let gameprofile = &player.gameprofile; let gameprofile = &player.gameprofile;
send_cancellable! {{ send_cancellable! {{

View File

@ -9,6 +9,9 @@ impl JavaClient {
command: &SChatCommand<'_>, command: &SChatCommand<'_>,
) { ) {
player.update_last_action_time(); player.update_last_action_time();
if player.check_chat_spam(server).await {
return;
}
let player_clone = player.clone(); let player_clone = player.clone();
let server_clone = server.clone(); let server_clone = server.clone();
send_cancellable! {{ send_cancellable! {{

View File

@ -30,6 +30,10 @@ impl JavaClient {
return; return;
} }
if player.check_chat_spam(server).await {
return;
}
send_cancellable! {{ send_cancellable! {{
server; server;
PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]); PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]);

View File

@ -86,7 +86,7 @@ pub struct Server {
/// Handles cryptographic keys for secure communication. /// Handles cryptographic keys for secure communication.
key_store: OnceCell<Arc<KeyStore>>, key_store: OnceCell<Arc<KeyStore>>,
/// Bedrock OIDC provider keys, fetched on startup for 1.26.10+ token validation. /// 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<OnceCell<(String, pumpkin_util::jwt::Jwks)>>,
/// Cached Bedrock server private key (process-lifetime). Generated on first Bedrock login and reused. /// Cached Bedrock server private key (process-lifetime). Generated on first Bedrock login and reused.
pub bedrock_private_key: OnceCell<Arc<pumpkin_util::p384::ecdsa::SigningKey>>, pub bedrock_private_key: OnceCell<Arc<pumpkin_util::p384::ecdsa::SigningKey>>,
/// Manages server status information. /// Manages server status information.
@ -241,21 +241,6 @@ impl Server {
let tick_rate_manager = Arc::new(ServerTickRateManager::new(basic_config.tps)); 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 dimensions = {
let mut dimensions = vec![Dimension::OVERWORLD]; let mut dimensions = vec![Dimension::OVERWORLD];
if basic_config.allow_nether { if basic_config.allow_nether {
@ -293,7 +278,7 @@ impl Server {
block_registry: block_registry.clone(), block_registry: block_registry.clone(),
item_registry: super::item::items::default_registry(), item_registry: super::item::items::default_registry(),
key_store: OnceCell::new(), key_store: OnceCell::new(),
bedrock_oidc_keys: OnceCell::new(), bedrock_oidc_keys: Arc::new(OnceCell::new()),
bedrock_private_key: OnceCell::new(), bedrock_private_key: OnceCell::new(),
listing, listing,
branding: CachedBranding::new(), branding: CachedBranding::new(),
@ -328,14 +313,75 @@ impl Server {
}), }),
); );
// Fetch / generate keys in background tasks to avoid blocking startup
let server_clone = server.clone(); let server_clone = server.clone();
tokio::spawn(async move { tokio::spawn(async move {
server_clone let key_store = tokio::task::spawn_blocking(|| Arc::new(KeyStore::new()))
.key_store .await
.get_or_init(|| async { Arc::new(KeyStore::new()) }) .unwrap_or_else(|_| Arc::new(KeyStore::new()));
.await; 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 world_loader = |dim: Dimension| {
let path = world_path.clone(); let path = world_path.clone();
let registry = block_registry.clone(); let registry = block_registry.clone();
@ -365,8 +411,7 @@ impl Server {
world_futures.push(world_loader(dim.clone())); world_futures.push(world_loader(dim.clone()));
} }
let (worlds_results, keys) = let worlds_results = futures::future::join_all(world_futures).await;
tokio::join!(futures::future::join_all(world_futures), mojang_keys_task);
let mut worlds_vec = Vec::new(); let mut worlds_vec = Vec::new();
for world in worlds_results.into_iter().flatten() { for world in worlds_results.into_iter().flatten() {
@ -390,31 +435,8 @@ impl Server {
} }
server.worlds.store(Arc::new(worlds_vec)); 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."); 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 server
} }