//! This module defines a wrapper around Minecraft's //! [ServerListPing](https://wiki.vg/Server_List_Ping) use std::time::Duration; use serde::Deserialize; use thiserror::Error; use tokio::net::TcpStream; use crate::protocol::{self, AsyncReadRawPacket, AsyncWriteRawPacket}; #[derive(Error, Debug)] pub enum ServerError { #[error("error reading or writing data")] ProtocolError, #[error("failed to connect to server")] FailedToConnect, #[error("connection timed out")] ConnectionTimedOut, #[error("invalid JSON response: \"{0}\"")] InvalidJson(String), #[error("mismatched pong payload (expected \"{expected}\", got \"{actual}\")")] MismatchedPayload { expected: u64, actual: u64 }, } impl From for ServerError { fn from(_err: protocol::ProtocolError) -> Self { ServerError::ProtocolError } } /// Contains information about the server version. #[derive(Debug, Deserialize)] pub struct ServerVersion { /// The server's Minecraft version, i.e. "1.15.2". pub name: String, /// The server's ServerListPing protocol version. pub protocol: i32, } /// Contains information about a player. #[derive(Debug, Deserialize)] pub struct ServerPlayer { /// The player's in-game name. pub name: String, /// The player's UUID. pub id: String, } /// Contains information about the currently online /// players. #[derive(Debug, Deserialize)] pub struct ServerPlayers { /// The configured maximum number of players for the /// server. pub max: i32, /// The number of players currently online. pub online: i32, /// An optional list of player information for /// currently online players. pub sample: Option>, } /// Contains the server's MOTD. #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum ServerDescription { Plain(String), Object { text: String }, } /// The decoded JSON response from a status query over /// ServerListPing. #[derive(Debug, Deserialize)] pub struct StatusResponse { /// Information about the server's version. pub version: ServerVersion, /// Information about currently online players. pub players: ServerPlayers, /// Single-field struct containing the server's MOTD. pub description: ServerDescription, /// Optional field containing a path to the server's /// favicon. pub favicon: Option, } const LATEST_PROTOCOL_VERSION: usize = 578; const DEFAULT_PORT: u16 = 25565; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2); /// Builder for a Minecraft /// ServerListPing connection. pub struct ConnectionConfig { protocol_version: usize, address: String, port: u16, timeout: Duration, #[cfg(feature = "srv")] srv_lookup: bool, } impl ConnectionConfig { /// Initiates the Minecraft server /// connection build process. pub fn build>(address: T) -> Self { ConnectionConfig { protocol_version: LATEST_PROTOCOL_VERSION, address: address.into(), port: DEFAULT_PORT, timeout: DEFAULT_TIMEOUT, #[cfg(feature = "srv")] srv_lookup: false, } } /// Sets a specific /// protocol version for the connection to /// use. If not specified, the latest version /// will be used. pub fn with_protocol_version(mut self, protocol_version: usize) -> Self { self.protocol_version = protocol_version; self } /// Sets a specific port for the /// connection to use. If not specified, the /// default port of 25565 will be used. pub fn with_port(mut self, port: u16) -> Self { self.port = port; self } /// Sets a specific timeout for the /// connection to use. If not specified, the /// timeout defaults to two seconds. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Enables SRV record lookup for the connection. /// /// When enabled, the library will query DNS for an SRV record /// at `_minecraft._tcp.
`. If found, the target host /// and port from the SRV record will be used instead of the /// configured address and port. /// /// This feature requires the `srv` feature to be enabled. #[cfg(feature = "srv")] pub fn with_srv_lookup(mut self) -> Self { self.srv_lookup = true; self } /// Connects to the server and consumes the builder. pub async fn connect(self) -> Result { let (address, port) = self.resolve_address().await; let stream = tokio::time::timeout( self.timeout, TcpStream::connect(format!("{}:{}", address, port)), ) .await .map_err(|_| ServerError::ConnectionTimedOut)? .map_err(|_| ServerError::FailedToConnect)?; Ok(StatusConnection { stream, protocol_version: self.protocol_version, address, port, timeout: self.timeout, }) } #[cfg(feature = "srv")] async fn resolve_address(&self) -> (String, u16) { if !self.srv_lookup { return (self.address.clone(), self.port); } // Try to resolve SRV record, fall back to original address on any failure match self.lookup_srv().await { Some((host, port)) => (host, port), None => (self.address.clone(), self.port), } } #[cfg(not(feature = "srv"))] async fn resolve_address(&self) -> (String, u16) { (self.address.clone(), self.port) } #[cfg(feature = "srv")] async fn lookup_srv(&self) -> Option<(String, u16)> { use hickory_resolver::TokioAsyncResolver; let resolver = TokioAsyncResolver::tokio_from_system_conf().ok()?; let srv_name = format!("_minecraft._tcp.{}", self.address); let lookup = tokio::time::timeout(self.timeout, resolver.srv_lookup(&srv_name)) .await .ok()? .ok()?; let record = lookup.iter().next()?; let target = record.target().to_string(); // Remove trailing dot from DNS name let host = target.trim_end_matches('.').to_string(); let port = record.port(); Some((host, port)) } } /// Convenience wrapper for easily connecting /// to a server on the default port with /// the latest protocol version. pub async fn connect(address: String) -> Result { ConnectionConfig::build(address).connect().await } /// Wraps a built connection pub struct StatusConnection { stream: TcpStream, protocol_version: usize, address: String, port: u16, timeout: Duration, } impl StatusConnection { /// Sends and reads the packets for the /// ServerListPing status call. /// /// Consumes the connection and returns a type /// that can only issue pings. The resulting /// status body is accessible via the `status` /// property on `PingConnection`. pub async fn status(mut self) -> Result { let handshake = protocol::HandshakePacket::new( self.protocol_version, self.address.to_string(), self.port, ); self.stream .write_packet_with_timeout(handshake, self.timeout) .await?; self.stream .write_packet_with_timeout(protocol::RequestPacket::new(), self.timeout) .await?; let response: protocol::ResponsePacket = self.stream.read_packet_with_timeout(self.timeout).await?; let status: StatusResponse = serde_json::from_str(&response.body) .map_err(|_| ServerError::InvalidJson(response.body))?; Ok(PingConnection { stream: self.stream, protocol_version: self.protocol_version, address: self.address, port: self.port, status, timeout: self.timeout, }) } } /// Wraps a built connection /// /// Constructed by calling `status()` on /// a `StatusConnection` struct. #[allow(dead_code)] pub struct PingConnection { stream: TcpStream, protocol_version: usize, address: String, port: u16, timeout: Duration, pub status: StatusResponse, } impl PingConnection { /// Sends a ping to the Minecraft server with the /// provided payload and asserts that the returned /// payload is the same. /// /// Server closes the connection after a ping call, /// so this method consumes the connection. pub async fn ping(mut self, payload: u64) -> Result<(), ServerError> { let ping = protocol::PingPacket::new(payload); self.stream .write_packet_with_timeout(ping, self.timeout) .await?; let pong: protocol::PongPacket = self.stream.read_packet_with_timeout(self.timeout).await?; if pong.payload != payload { return Err(ServerError::MismatchedPayload { expected: payload, actual: pong.payload, }); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_server_description_plain() { let json = r#""A Minecraft Server""#; let desc: ServerDescription = serde_json::from_str(json).unwrap(); assert!(matches!(desc, ServerDescription::Plain(s) if s == "A Minecraft Server")); } #[test] fn test_server_description_object() { let json = r#"{"text":"A Minecraft Server"}"#; let desc: ServerDescription = serde_json::from_str(json).unwrap(); assert!(matches!(desc, ServerDescription::Object { text } if text == "A Minecraft Server")); } #[test] fn test_status_response_minimal() { let json = r#"{ "version": {"name": "1.20.4", "protocol": 765}, "players": {"max": 20, "online": 5}, "description": "Welcome to the server" }"#; let response: StatusResponse = serde_json::from_str(json).unwrap(); assert_eq!(response.version.name, "1.20.4"); assert_eq!(response.version.protocol, 765); assert_eq!(response.players.max, 20); assert_eq!(response.players.online, 5); assert!(response.players.sample.is_none()); assert!(response.favicon.is_none()); } #[test] fn test_status_response_with_players() { let json = r#"{ "version": {"name": "1.20.4", "protocol": 765}, "players": { "max": 20, "online": 2, "sample": [ {"name": "Player1", "id": "uuid-1"}, {"name": "Player2", "id": "uuid-2"} ] }, "description": {"text": "Welcome"} }"#; let response: StatusResponse = serde_json::from_str(json).unwrap(); let sample = response.players.sample.unwrap(); assert_eq!(sample.len(), 2); assert_eq!(sample[0].name, "Player1"); assert_eq!(sample[1].name, "Player2"); } #[test] fn test_status_response_with_favicon() { let json = r#"{ "version": {"name": "1.20.4", "protocol": 765}, "players": {"max": 20, "online": 0}, "description": "Test", "favicon": "data:image/png;base64,iVBORw0KGgo=" }"#; let response: StatusResponse = serde_json::from_str(json).unwrap(); assert!(response.favicon.is_some()); assert!(response.favicon.unwrap().starts_with("data:image/png")); } #[test] fn test_connection_config_defaults() { let config = ConnectionConfig::build("localhost"); assert_eq!(config.address, "localhost"); assert_eq!(config.port, DEFAULT_PORT); assert_eq!(config.timeout, DEFAULT_TIMEOUT); assert_eq!(config.protocol_version, LATEST_PROTOCOL_VERSION); } #[test] fn test_connection_config_with_port() { let config = ConnectionConfig::build("localhost").with_port(12345); assert_eq!(config.port, 12345); } #[test] fn test_connection_config_with_timeout() { let config = ConnectionConfig::build("localhost").with_timeout(Duration::from_secs(10)); assert_eq!(config.timeout, Duration::from_secs(10)); } #[test] fn test_connection_config_with_protocol_version() { let config = ConnectionConfig::build("localhost").with_protocol_version(47); assert_eq!(config.protocol_version, 47); } #[cfg(feature = "srv")] #[test] fn test_connection_config_with_srv_lookup() { let config = ConnectionConfig::build("localhost").with_srv_lookup(); assert!(config.srv_lookup); } }