diff --git a/README.md b/README.md index b18888dc..106ab218 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ and customizable experience. It prioritizes performance and player enjoyment whi - [x] Player Combat - Server - [ ] Plugins - - [ ] Query + - [x] Query - [x] RCON - [x] Inventories - [x] Particles diff --git a/pumpkin-config/src/lib.rs b/pumpkin-config/src/lib.rs index 3295b186..e7f4f4ee 100644 --- a/pumpkin-config/src/lib.rs +++ b/pumpkin-config/src/lib.rs @@ -1,6 +1,7 @@ use log::warn; use logging::LoggingConfig; use pumpkin_core::{Difficulty, GameMode}; +use query::QueryConfig; use serde::{de::DeserializeOwned, Deserialize, Serialize}; // TODO: when https://github.com/rust-lang/rfcs/pull/3681 gets merged, replace serde-inline-default with native syntax @@ -16,6 +17,7 @@ use std::{ pub mod auth; pub mod logging; pub mod proxy; +pub mod query; pub mod resource_pack; pub use auth::AuthenticationConfig; @@ -53,6 +55,7 @@ pub struct AdvancedConfiguration { pub rcon: RCONConfig, pub pvp: PVPConfig, pub logging: LoggingConfig, + pub query: QueryConfig, } #[serde_inline_default] diff --git a/pumpkin-config/src/query.rs b/pumpkin-config/src/query.rs new file mode 100644 index 00000000..86ce0a1e --- /dev/null +++ b/pumpkin-config/src/query.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use serde_inline_default::serde_inline_default; + +#[serde_inline_default] +#[derive(Deserialize, Serialize, Default)] +pub struct QueryConfig { + #[serde_inline_default(false)] + pub enabled: bool, + // Optional so if not specified the port server is running on will be used + #[serde_inline_default(None)] + pub port: Option, +} diff --git a/pumpkin-protocol/Cargo.toml b/pumpkin-protocol/Cargo.toml index abb54fc8..0f0ca1da 100644 --- a/pumpkin-protocol/Cargo.toml +++ b/pumpkin-protocol/Cargo.toml @@ -14,7 +14,7 @@ serde.workspace = true thiserror.workspace = true itertools.workspace = true log.workspace = true - +tokio.workspace = true num-traits.workspace = true num-derive.workspace = true diff --git a/pumpkin-protocol/src/lib.rs b/pumpkin-protocol/src/lib.rs index 5def3df3..bfaf7983 100644 --- a/pumpkin-protocol/src/lib.rs +++ b/pumpkin-protocol/src/lib.rs @@ -6,6 +6,7 @@ pub mod bytebuf; pub mod client; pub mod packet_decoder; pub mod packet_encoder; +pub mod query; pub mod server; pub mod slot; diff --git a/pumpkin-protocol/src/query.rs b/pumpkin-protocol/src/query.rs new file mode 100644 index 00000000..8943d91e --- /dev/null +++ b/pumpkin-protocol/src/query.rs @@ -0,0 +1,350 @@ +use std::{ffi::CString, io::Cursor}; + +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(FromPrimitive)] +#[repr(u8)] +pub enum PacketType { + // There could be other types but they are not documented + // Besides these types are enough to get server status + Handshake = 9, + Status = 0, +} + +pub struct RawQueryPacket { + pub packet_type: PacketType, + reader: Cursor>, +} + +impl RawQueryPacket { + pub async fn decode(bytes: Vec) -> Result { + let mut reader = Cursor::new(bytes); + + match reader.read_u16().await.map_err(|_| ())? { + // Magic should always equal 65277 + // Since it denotes the protocl being used + // Should not attempt to decode packets with other magic values + 65277 => Ok(Self { + packet_type: PacketType::from_u8(reader.read_u8().await.map_err(|_| ())?) + .ok_or(())?, + reader, + }), + _ => Err(()), + } + } +} + +#[derive(PartialEq, Debug)] +pub struct SHandshake { + pub session_id: i32, +} + +impl SHandshake { + pub async fn decode(packet: &mut RawQueryPacket) -> Result { + Ok(Self { + session_id: packet.reader.read_i32().await.map_err(|_| ())?, + }) + } +} + +#[derive(PartialEq, Debug)] +pub struct SStatusRequest { + pub session_id: i32, + pub challange_token: i32, + // Full status request and basic status request are pretty much similar + // So might as just use the same struct + pub is_full_request: bool, +} + +impl SStatusRequest { + pub async fn decode(packet: &mut RawQueryPacket) -> Result { + Ok(Self { + session_id: packet.reader.read_i32().await.map_err(|_| ())?, + challange_token: packet.reader.read_i32().await.map_err(|_| ())?, + is_full_request: { + let mut buf = [0; 4]; + + // If payload is padded to 8 bytes, the client is requesting full status response + // In other terms, check if there are 4 extra bytes at the end + // The extra bytes should be meaningless + // Otherwise the client is requesting basic status response + match packet.reader.read(&mut buf).await { + Ok(0) => false, + Ok(4) => true, + _ => { + // Just ingnore malformed packets or errors + return Err(()); + } + } + }, + }) + } +} + +pub struct CHandshake { + pub session_id: i32, + // For simplicity use a number type + // Should be encoded as string here + // Will be converted in encoding + pub challange_token: i32, +} + +impl CHandshake { + pub async fn encode(&self) -> Vec { + let mut buf = Vec::new(); + + // Packet Type + buf.write_u8(9).await.unwrap(); + // Session ID + buf.write_i32(self.session_id).await.unwrap(); + // Challange token + // Use CString to add null terminator and ensure no null bytes in the middle of data + // Unwrap here since there should be no errors with nulls in the middle of data + let token = CString::new(self.challange_token.to_string()).unwrap(); + buf.extend_from_slice(token.as_bytes_with_nul()); + + buf + } +} + +pub struct CBasicStatus { + pub session_id: i32, + // Use CString as protocol requires nul terminated strings + pub motd: CString, + // Game type is hardcoded + pub map: CString, + pub num_players: usize, + pub max_players: usize, + pub host_port: u16, + pub host_ip: CString, +} + +impl CBasicStatus { + pub async fn encode(&self) -> Vec { + let mut buf = Vec::new(); + + // Packet Type + buf.write_u8(0).await.unwrap(); + // Session ID + buf.write_i32(self.session_id).await.unwrap(); + // MOTD + buf.extend_from_slice(self.motd.as_bytes_with_nul()); + // Game Type + let game_type = CString::new("SMP").unwrap(); + buf.extend_from_slice(game_type.as_bytes_with_nul()); + // Map + buf.extend_from_slice(self.map.as_bytes_with_nul()); + // Num players + let num_players = CString::new(self.num_players.to_string()).unwrap(); + buf.extend_from_slice(num_players.as_bytes_with_nul()); + // Max players + let max_players = CString::new(self.max_players.to_string()).unwrap(); + buf.extend_from_slice(max_players.as_bytes_with_nul()); + // Port + // No idea why the port needs to be in little endian + buf.write_u16_le(self.host_port).await.unwrap(); + // IP + buf.extend_from_slice(self.host_ip.as_bytes_with_nul()); + + buf + } +} + +pub struct CFullStatus { + pub session_id: i32, + pub hostname: CString, + // Game type and game id are hardcoded into protocol + // They are not here as they cannot be changed + pub version: CString, + pub plugins: CString, + pub map: CString, + pub num_players: usize, + pub max_players: usize, + pub host_port: u16, + pub host_ip: CString, + pub players: Vec, +} + +impl CFullStatus { + pub async fn encode(&self) -> Vec { + let mut buf = Vec::new(); + + // Packet type + buf.write_u8(0).await.unwrap(); + // Session ID + buf.write_i32(self.session_id).await.unwrap(); + + // Padding (11 bytes, meaningless) + // This is the padding used by vanilla + // Although meaningless, in testing some query checkers depend on these bytes? + const PADDING_START: [u8; 11] = [ + 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00, + ]; + buf.extend_from_slice(PADDING_START.as_slice()); + + // Key-value pairs + // Keys will not error when encoding as CString + for (key, value) in [ + ("hostname", &self.hostname), + ("gametype", &CString::new("SMP").unwrap()), + ("game_id", &CString::new("MINECRAFT").unwrap()), + ("version", &self.version), + ("plugins", &self.plugins), + ("map", &self.map), + ( + "numplayers", + &CString::new(self.num_players.to_string()).unwrap(), + ), + ( + "maxplayers", + &CString::new(self.max_players.to_string()).unwrap(), + ), + ( + "hostport", + &CString::new(self.host_port.to_string()).unwrap(), + ), + ("hostip", &self.host_ip), + ] { + buf.extend_from_slice(CString::new(key).unwrap().as_bytes_with_nul()); + buf.extend_from_slice(value.as_bytes_with_nul()); + } + + // Padding (10 bytes, meaningless), with one extra 0x00 for the extra required null terminator after the Key Value section + const PADDING_END: [u8; 11] = [ + 0x00, 0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00, + ]; + buf.extend_from_slice(PADDING_END.as_slice()); + + // Players + for player in &self.players { + buf.extend_from_slice(player.as_bytes_with_nul()); + } + // Required extra null terminator + buf.write_u8(0).await.unwrap(); + + buf + } +} + +// All test bytes/packets are from protocol documentation +#[tokio::test] +async fn test_handshake_request() { + let bytes = vec![0xFE, 0xFD, 0x09, 0x00, 0x00, 0x00, 0x01]; + let mut raw_packet = RawQueryPacket::decode(bytes).await.unwrap(); + let packet = SHandshake::decode(&mut raw_packet).await.unwrap(); + + // What the decoded packet should look like + let actual_packet = SHandshake { session_id: 1 }; + + assert_eq!(packet, actual_packet); +} + +#[tokio::test] +async fn test_handshake_response() { + let bytes = vec![ + 0x09, 0x00, 0x00, 0x00, 0x01, 0x39, 0x35, 0x31, 0x33, 0x33, 0x30, 0x37, 0x00, + ]; + + let packet = CHandshake { + session_id: 1, + challange_token: 9513307, + }; + + assert_eq!(bytes, packet.encode().await) +} + +#[tokio::test] +async fn test_basic_stat_request() { + let bytes = vec![ + 0xFE, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x91, 0x29, 0x5B, + ]; + let mut raw_packet = RawQueryPacket::decode(bytes).await.unwrap(); + let packet = SStatusRequest::decode(&mut raw_packet).await.unwrap(); + + let actual_packet = SStatusRequest { + session_id: 1, + challange_token: 9513307, + is_full_request: false, + }; + + assert_eq!(packet, actual_packet); +} + +#[tokio::test] +async fn test_basic_stat_response() { + let bytes = vec![ + 0x00, 0x00, 0x00, 0x00, 0x01, 0x41, 0x20, 0x4D, 0x69, 0x6E, 0x65, 0x63, 0x72, 0x61, 0x66, + 0x74, 0x20, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x00, 0x53, 0x4D, 0x50, 0x00, 0x77, 0x6F, + 0x72, 0x6C, 0x64, 0x00, 0x32, 0x00, 0x32, 0x30, 0x00, 0xDD, 0x63, 0x31, 0x32, 0x37, 0x2E, + 0x30, 0x2E, 0x30, 0x2E, 0x31, 0x00, + ]; + + let packet = CBasicStatus { + session_id: 1, + motd: CString::new("A Minecraft Server").unwrap(), + map: CString::new("world").unwrap(), + num_players: 2, + max_players: 20, + host_port: 25565, + host_ip: CString::new("127.0.0.1").unwrap(), + }; + + assert_eq!(bytes, packet.encode().await); +} + +#[tokio::test] +async fn test_full_stat_request() { + let bytes = vec![ + 0xFE, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x91, 0x29, 0x5B, 0x00, 0x00, 0x00, 0x00, + ]; + let mut raw_packet = RawQueryPacket::decode(bytes).await.unwrap(); + let packet = SStatusRequest::decode(&mut raw_packet).await.unwrap(); + + let actual_packet = SStatusRequest { + session_id: 1, + challange_token: 9513307, + is_full_request: true, + }; + + assert_eq!(packet, actual_packet); +} +#[tokio::test] +async fn test_full_stat_response() { + let bytes = vec![ + 0x00, 0x00, 0x00, 0x00, 0x01, 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, + 0x00, 0x68, 0x6F, 0x73, 0x74, 0x6E, 0x61, 0x6D, 0x65, 0x00, 0x41, 0x20, 0x4D, 0x69, 0x6E, + 0x65, 0x63, 0x72, 0x61, 0x66, 0x74, 0x20, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x00, 0x67, + 0x61, 0x6D, 0x65, 0x74, 0x79, 0x70, 0x65, 0x00, 0x53, 0x4D, 0x50, 0x00, 0x67, 0x61, 0x6D, + 0x65, 0x5F, 0x69, 0x64, 0x00, 0x4D, 0x49, 0x4E, 0x45, 0x43, 0x52, 0x41, 0x46, 0x54, 0x00, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6F, 0x6E, 0x00, 0x42, 0x65, 0x74, 0x61, 0x20, 0x31, 0x2E, + 0x39, 0x20, 0x50, 0x72, 0x65, 0x72, 0x65, 0x6C, 0x65, 0x61, 0x73, 0x65, 0x20, 0x34, 0x00, + 0x70, 0x6C, 0x75, 0x67, 0x69, 0x6E, 0x73, 0x00, 0x00, 0x6D, 0x61, 0x70, 0x00, 0x77, 0x6F, + 0x72, 0x6C, 0x64, 0x00, 0x6E, 0x75, 0x6D, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x73, 0x00, + 0x32, 0x00, 0x6D, 0x61, 0x78, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x73, 0x00, 0x32, 0x30, + 0x00, 0x68, 0x6F, 0x73, 0x74, 0x70, 0x6F, 0x72, 0x74, 0x00, 0x32, 0x35, 0x35, 0x36, 0x35, + 0x00, 0x68, 0x6F, 0x73, 0x74, 0x69, 0x70, 0x00, 0x31, 0x32, 0x37, 0x2E, 0x30, 0x2E, 0x30, + 0x2E, 0x31, 0x00, 0x00, 0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00, 0x62, + 0x61, 0x72, 0x6E, 0x65, 0x79, 0x67, 0x61, 0x6C, 0x65, 0x00, 0x56, 0x69, 0x76, 0x61, 0x6C, + 0x61, 0x68, 0x65, 0x6C, 0x76, 0x69, 0x67, 0x00, 0x00, + ]; + + let packet = CFullStatus { + session_id: 1, + hostname: CString::new("A Minecraft Server").unwrap(), + version: CString::new("Beta 1.9 Prerelease 4").unwrap(), + plugins: CString::new("").unwrap(), + map: CString::new("world").unwrap(), + num_players: 2, + max_players: 20, + host_port: 25565, + host_ip: CString::new("127.0.0.1").unwrap(), + players: vec![ + CString::new("barneygale").unwrap(), + CString::new("Vivalahelvig").unwrap(), + ], + }; + + assert_eq!(bytes, packet.encode().await); +} diff --git a/pumpkin/src/main.rs b/pumpkin/src/main.rs index 8b7ca607..33ac0f53 100644 --- a/pumpkin/src/main.rs +++ b/pumpkin/src/main.rs @@ -44,6 +44,7 @@ pub mod command; pub mod entity; pub mod error; pub mod proxy; +pub mod query; pub mod rcon; pub mod server; pub mod world; @@ -117,10 +118,13 @@ async fn main() -> io::Result<()> { let time = Instant::now(); // Setup the TCP server socket. - let addr = BASIC_CONFIG.server_address; - let listener = tokio::net::TcpListener::bind(addr) + let listener = tokio::net::TcpListener::bind(BASIC_CONFIG.server_address) .await .expect("Failed to start TcpListener"); + // In the event the user puts 0 for their port, this will allow us to know what port it is running on + let addr = listener + .local_addr() + .expect("Unable to get the address of server!"); let use_console = ADVANCED_CONFIG.commands.use_console; let rcon = ADVANCED_CONFIG.rcon.clone(); @@ -140,6 +144,12 @@ async fn main() -> io::Result<()> { RCONServer::new(&rcon, server).await.unwrap(); }); } + + if ADVANCED_CONFIG.query.enabled { + log::info!("Query protocol enabled. Starting..."); + tokio::spawn(query::start_query_handler(server.clone(), addr)); + } + { let server = server.clone(); tokio::spawn(async move { diff --git a/pumpkin/src/query.rs b/pumpkin/src/query.rs new file mode 100644 index 00000000..645fbfe2 --- /dev/null +++ b/pumpkin/src/query.rs @@ -0,0 +1,173 @@ +use std::{ + collections::HashMap, + ffi::{CString, NulError}, + net::SocketAddr, + sync::Arc, + time::Duration, +}; + +use pumpkin_config::{ADVANCED_CONFIG, BASIC_CONFIG}; +use pumpkin_protocol::query::{ + CBasicStatus, CFullStatus, CHandshake, PacketType, RawQueryPacket, SHandshake, SStatusRequest, +}; +use rand::Rng; +use tokio::{net::UdpSocket, sync::RwLock, time}; + +use crate::server::{Server, CURRENT_MC_VERSION}; + +pub async fn start_query_handler(server: Arc, bound_addr: SocketAddr) { + let mut query_addr = bound_addr; + if let Some(port) = ADVANCED_CONFIG.query.port { + query_addr.set_port(port); + } + + let socket = Arc::new( + UdpSocket::bind(query_addr) + .await + .expect("Unable to bind to address"), + ); + + // Challange tokens are bound to the IP address and port + let valid_challange_tokens = Arc::new(RwLock::new(HashMap::new())); + let valid_challange_tokens_clone = valid_challange_tokens.clone(); + // All challange tokens ever created are expired every 30 seconds + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(30)); + + loop { + interval.tick().await; + valid_challange_tokens_clone.write().await.clear(); + } + }); + + log::info!( + "Server query running on {}", + socket + .local_addr() + .expect("Unable to find running address!") + ); + + loop { + let socket = socket.clone(); + let valid_challange_tokens = valid_challange_tokens.clone(); + let server = server.clone(); + let mut buf = vec![0; 1024]; + let (_, addr) = socket.recv_from(&mut buf).await.unwrap(); + + tokio::spawn(async move { + if let Err(err) = handle_packet( + buf, + valid_challange_tokens, + server, + socket, + addr, + bound_addr, + ) + .await + { + log::error!("Interior 0 bytes found! Cannot encode query response! {err}"); + } + }); + } +} + +// Errors of packets that don't meet the format aren't returned since we won't handle them anyway +// The only errors that are thrown are because of a null terminator in a CString +// since those errors need to be corrected by server owner +#[inline] +async fn handle_packet( + buf: Vec, + clients: Arc>>, + server: Arc, + socket: Arc, + addr: SocketAddr, + bound_addr: SocketAddr, +) -> Result<(), NulError> { + if let Ok(mut raw_packet) = RawQueryPacket::decode(buf).await { + match raw_packet.packet_type { + PacketType::Handshake => { + if let Ok(packet) = SHandshake::decode(&mut raw_packet).await { + let challange_token = rand::thread_rng().gen_range(1..=i32::MAX); + let response = CHandshake { + session_id: packet.session_id, + challange_token, + }; + + // Ignore all errors since we don't want the query handler to crash + // Protocol also ignores all errors and just doesn't respond + let _ = socket + .send_to(response.encode().await.as_slice(), addr) + .await; + + clients.write().await.insert(challange_token, addr); + } + } + PacketType::Status => { + if let Ok(packet) = SStatusRequest::decode(&mut raw_packet).await { + if clients + .read() + .await + .get(&packet.challange_token) + .is_some_and(|token_bound_ip: &SocketAddr| token_bound_ip == &addr) + { + if packet.is_full_request { + // Get 4 players + let mut players: Vec = Vec::new(); + for world in &server.worlds { + let mut world_players = world + .current_players + .lock() + .await + // Although there is no documented limit, we will limit to 4 players + .values() + .take(4 - players.len()) + .map(|player| { + CString::new(player.gameprofile.name.as_str()).unwrap() + }) + .collect::>(); + + players.append(&mut world_players); // Append players from this world + + if players.len() >= 4 { + break; // Stop if we've collected 4 players + } + } + + let response = CFullStatus { + session_id: packet.session_id, + hostname: CString::new(BASIC_CONFIG.motd.as_str())?, + version: CString::new(CURRENT_MC_VERSION)?, + plugins: CString::new("Pumpkin on 1.21.3")?, // TODO: Fill this with plugins when plugins are working + map: CString::new("world")?, // TODO: Get actual world name + num_players: server.get_player_count().await, + max_players: BASIC_CONFIG.max_players as usize, + host_port: bound_addr.port(), + host_ip: CString::new(bound_addr.ip().to_string())?, + players, + }; + + let _ = socket + .send_to(response.encode().await.as_slice(), addr) + .await; + } else { + let resposne = CBasicStatus { + session_id: packet.session_id, + motd: CString::new(BASIC_CONFIG.motd.as_str())?, + map: CString::new("world")?, + num_players: server.get_player_count().await, + max_players: BASIC_CONFIG.max_players as usize, + host_port: bound_addr.port(), + host_ip: CString::new(bound_addr.ip().to_string())?, + }; + + let _ = socket + .send_to(resposne.encode().await.as_slice(), addr) + .await; + } + } + } + } + } + } + Ok(()) +}