diff --git a/.gitignore b/.gitignore index 07827cc..8cbd25d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ -.idea/ \ No newline at end of file +.idea/ +.local/ diff --git a/Cargo.lock b/Cargo.lock index bd26636..254f6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,12 +510,11 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minecraft-pinger" -version = "0.1.10" +version = "1.0.0" dependencies = [ "bytes", "hickory-resolver", "log", - "rand", "serde", "serde_json", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index d4e32d3..666ac30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "minecraft-pinger" -version = "0.1.10" +version = "1.0.0" edition = "2024" description = "A high-performance, asynchronous Rust library for pinging Minecraft servers to retrieve status, players, and metadata." license = "MIT" @@ -25,4 +25,3 @@ thiserror = "2.*" bytes = "1.*" log = "0.4.*" -rand = "0.10.*" diff --git a/README.md b/README.md index ec54f5a..9644df9 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ A high-performance, asynchronous Rust library for pinging Minecraft servers to r - **Asynchronous I/O**: Built on top of `tokio` for maximum efficiency. - **Feature Flags**: Modular design with `java` and `bedrock` features (both enabled by default). - **Java & Bedrock Support**: Supports pinging both Java and Bedrock (MCPE) edition servers. -- **Optimized Performance**: Uses `BufReader`/`BufWriter` and zero-copy string parsing. -- **Robust DNS Support**: Full support for SRV record resolution (`_minecraft._tcp`). +- **Optimized Performance**: Zero-copy packet framing, single-pass MOTD deserialization, and a single deadline timer per ping (see [Performance](#-performance)). +- **Robust DNS Support**: Full support for SRV record resolution (`_minecraft._tcp`), resolved concurrently. - **Rich Metadata**: Parses MOTD (plain and JSON components), player samples, versions, and ModInfo (Forge/Fabric). -- **Resource Efficient**: Shared DNS resolver to minimize overhead during batch pings. +- **Resource Efficient**: Shared DNS resolver to minimize overhead during batch pings; optional shared UDP socket for Bedrock scans; optional skipping of heavy fields (`favicon`, player sample). - **Security**: Built-in IP filtering capability to prevent SSRF (Server-Side Request Forgery) attacks by blocking private or reserved IP addresses. ## ⚙️ Installation @@ -23,14 +23,14 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -minecraft-pinger = "0.1.*" +minecraft-pinger = "1.0" tokio = { version = "1.0", features = ["full"] } ``` > **Note**: Both `java` and `bedrock` features are enabled by default. If you only need one, you can disable the default features: > ```toml > [dependencies] -> minecraft-pinger = { version = "0.1.*", default-features = false, features = ["java"] } +> minecraft-pinger = { version = "1.0", default-features = false, features = ["java"] } > ``` ## 🚀 Quick Start @@ -52,7 +52,7 @@ async fn main() { .deny_non_public_ips() // Prevent SSRF by dropping local/private IPs .build(); - let java_config = JavaPingConfig::from(&config.to_builder()) + let java_config = JavaPingConfig::from(&config) .set_protocol_version(763) // 1.20.1 .build(); @@ -65,7 +65,7 @@ async fn main() { } Err(e) => eprintln!("Java Ping failed: {:?}", e), } - + // 4. Ping a Bedrock server match pinger.ping_bedrock_server("bedrock.nationsglory.fr", 19132, &config).await { Ok(pong) => { @@ -77,6 +77,35 @@ async fn main() { } ``` +### Fleet scans: skip heavy fields (Java) + +The `favicon` (base64, ~20–130 KB) and the player `sample` list dominate +parsing cost. Skip what you don't display: + +```rust +let java_config = JavaPingConfig::from(&config) + .skip_favicon() + .skip_sample() + .build(); +``` + +### Fleet scans: share one UDP socket (Bedrock) + +Binding a socket per ping burns an ephemeral port each time. Share one +(or a pool of) sockets instead: + +```rust +use tokio::net::UdpSocket; +use std::sync::Arc; + +let socket = Arc::new(UdpSocket::bind("0.0.0.0:0").await.unwrap()); +let pong = pinger.ping_bedrock_with_socket(&socket, "play.example.net", 19132, &config).await?; +``` + +> Replies are matched by sender address. Concurrent pings to the *same* +> address over one socket cannot be told apart (the client GUID is not +> echoed by the pong) — use one socket per concurrent target in that case. + ## 🛠️ Configuration Configuration is managed via `PingConfig` for common properties and `JavaPingConfig` for Java-specific options, both utilizing the Builder pattern. @@ -85,27 +114,80 @@ Configuration is managed via `PingConfig` for common properties and `JavaPingCon | Builder Method | Type | Description | |----------------|------|-------------| -| `set_timeout` | `Duration` | Maximum time to wait for a response. | +| `set_timeout` | `Duration` | Maximum time budget for a whole ping (DNS + connect + I/O). | | `deny_non_public_ips` | `self` | Rejects non-public IPs (e.g. 127.0.0.1, 192.168.0.1), preventing SSRF attacks. | | `set_ip_filter` | `Fn(IpAddr) -> bool` | Apply a custom filter to resolved IPs. Returning `false` blocks the connection. | ### JavaPingConfig -Created using `JavaPingConfig::from(&config.to_builder())` or `JavaPingConfig::builder()`. +Created using `JavaPingConfig::from(&config)` or `JavaPingConfig::builder()`. | Builder Method | Type | Description | |----------------|------|-------------| | `set_protocol_version` | `i32` | Protocol ID sent in the handshake. | | `set_hostname` | `Option` | Override the hostname sent in the handshake (defaults to the IP provided). | +| `skip_favicon` | `self` | Skip parsing the `favicon` field (saves a ~20–130 KB allocation per server). | +| `skip_sample` | `self` | Skip parsing the `players.sample` list. | + +## ⏱️ Performance + +Measured 2026-10-25, release profile, same machine/network, alternating +order (see `examples/bench_fleet.rs`): + +**Fleet: 10 rounds × 500 public servers (488 Java + 12 Bedrock), concurrency 64, timeout 4 s** + +| | crates.io 0.1.10 | 1.0 (this) | +|---|---|---| +| Wall time (mean of 10 runs) | 6328 ms | **6170 ms (−2,5 %)** | +| Success (mean of 10 runs) | 469,5/500 | **470,1/500** | +| Servers OK in all 10 runs | 431 | **439 (+8)** | +| Mean per-ping time (4681 paired samples) | 298,8 ms | **278,9 ms (−6,7 %)** | +| Median / p90 / p95 / p99 | 219 / 591 / 1107 / 1519 ms | **209 / 544 / 1090 / 1377 ms** | +| Faster on same server | — | **54 % of samples** | + +Reliability detail: 16 servers succeed more often with 1.0 (including +several that reset the connection mid-handshake — the old byte-at-a-time +reader lost the race, the new bulk reader gets the payload first), versus +10 in the other direction (network flakiness noise). + +**Heap (`dhat`, same workload both sides, 2000 iterations):** + +| Path | 0.1.10 | 1.0 | +|---|---|---| +| Full ping path (framing + Bedrock + serde + MOTD text) | 4524 B / 51 blocks per iter | **2420 B / 28 blocks (−46 %)** | +| Serde status parse | 2491 B / 25 blocks per parse | **1245 B / 12 blocks (−50 %)** | +| Serde parse with `skip_favicon` + `skip_sample` | n/a | **900 B / 9 blocks per parse** | ## 🚨 Error Handling The library uses a custom `PingError` enum to categorize failures: +- `Io`: Underlying I/O error. +- `Timeout`: Request exceeded the configured duration. - `ConnectionRefused`: Network-level connection issues. -- `TimeoutError`: Request exceeded the configured duration. -- `SerializationError`: Failed to parse the server's JSON response. -- `ReadPacketError`: Protocol-level parsing failure. -- `BlockedEndpoint`: The resolved IP was blocked by the configured IP filter. +- `SendPacket`: Failed to send the request packet. +- `ReadPacket`: Protocol-level parsing failure. +- `ParseResponse`: Bedrock pong did not match the expected shape. +- `Serialization`: Failed to parse the server's JSON response. +- `BlockedEndpoint(Vec)`: Every resolved IP was rejected by the IP filter (lists the culprits). +- `DnsParse` / `DnsIpNotFound` / `AddressParse`: DNS resolution failures. +- `Init`: Resolver initialization failure. + +## 🔼 Migrating from 0.x to 1.0 + +Breaking changes (the reason for the major bump): + +- `JavaPingConfig::from(&PingConfigBuilder)` → `JavaPingConfig::from(&PingConfig)`. + `PingConfig::to_builder()` was removed; build the `PingConfig` first, then derive from it: + ```rust + // before + let java_config = JavaPingConfig::from(&config.to_builder()).build(); + // after + let java_config = JavaPingConfig::from(&config).build(); + ``` +- `PingError::BlockedEndpoint(String)` → `BlockedEndpoint(Vec)`: match on a list of rejected IPs instead of a pre-formatted string. +- `PingError::ReadPacket(String)` → `ReadPacket(&'static str)`: no allocation on the error path. +- `BedrockPing::try_from` now takes `&str` (a `TryFrom` shim delegates to it). +- Dependency `rand` removed (Bedrock GUIDs use an atomic counter). ## 📄 License diff --git a/examples/bench_fleet.rs b/examples/bench_fleet.rs new file mode 100644 index 0000000..998e177 --- /dev/null +++ b/examples/bench_fleet.rs @@ -0,0 +1,132 @@ +//! Fleet bench: pings every server in a JSON list and writes results. +//! Usage: bench_fleet [limit] [concurrency] +//! Input: [{"ip": "...", "port": 25565, "type": "java"|"bedrock"}, ...] +//! Same file is used against the published crate (old API) and this repo. +use minecraft_pinger::MinecraftPinger; +use minecraft_pinger::config::PingConfig; +use minecraft_pinger::java::config::JavaPingConfig; +use std::time::{Duration, Instant}; + +#[derive(serde::Deserialize)] +struct Server { + ip: String, + port: u16, + #[serde(rename = "type")] + kind: String, +} + +#[derive(serde::Serialize)] +struct Row { + ip: String, + port: u16, + kind: String, + ok: bool, + latency_ms: Option, + elapsed_ms: u64, + err: Option, +} + +#[derive(serde::Serialize)] +struct Report { + wall_ms: u64, + success: usize, + failed: usize, + rows: Vec, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let args: Vec = std::env::args().collect(); + let list_path = args.get(1).cloned().expect("missing "); + let out_path = args.get(2).cloned().expect("missing "); + let limit: usize = args + .get(3) + .map(|s| s.parse().expect("bad limit")) + .unwrap_or(usize::MAX); + let concurrency: usize = args + .get(4) + .map(|s| s.parse().expect("bad concurrency")) + .unwrap_or(64); + + let data = std::fs::read_to_string(&list_path).expect("read server list"); + let mut servers: Vec = serde_json::from_str(&data).expect("parse server list"); + servers.truncate(limit); + eprintln!( + "bench_fleet: {} servers, concurrency {}", + servers.len(), + concurrency + ); + + let pinger = MinecraftPinger::new().expect("pinger"); + let config = PingConfig::builder() + .set_timeout(Duration::from_secs(4)) + .build(); + let java_config = JavaPingConfig::from(&config) + .set_protocol_version(777) + .build(); + + let t0 = Instant::now(); + let mut set = tokio::task::JoinSet::new(); + let mut rows: Vec = Vec::with_capacity(servers.len()); + + for s in servers { + while set.len() >= concurrency { + rows.push(set.join_next().await.expect("task").expect("row")); + } + let pinger = pinger.clone(); + let config = config.clone(); + let java_config = java_config.clone(); + set.spawn(async move { + let t = Instant::now(); + let base = + |ok: bool, latency_ms: Option, err: Option, elapsed_ms: u64| Row { + ip: s.ip.clone(), + port: s.port, + kind: s.kind.clone(), + ok, + latency_ms, + elapsed_ms, + err, + }; + if s.kind == "bedrock" { + match pinger.ping_bedrock_server(&s.ip, s.port, &config).await { + Ok(r) => base(true, Some(r.latency), None, t.elapsed().as_millis() as u64), + Err(e) => base( + false, + None, + Some(format!("{e:?}")), + t.elapsed().as_millis() as u64, + ), + } + } else { + match pinger.ping_java_server(&s.ip, s.port, &java_config).await { + Ok(r) => base(true, Some(r.latency), None, t.elapsed().as_millis() as u64), + Err(e) => base( + false, + None, + Some(format!("{e:?}")), + t.elapsed().as_millis() as u64, + ), + } + } + }); + } + while let Some(r) = set.join_next().await { + rows.push(r.expect("row")); + } + + let report = Report { + wall_ms: t0.elapsed().as_millis() as u64, + success: rows.iter().filter(|r| r.ok).count(), + failed: rows.iter().filter(|r| !r.ok).count(), + rows, + }; + std::fs::write(&out_path, serde_json::to_string(&report).unwrap()).expect("write report"); + eprintln!( + "done: {}/{} ok in {} ms -> {}", + report.success, + report.success + report.failed, + report.wall_ms, + out_path + ); +} diff --git a/src/bedrock/model.rs b/src/bedrock/model.rs index 0815bd5..98672b5 100644 --- a/src/bedrock/model.rs +++ b/src/bedrock/model.rs @@ -1,7 +1,14 @@ use crate::error::PingError; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; - +use std::str::FromStr; + +/// Parsed Bedrock (RakNet unconnected pong) status. +/// +/// Field order follows the `;`-separated pong string. Optional trailing +/// fields are `None` when the server omits them. Parse from `&str` +/// without intermediate allocation via `TryFrom<&str>` (`String` kept +/// as a convenience wrapper). #[derive(Debug, Deserialize, Serialize, Default)] #[serde(default)] pub struct BedrockPing { @@ -25,33 +32,68 @@ pub struct BedrockPing { pub connected_addr: Option, } -impl TryFrom for BedrockPing { - type Error = PingError; +impl FromStr for BedrockPing { + type Err = PingError; + + fn from_str(data: &str) -> Result { + let mut parts = data.split(';'); + + let edition = parts.next().ok_or(PingError::ParseResponse)?.to_owned(); + let motd = parts.next().ok_or(PingError::ParseResponse)?.to_owned(); + let protocol_version = parts + .next() + .ok_or(PingError::ParseResponse)? + .parse() + .unwrap_or(0); + let version = parts.next().ok_or(PingError::ParseResponse)?.to_owned(); + let current_players = parts + .next() + .ok_or(PingError::ParseResponse)? + .parse() + .unwrap_or(0); + let max_players = parts + .next() + .ok_or(PingError::ParseResponse)? + .parse() + .unwrap_or(0); + let server_id = parts.next().ok_or(PingError::ParseResponse)?.to_owned(); + let map_name = parts.next().ok_or(PingError::ParseResponse)?.to_owned(); - fn try_from(data: String) -> Result { - let parts: Vec<&str> = data.split(';').collect(); - if parts.len() < 8 { - return Err(PingError::ParseResponse); - } Ok(BedrockPing { - edition: parts[0].to_string(), - motd: parts[1].to_string(), - protocol_version: parts[2].parse().unwrap_or(0), - version: parts[3].to_string(), - current_players: parts[4].parse().unwrap_or(0), - max_players: parts[5].parse().unwrap_or(0), - server_id: parts[6].to_string(), - map_name: parts[7].to_string(), - game_mode: parts.get(8).map(|s| s.to_string()).unwrap_or_default(), - numeric_id: parts.get(9).and_then(|s| s.parse().ok()), - port: parts.get(10).and_then(|s| s.parse().ok()), - unknown_val: parts.get(11).and_then(|s| s.parse().ok()), + edition, + motd, + protocol_version, + version, + current_players, + max_players, + server_id, + map_name, + game_mode: parts.next().unwrap_or_default().to_owned(), + numeric_id: parts.next().and_then(|part| part.parse().ok()), + port: parts.next().and_then(|part| part.parse().ok()), + unknown_val: parts.next().and_then(|part| part.parse().ok()), latency: 0, connected_addr: None, }) } } +impl TryFrom<&str> for BedrockPing { + type Error = PingError; + + fn try_from(value: &str) -> Result { + BedrockPing::from_str(value) + } +} + +impl TryFrom for BedrockPing { + type Error = PingError; + + fn try_from(data: String) -> Result { + Self::from_str(data.as_str()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -59,7 +101,7 @@ mod tests { #[test] fn test_bedrock_ping_try_from_full() { let data = "MCPE;Test Server;589;1.20.0;10;20;1234567890;world;Survival;1;19132;123"; - let ping = BedrockPing::try_from(data.to_string()).unwrap(); + let ping = BedrockPing::from_str(data).unwrap(); assert_eq!(ping.edition, "MCPE"); assert_eq!(ping.motd, "Test Server"); @@ -79,7 +121,7 @@ mod tests { #[test] fn test_bedrock_ping_try_from_minimal() { let data = "MCPE;Test;589;1.20.0;0;20;1234567890;world;Creative"; - let ping = BedrockPing::try_from(data.to_string()).unwrap(); + let ping = BedrockPing::from_str(data).unwrap(); assert_eq!(ping.edition, "MCPE"); assert_eq!(ping.motd, "Test"); @@ -96,7 +138,7 @@ mod tests { #[test] fn test_bedrock_ping_try_from_empty_game_mode() { let data = "MCPE;Test;589;1.20.0;0;20;1234567890;world"; - let ping = BedrockPing::try_from(data.to_string()).unwrap(); + let ping = BedrockPing::from_str(data).unwrap(); assert_eq!(ping.game_mode, ""); } @@ -104,7 +146,7 @@ mod tests { #[test] fn test_bedrock_ping_try_from_invalid_protocol_version() { let data = "MCPE;Test;invalid;1.20.0;0;20;1234567890;world"; - let ping = BedrockPing::try_from(data.to_string()).unwrap(); + let ping = BedrockPing::from_str(data).unwrap(); assert_eq!(ping.protocol_version, 0); } @@ -112,16 +154,24 @@ mod tests { #[test] fn test_bedrock_ping_try_from_invalid_players() { let data = "MCPE;Test;589;1.20.0;invalid;invalid;1234567890;world"; - let ping = BedrockPing::try_from(data.to_string()).unwrap(); + let ping = BedrockPing::from_str(data).unwrap(); assert_eq!(ping.current_players, 0); assert_eq!(ping.max_players, 0); } + #[test] + fn test_bedrock_ping_try_from_string_compat() { + let ping = + BedrockPing::try_from("MCPE;Test;589;1.20.0;0;20;123;world".to_string()).unwrap(); + assert_eq!(ping.edition, "MCPE"); + assert_eq!(ping.game_mode, ""); + } + #[test] fn test_bedrock_ping_try_from_too_few_parts() { let data = "MCPE;Test;589;1.20.0;0;20;1234567890"; - let result = BedrockPing::try_from(data.to_string()); + let result = BedrockPing::from_str(data); assert!(result.is_err()); match result.unwrap_err() { diff --git a/src/bedrock/pinger.rs b/src/bedrock/pinger.rs index 088a4e3..4f019a0 100644 --- a/src/bedrock/pinger.rs +++ b/src/bedrock/pinger.rs @@ -4,25 +4,26 @@ use crate::bedrock::protocol::{create_ping, read_response}; use crate::common::dns::resolve_filtered_addrs; use crate::config::PingConfig; use crate::error::PingError; -use bytes::Bytes; use log::debug; +use std::net::SocketAddr; use std::time::Instant; use tokio::net::UdpSocket; -use tokio::time::timeout; +use tokio::time::{Instant as TokioInstant, timeout_at}; impl MinecraftPinger { + /// Pings a Bedrock server with a fresh UDP socket. + /// + /// For batch scans, prefer [`ping_bedrock_with_socket`](Self::ping_bedrock_with_socket) + /// on a shared socket instead of binding one socket per ping. pub async fn ping_bedrock_server( &self, ip: &str, port: u16, config: &PingConfig, ) -> Result { - let rs = timeout( - config.timeout(), - self.ping_bedrock_server_internal(ip, port, config), - ) - .await??; - Ok(rs) + let deadline = TokioInstant::now() + config.timeout(); + self.ping_bedrock_server_internal(ip, port, config, deadline) + .await } async fn ping_bedrock_server_internal( @@ -30,36 +31,171 @@ impl MinecraftPinger { ip: &str, port: u16, config: &PingConfig, + deadline: TokioInstant, ) -> Result { debug!("Pinging bedrock server {}:{}", ip, port); - let (_, addrs) = resolve_filtered_addrs(self, ip, port, "udp", config.ip_filter()).await?; - let start_time = Instant::now(); + let (_, addrs) = timeout_at( + deadline, + resolve_filtered_addrs(self, ip, port, "udp", config.ip_filter()), + ) + .await??; let socket = UdpSocket::bind("0.0.0.0:0").await?; - timeout(config.timeout(), socket.connect(&addrs[..])) - .await? - .map_err(|e| { - debug!("Connection error: {}", e); - PingError::ConnectionRefused - })?; + Self::exchange(&socket, &addrs, deadline).await + } + + /// Pings a Bedrock server through a caller-provided UDP socket. + /// + /// Share one (or a pool of) `Arc` across scans to avoid + /// binding a socket and consuming an ephemeral port per ping. + /// Replies are matched by sender address; concurrent pings to the + /// *same* address on one socket cannot be told apart (the client + /// GUID is not echoed by the pong), so use one socket per concurrent + /// target or accept that ambiguity. + pub async fn ping_bedrock_with_socket( + &self, + socket: &UdpSocket, + ip: &str, + port: u16, + config: &PingConfig, + ) -> Result { + let deadline = TokioInstant::now() + config.timeout(); + self.ping_bedrock_with_socket_internal(socket, ip, port, config, deadline) + .await + } - let _ = socket.send(&create_ping()?).await; + async fn ping_bedrock_with_socket_internal( + &self, + socket: &UdpSocket, + ip: &str, + port: u16, + config: &PingConfig, + deadline: TokioInstant, + ) -> Result { + debug!("Pinging bedrock server {}:{} (shared socket)", ip, port); - let mut buffer = [0u8; 1024]; - let len = timeout(config.timeout(), socket.recv(&mut buffer)) + let (_, addrs) = timeout_at( + deadline, + resolve_filtered_addrs(self, ip, port, "udp", config.ip_filter()), + ) + .await??; + + Self::exchange(socket, &addrs, deadline).await + } + + async fn exchange( + socket: &UdpSocket, + addrs: &[SocketAddr], + deadline: TokioInstant, + ) -> Result { + let start_time = Instant::now(); + let target = addrs.first().copied().ok_or(PingError::ConnectionRefused)?; + + timeout_at(deadline, socket.send_to(&create_ping()?, target)) .await? - .map_err(|_| PingError::ConnectionRefused)?; + .map_err(|_| PingError::SendPacket)?; - let connected_addr = socket.peer_addr().ok(); + // A shared socket may carry other traffic: only accept the target reply. + // A full buffer means the datagram may have been cut: reject it. + let mut buffer = [0u8; 4096]; + let len = loop { + let (len, from) = timeout_at(deadline, socket.recv_from(&mut buffer)) + .await? + .map_err(|_| PingError::ConnectionRefused)?; + if from == target { + break len; + } + }; + if len == buffer.len() { + return Err(PingError::ReadPacket("truncated response")); + } - let mut response_bytes = Bytes::copy_from_slice(&buffer[..len]); + let mut response_bytes = &buffer[..len]; let latency = start_time.elapsed().as_millis() as u32; let mut rs = read_response(&mut response_bytes)?; rs.latency = latency; - rs.connected_addr = connected_addr; + rs.connected_addr = Some(target); Ok(rs) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PingConfig; + use tokio::net::UdpSocket; + + fn pong_bytes() -> Vec { + let mut pong = Vec::new(); + pong.push(0x1C); + pong.extend_from_slice(&[0u8; 8]); + pong.extend_from_slice(&[0u8; 8]); + pong.extend_from_slice(&[0u8; 16]); + let body = "MCPE;Test Server;589;1.20.0;10;20;123;world;Survival"; + pong.extend_from_slice(&(body.len() as u16).to_be_bytes()); + pong.extend_from_slice(body.as_bytes()); + pong + } + + #[tokio::test] + async fn ping_loopback_default_and_shared_socket() { + let server = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = server.local_addr().unwrap().port(); + + tokio::spawn(async move { + for _ in 0..2 { + let mut buf = [0u8; 128]; + let (len, from) = server.recv_from(&mut buf).await.unwrap(); + assert_eq!(len, 33); + assert_eq!(buf[0], 0x01); + server.send_to(&pong_bytes(), from).await.unwrap(); + } + }); + + let pinger = MinecraftPinger::new().unwrap(); + let config = PingConfig::builder().build(); + + let res = pinger + .ping_bedrock_server("127.0.0.1", port, &config) + .await + .unwrap(); + assert_eq!(res.motd, "Test Server"); + assert_eq!(res.current_players, 10); + + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let res = pinger + .ping_bedrock_with_socket(&socket, "127.0.0.1", port, &config) + .await + .unwrap(); + assert_eq!(res.motd, "Test Server"); + assert_eq!(res.current_players, 10); + assert_eq!(res.connected_addr.unwrap().port(), port); + } + + #[tokio::test] + async fn ping_rejects_truncated_response() { + let server = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = server.local_addr().unwrap().port(); + + tokio::spawn(async move { + let mut buf = [0u8; 128]; + let (_, from) = server.recv_from(&mut buf).await.unwrap(); + server.send_to(&[0u8; 4096], from).await.unwrap(); + }); + + let pinger = MinecraftPinger::new().unwrap(); + let config = PingConfig::builder().build(); + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + + let result = pinger + .ping_bedrock_with_socket(&socket, "127.0.0.1", port, &config) + .await; + assert!(matches!( + result, + Err(PingError::ReadPacket("truncated response")) + )); + } +} diff --git a/src/bedrock/protocol.rs b/src/bedrock/protocol.rs index 7cc4751..1b9bd99 100644 --- a/src/bedrock/protocol.rs +++ b/src/bedrock/protocol.rs @@ -1,18 +1,12 @@ use crate::bedrock::model::BedrockPing; use crate::error::PingError; -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use rand::Rng; +use bytes::{BufMut, Bytes, BytesMut}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; const MAGIC: u128 = 0x00ffff00fefefefefdfdfdfd12345678; -fn try_split_to(buffer: &mut Bytes, size: usize) -> Option { - if buffer.len() >= size { - Some(buffer.split_to(size)) - } else { - None - } -} +static NEXT_GUID: AtomicU64 = AtomicU64::new(0); pub(crate) fn create_ping() -> Result { let mut data = BytesMut::with_capacity(33); @@ -25,42 +19,44 @@ pub(crate) fn create_ping() -> Result { data.put_u64(time); data.put_u128(MAGIC); - - let mut guid = [0u8; 8]; - rand::rng().fill_bytes(&mut guid); - - data.put_slice(&guid); + data.put_u64(NEXT_GUID.fetch_add(1, Ordering::Relaxed)); Ok(data.freeze()) } -pub(crate) fn read_response(buffer: &mut Bytes) -> Result { - let _packet_id = buffer - .try_get_u8() - .map_err(|_| PingError::ReadPacket("Packet id not found".to_string()))?; - - let _timestamp = buffer - .try_get_u64() - .map_err(|_| PingError::ReadPacket("Timestamp not found".to_string()))?; - - let _server_guid = buffer - .try_get_u64() - .map_err(|_| PingError::ReadPacket("Server guid not found".to_string()))?; - - let _magic = buffer - .try_get_u128() - .map_err(|_| PingError::ReadPacket("Magic id not found".to_string()))?; - - let string_size = buffer - .try_get_u16() - .map_err(|_| PingError::ReadPacket("String size not found".to_string()))?; - - let string_data = try_split_to(buffer, string_size as usize) - .ok_or(PingError::ReadPacket("String data not found".to_string()))?; - - let str = String::from_utf8(string_data.into())?; +#[inline] +fn take<'a>(buffer: &mut &'a [u8], len: usize, err: &'static str) -> Result<&'a [u8], PingError> { + if buffer.len() < len { + return Err(PingError::ReadPacket(err)); + } + let (head, tail) = buffer.split_at(len); + *buffer = tail; + Ok(head) +} - str.try_into() +pub(crate) fn read_response(buffer: &mut &[u8]) -> Result { + let _packet_id = take(buffer, 1, "Packet id not found")?[0]; + let _timestamp = + u64::from_be_bytes(take(buffer, 8, "Timestamp not found")?.try_into().unwrap()); + let _server_guid = u64::from_be_bytes( + take(buffer, 8, "Server guid not found")? + .try_into() + .unwrap(), + ); + let _magic = u128::from_be_bytes(take(buffer, 16, "Magic id not found")?.try_into().unwrap()); + + let string_size = u16::from_be_bytes( + take(buffer, 2, "String size not found")? + .try_into() + .unwrap(), + ) as usize; + + let string_data = take(buffer, string_size, "String data not found")?; + + let valid = + std::str::from_utf8(string_data).map_err(|_| PingError::ReadPacket("invalid utf-8"))?; + + valid.try_into() } #[cfg(test)] @@ -76,6 +72,13 @@ mod tests { assert_eq!(ping[0], 0x01); } + #[test] + fn test_create_ping_guid_unique() { + let first = create_ping().unwrap(); + let second = create_ping().unwrap(); + assert_ne!(&first[25..33], &second[25..33]); + } + #[test] fn test_read_response_valid() { let mut buffer = BytesMut::new(); @@ -89,8 +92,9 @@ mod tests { buffer.put_u16(response_str.len() as u16); buffer.put_slice(response_str.as_bytes()); - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes).unwrap(); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice).unwrap(); assert_eq!(result.edition, "MCPE"); assert_eq!(result.motd, "Test Server"); @@ -118,8 +122,9 @@ mod tests { buffer.put_u16(response_str.len() as u16); buffer.put_slice(response_str.as_bytes()); - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes).unwrap(); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice).unwrap(); assert_eq!(result.edition, "MCPE"); assert_eq!(result.motd, "Test"); @@ -136,8 +141,9 @@ mod tests { buffer.put_u64(1234567890); // Missing fields - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice); assert!(result.is_err()); } @@ -151,8 +157,9 @@ mod tests { buffer.put_u16(100); // String size larger than actual data buffer.put_slice(b"short"); - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice); assert!(result.is_err()); } @@ -166,8 +173,9 @@ mod tests { buffer.put_u16(4); buffer.put_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); // Invalid UTF-8 - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice); assert!(result.is_err()); } @@ -183,8 +191,9 @@ mod tests { buffer.put_u16(response_str.len() as u16); buffer.put_slice(response_str.as_bytes()); - let mut bytes = buffer.freeze(); - let result = read_response(&mut bytes); + let bytes = buffer.freeze(); + let mut slice: &[u8] = &bytes; + let result = read_response(&mut slice); assert!(result.is_err()); } } diff --git a/src/common/dns.rs b/src/common/dns.rs index 211e06b..da9fc5a 100644 --- a/src/common/dns.rs +++ b/src/common/dns.rs @@ -5,6 +5,7 @@ use hickory_resolver::net::NetError; use hickory_resolver::proto::ProtoError; use hickory_resolver::proto::rr::{Name, RData}; use log::debug; +use tokio::task::JoinSet; use crate::MinecraftPinger; use crate::common::ip_filter::IpFilter; @@ -50,10 +51,9 @@ pub(crate) async fn resolve_filtered_addrs( } if allowed.is_empty() { - return Err(PingError::BlockedEndpoint(format!( - "every resolved address was rejected: {:?}", - rejected.iter().map(|a| a.ip()).collect::>() - ))); + return Err(PingError::BlockedEndpoint( + rejected.iter().map(|addr| addr.ip()).collect(), + )); } Ok((override_hostname, allowed)) @@ -74,27 +74,36 @@ async fn resolve_all_addrs( if let Ok(srv_name) = parse_host(&srv_record) && let Ok(lookup) = pinger.dns_resolver.srv_lookup(srv_name).await { - let mut override_hostname: Option = None; - - let mut all_addrs = Vec::new(); - for record in lookup.answers() { - if let RData::SRV(srv) = &record.data { - let srv_target = srv.target.to_string().trim_end_matches('.').to_string(); - // if the srv target is not an ip, we use the subdomain as hostname - if !srv_target.parse::().is_ok() { - override_hostname = Some(srv_target.clone()); - } - - if let Ok(ip_lookup) = pinger.dns_resolver.lookup_ip(srv.target.clone()).await { - let ips: Vec = ip_lookup - .iter() - .map(|ip| SocketAddr::new(ip, srv.port)) - .collect(); - - all_addrs.extend(ips); - } + // Every SRV target resolves concurrently: awaiting them one by one + // would stack a full DNS round-trip per record. + let resolver = Arc::clone(&pinger.dns_resolver); + let targets: Vec<(usize, Name, u16)> = lookup + .answers() + .iter() + .enumerate() + .filter_map(|(index, record)| match &record.data { + RData::SRV(srv) => Some((index, srv.target.clone(), srv.port)), + _ => None, + }) + .collect(); + let lookup_ip = Arc::new(move |target: Name, port: u16| { + let resolver = Arc::clone(&resolver); + async move { + resolver + .lookup_ip(target) + .await + .map(|ip_lookup| { + ip_lookup + .iter() + .map(|ip| SocketAddr::new(ip, port)) + .collect() + }) + .unwrap_or_default() } - } + }); + + let resolved = lookup_all(targets, lookup_ip).await; + let (override_hostname, all_addrs) = collect_srv_addrs(resolved); if !all_addrs.is_empty() { return Ok((override_hostname, all_addrs)); } @@ -118,6 +127,51 @@ async fn resolve_all_addrs( Ok((None, all_addrs)) } +async fn lookup_all( + targets: Vec<(usize, Name, u16)>, + lookup: Arc, +) -> Vec<(usize, Name, Vec)> +where + F: Fn(Name, u16) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + let mut lookups = JoinSet::new(); + for (index, target, port) in targets { + let lookup = Arc::clone(&lookup); + lookups.spawn(async move { + let addrs = lookup(target.clone(), port).await; + (index, target, addrs) + }); + } + + let mut resolved = Vec::new(); + while let Some(outcome) = lookups.join_next().await { + if let Ok(entry) = outcome { + resolved.push(entry); + } + } + resolved +} + +fn collect_srv_addrs( + mut resolved: Vec<(usize, Name, Vec)>, +) -> (Option, Vec) { + // Tasks finish in any order: restore the SRV answer order first. + resolved.sort_by_key(|entry| entry.0); + + let mut override_hostname = None; + let mut all_addrs = Vec::new(); + for (_, target, addrs) in resolved { + let srv_target = target.to_string().trim_end_matches('.').to_owned(); + // if the srv target is not an ip, we use the subdomain as hostname + if srv_target.parse::().is_err() { + override_hostname = Some(srv_target); + } + all_addrs.extend(addrs); + } + (override_hostname, all_addrs) +} + #[cfg(test)] mod tests { use super::*; @@ -237,4 +291,112 @@ mod tests { assert_eq!(allowed.len(), 2); assert!(rejected.is_empty()); } + + #[test] + fn test_collect_srv_addrs_restores_answer_order() { + let resolved = vec![ + ( + 1, + parse_host("mc2.example.com").unwrap(), + vec![SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), + 25566, + )], + ), + ( + 0, + parse_host("mc1.example.com").unwrap(), + vec![SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), + 25565, + )], + ), + ]; + + let (hostname, addrs) = collect_srv_addrs(resolved); + + assert_eq!(hostname, Some("mc2.example.com".to_string())); + assert_eq!( + addrs, + vec![ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 25565), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 25566), + ] + ); + } + + #[test] + fn test_collect_srv_addrs_empty() { + let (hostname, addrs) = collect_srv_addrs(Vec::new()); + assert!(hostname.is_none()); + assert!(addrs.is_empty()); + } + + #[tokio::test] + async fn test_srv_lookups_run_concurrently() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let lookup = { + let active = Arc::clone(&active); + let peak = Arc::clone(&peak); + Arc::new(move |target: Name, port: u16| { + let active = Arc::clone(&active); + let peak = Arc::clone(&peak); + async move { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(50)).await; + active.fetch_sub(1, Ordering::SeqCst); + let _ = target; + vec![SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + port, + )] + } + }) + }; + + let targets = vec![ + (0, parse_host("mc1.example.com").unwrap(), 25565), + (1, parse_host("mc2.example.com").unwrap(), 25566), + (2, parse_host("mc3.example.com").unwrap(), 25567), + ]; + let resolved = lookup_all(targets, lookup).await; + assert_eq!(peak.load(Ordering::SeqCst), 3); + + let (hostname, addrs) = collect_srv_addrs(resolved); + assert_eq!(hostname, Some("mc3.example.com".to_string())); + assert_eq!( + addrs.iter().map(|addr| addr.port()).collect::>(), + vec![25565, 25566, 25567] + ); + } + + #[tokio::test] + async fn test_resolve_ip_literal_skips_dns() { + let pinger = MinecraftPinger::new().unwrap(); + let (hostname, addrs) = resolve_all_addrs(&pinger, "127.0.0.1", 25565, "tcp") + .await + .unwrap(); + assert!(hostname.is_none()); + assert_eq!( + addrs, + vec![SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 25565 + )] + ); + } + + #[tokio::test] + async fn test_resolve_filtered_addrs_blocks_private_ip() { + let pinger = MinecraftPinger::new().unwrap(); + let filter: Arc = Arc::new(is_public_ip); + let result = + resolve_filtered_addrs(&pinger, "127.0.0.1", 25565, "tcp", Some(&filter)).await; + assert!(matches!(result, Err(PingError::BlockedEndpoint(_)))); + } } diff --git a/src/common/ip_filter.rs b/src/common/ip_filter.rs index 76ba651..5dabe83 100644 --- a/src/common/ip_filter.rs +++ b/src/common/ip_filter.rs @@ -6,6 +6,7 @@ pub type IpFilter = dyn Fn(IpAddr) -> bool + Send + Sync; /// Returns true if the IP belongs to the public internet: excludes private /// ranges, loopback, link-local, CGNAT, documentation, benchmarking, multicast /// and reserved blocks. +#[inline] pub fn is_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_public_ipv4(v4), @@ -13,6 +14,7 @@ pub fn is_public_ip(ip: IpAddr) -> bool { } } +#[inline] fn is_public_ipv4(ip: Ipv4Addr) -> bool { let o = ip.octets(); !( @@ -45,6 +47,7 @@ fn is_public_ipv4(ip: Ipv4Addr) -> bool { ) } +#[inline] fn is_public_ipv6(ip: Ipv6Addr) -> bool { // ::ffff:x.y.z.w - IPv4-mapped: apply IPv4 rules if let Some(mapped) = ip.to_ipv4_mapped() { diff --git a/src/common/protocol.rs b/src/common/protocol.rs index 9715f61..397a1d8 100644 --- a/src/common/protocol.rs +++ b/src/common/protocol.rs @@ -1,10 +1,11 @@ use crate::error::PingError; -use bytes::{Buf, BufMut, Bytes, BytesMut}; +use bytes::{Buf, BufMut, BytesMut}; pub const NEXT_BYTE_MASK: u32 = 0xFFFFFF80; pub const CONTINUATION_BIT: u8 = 0x80; pub const DATA_MASK: u8 = 0x7F; +#[inline] pub(crate) fn write_var_int(buffer: &mut BytesMut, x: i32) { let mut ux = x as u32; // cast to u32 for logical shift while (ux & NEXT_BYTE_MASK) != 0 { @@ -14,6 +15,18 @@ pub(crate) fn write_var_int(buffer: &mut BytesMut, x: i32) { buffer.put_u8(ux as u8); // put the last, often 0x000.. } +#[inline] +pub(crate) fn var_int_len(x: i32) -> usize { + let mut ux = x as u32; + let mut len = 1; + while (ux & NEXT_BYTE_MASK) != 0 { + ux >>= 7; + len += 1; + } + len +} + +#[inline] pub(crate) fn write_string(buffer: &mut BytesMut, string: &str) { write_var_int(buffer, string.len() as i32); buffer.put_slice(string.as_bytes()); @@ -24,8 +37,7 @@ macro_rules! decode_var_int { let mut result = 0i32; let mut shift = 0; loop { - let byte = - $read_byte.map_err(|e| PingError::ReadPacket(format!("{} {e}", $err_msg)))?; + let byte = $read_byte.map_err(|_| PingError::ReadPacket($err_msg))?; result |= ((byte & DATA_MASK) as i32) << shift; if byte & CONTINUATION_BIT == 0 { @@ -35,30 +47,28 @@ macro_rules! decode_var_int { // Security if shift >= 35 { - break Err(PingError::ReadPacket("Shift security".to_string())); + break Err(PingError::ReadPacket("Shift security")); } } }}; } -pub(crate) fn read_var_int(buf: &mut Bytes) -> Result { +#[inline] +pub(crate) fn read_var_int(buf: &mut impl Buf) -> Result { decode_var_int!(buf.try_get_u8(), "Read varint length (v1)") } -pub(crate) async fn read_var_int_stream( - stream: &mut R, -) -> Result { - decode_var_int!(stream.read_u8().await, "Read varint length (v2)") -} - -pub(crate) fn read_string(buf: &mut Bytes) -> Result { +#[inline] +pub(crate) fn read_string(buf: &mut BytesMut) -> Result { let len = read_var_int(buf)? as usize; if buf.remaining() < len { - return Err(PingError::ReadPacket("Remaining < len".to_string())); + return Err(PingError::ReadPacket("Remaining < len")); } - let bytes = buf.split_to(len); - String::from_utf8(bytes.into()) - .map_err(|_| PingError::ReadPacket("Parse to string error".to_string())) + // The JSON is the last field: truncate any trailing bytes, then take + // the whole buffer so its allocation is reused by the String. + buf.truncate(len); + let json = std::mem::replace(buf, BytesMut::new()); + Ok(String::from_utf8(json.into())?) } #[cfg(test)] @@ -114,24 +124,50 @@ mod tests { fn test_write_string() { let mut buf = BytesMut::new(); write_string(&mut buf, "hello"); - let mut data = buf.freeze(); - assert_eq!(read_string(&mut data).unwrap(), "hello"); + assert_eq!(read_string(&mut buf).unwrap(), "hello"); } #[test] fn test_write_empty_string() { let mut buf = BytesMut::new(); write_string(&mut buf, ""); - let mut data = buf.freeze(); - assert_eq!(read_string(&mut data).unwrap(), ""); + assert_eq!(read_string(&mut buf).unwrap(), ""); } #[test] fn test_write_unicode_string() { let mut buf = BytesMut::new(); write_string(&mut buf, "héllo 🌍"); - let mut data = buf.freeze(); - assert_eq!(read_string(&mut data).unwrap(), "héllo 🌍"); + assert_eq!(read_string(&mut buf).unwrap(), "héllo 🌍"); + } + + #[test] + fn test_var_int_len_matches_encoding() { + let mut buf = BytesMut::new(); + for value in 0..70000 { + buf.clear(); + write_var_int(&mut buf, value); + assert_eq!(buf.len(), var_int_len(value)); + } + for value in [ + 127, + 128, + 255, + 16383, + 16384, + 2097151, + 2097152, + 268435455, + 268435456, + i32::MAX, + -1, + -128, + i32::MIN, + ] { + buf.clear(); + write_var_int(&mut buf, value); + assert_eq!(buf.len(), var_int_len(value)); + } } #[test] @@ -151,8 +187,7 @@ mod tests { let mut buf = BytesMut::new(); write_var_int(&mut buf, 10); buf.put_slice(b"hi"); - let mut data = buf.freeze(); - let result = read_string(&mut data); + let result = read_string(&mut buf); assert!(result.is_err()); } } diff --git a/src/config.rs b/src/config.rs index 14828f5..5c3c3ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,10 @@ use crate::common::ip_filter::{IpFilter, is_public_ip}; pub const DEFAULT_PROTOCOL_VERSION: i32 = 777; pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1); +/// Common ping settings shared by the Java and Bedrock pingers. +/// +/// Build with [`PingConfig::builder`]. Cheap to clone (the IP filter, +/// if any, is behind an `Arc`). #[derive(Clone)] pub struct PingConfig { timeout: Duration, @@ -18,18 +22,13 @@ impl PingConfig { PingConfigBuilder::new() } - // Remove it ? - pub fn to_builder(&self) -> PingConfigBuilder { - PingConfigBuilder { - timeout: self.timeout, - ip_filter: self.ip_filter.clone(), - } - } - + /// Maximum time budget for a whole ping (DNS + connect + I/O). pub fn timeout(&self) -> Duration { self.timeout } + /// The IP filter applied to resolved addresses, if any. + /// A ping is only attempted when at least one address is accepted. pub fn ip_filter(&self) -> Option<&Arc> { self.ip_filter.as_ref() } @@ -41,10 +40,11 @@ impl Default for PingConfig { } } +/// Builder for [`PingConfig`]. #[derive(Clone)] pub struct PingConfigBuilder { - timeout: Duration, - ip_filter: Option>, + pub(crate) timeout: Duration, + pub(crate) ip_filter: Option>, } impl Default for PingConfigBuilder { @@ -61,16 +61,23 @@ impl PingConfigBuilder { } } + /// Sets the whole-ping time budget (default: 1 s). pub fn set_timeout(mut self, timeout: Duration) -> PingConfigBuilder { self.timeout = timeout; self } + /// Rejects non-public IPs (loopback, private, link-local, …). + /// This prevents SSRF: resolved addresses pointing at internal + /// infrastructure are refused before any connection is made. pub fn deny_non_public_ips(mut self) -> PingConfigBuilder { self.ip_filter = Some(Arc::new(is_public_ip)); self } + /// Accepts a custom predicate over resolved IPs. + /// Returning `false` rejects the address; when every address is + /// rejected the ping fails with [`PingError::BlockedEndpoint`](crate::error::PingError::BlockedEndpoint). pub fn set_ip_filter(mut self, filter: F) -> PingConfigBuilder where F: Fn(IpAddr) -> bool + Send + Sync + 'static, @@ -148,18 +155,4 @@ mod tests { assert_eq!(config.timeout(), DEFAULT_TIMEOUT); assert!(config.ip_filter().is_none()); } - - #[test] - fn test_ping_config_to_builder() { - let original = PingConfigBuilder::new() - .set_timeout(Duration::from_secs(10)) - .deny_non_public_ips() - .build(); - - let builder = original.to_builder(); - let rebuilt = builder.build(); - - assert_eq!(rebuilt.timeout(), Duration::from_secs(10)); - assert!(rebuilt.ip_filter().is_some()); - } } diff --git a/src/error.rs b/src/error.rs index 9f2aa7c..607fef8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,5 @@ use hickory_resolver::net::NetError; +use std::net::IpAddr; use std::string::FromUtf8Error; use thiserror::Error; use tokio::time::error::Elapsed; @@ -18,7 +19,7 @@ pub enum PingError { SendPacket, #[error("Failed to read packet: {0}")] - ReadPacket(String), + ReadPacket(&'static str), #[error("Invalid UTF-8: {0}")] Utf8Error(#[from] FromUtf8Error), @@ -41,8 +42,8 @@ pub enum PingError { #[error("Initialization error: {0}")] Init(String), - #[error("Endpoint blocked by IP filter: {0}")] - BlockedEndpoint(String), + #[error("Endpoint blocked by IP filter: {0:?}")] + BlockedEndpoint(Vec), } #[cfg(test)] @@ -86,7 +87,7 @@ mod tests { #[test] fn test_ping_error_read_packet() { - let ping_error = PingError::ReadPacket("test error".to_string()); + let ping_error = PingError::ReadPacket("test error"); assert_eq!(ping_error.to_string(), "Failed to read packet: test error"); } @@ -149,10 +150,10 @@ mod tests { #[test] fn test_ping_error_blocked_endpoint() { - let ping_error = PingError::BlockedEndpoint("10.0.0.1".to_string()); + let ping_error = PingError::BlockedEndpoint(vec!["10.0.0.1".parse().unwrap()]); assert_eq!( ping_error.to_string(), - "Endpoint blocked by IP filter: 10.0.0.1" + "Endpoint blocked by IP filter: [10.0.0.1]" ); } diff --git a/src/java/component.rs b/src/java/component.rs new file mode 100644 index 0000000..67f58c7 --- /dev/null +++ b/src/java/component.rs @@ -0,0 +1,409 @@ +use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +/// Server MOTD: either a legacy plain string or a JSON chat component. +/// Deserialized in a single pass (no `untagged` retry). +#[derive(Serialize)] +#[serde(untagged)] +pub enum Description { + Plain(String), + Component(TextComponent), +} + +impl<'de> Deserialize<'de> for Description { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(DescriptionVisitor) + } +} + +struct DescriptionVisitor; + +impl<'de> Visitor<'de> for DescriptionVisitor { + type Value = Description; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string, map or sequence") + } + + fn visit_str(self, value: &str) -> Result { + Ok(Description::Plain(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Description::Plain(value)) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result { + Ok(Description::Plain(value.to_owned())) + } + + fn visit_bytes(self, value: &[u8]) -> Result { + Ok(Description::Plain( + std::str::from_utf8(value).map_err(E::custom)?.to_owned(), + )) + } + + fn visit_borrowed_bytes(self, value: &'de [u8]) -> Result { + Ok(Description::Plain( + std::str::from_utf8(value).map_err(E::custom)?.to_owned(), + )) + } + + fn visit_map>(self, map: A) -> Result { + Ok(Description::Component(TextComponent::deserialize( + MapAccessDeserializer::new(map), + )?)) + } + + fn visit_seq>(self, seq: A) -> Result { + Ok(Description::Component(TextComponent::deserialize( + SeqAccessDeserializer::new(seq), + )?)) + } +} + +impl Description { + /// Renders the MOTD as plain text (formatting codes stripped). + pub fn to_plain_text(&self) -> String { + match self { + Description::Plain(s) => s.clone(), + Description::Component(c) => c.to_plain_text(), + } + } +} + +/// A JSON chat component: object, raw string, or nested array. +/// Deserialized in a single pass (no `untagged` retry). +#[derive(Serialize)] +#[serde(untagged)] +pub enum TextComponent { + Object { + text: String, + + #[serde(skip_serializing_if = "Option::is_none")] + hat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + player: Option, + #[serde(skip_serializing_if = "Option::is_none")] + shadow_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + atlas: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sprite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + strikethrough: Option, + #[serde(skip_serializing_if = "Option::is_none")] + obfuscated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + italic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + underlined: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + extra: Option>, + }, + String(String), + Array(Vec), +} + +#[derive(Deserialize)] +struct ComponentObject { + #[serde(default)] + text: String, + hat: Option, + player: Option, + #[serde(alias = "shadowColor")] + shadow_color: Option, + atlas: Option, + sprite: Option, + bold: Option, + strikethrough: Option, + obfuscated: Option, + italic: Option, + underlined: Option, + font: Option, + color: Option, + extra: Option>, +} + +impl From for TextComponent { + fn from(raw: ComponentObject) -> Self { + TextComponent::Object { + text: raw.text, + hat: raw.hat, + player: raw.player, + shadow_color: raw.shadow_color, + atlas: raw.atlas, + sprite: raw.sprite, + bold: raw.bold, + strikethrough: raw.strikethrough, + obfuscated: raw.obfuscated, + italic: raw.italic, + underlined: raw.underlined, + font: raw.font, + color: raw.color, + extra: raw.extra, + } + } +} + +impl<'de> Deserialize<'de> for TextComponent { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(TextComponentVisitor) + } +} + +struct TextComponentVisitor; + +impl<'de> Visitor<'de> for TextComponentVisitor { + type Value = TextComponent; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string, map or sequence") + } + + fn visit_str(self, value: &str) -> Result { + Ok(TextComponent::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(TextComponent::String(value)) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result { + Ok(TextComponent::String(value.to_owned())) + } + + fn visit_bytes(self, value: &[u8]) -> Result { + Ok(TextComponent::String( + std::str::from_utf8(value).map_err(E::custom)?.to_owned(), + )) + } + + fn visit_borrowed_bytes(self, value: &'de [u8]) -> Result { + Ok(TextComponent::String( + std::str::from_utf8(value).map_err(E::custom)?.to_owned(), + )) + } + + fn visit_map>(self, map: A) -> Result { + Ok(ComponentObject::deserialize(MapAccessDeserializer::new(map))?.into()) + } + + fn visit_seq>(self, seq: A) -> Result { + Ok(TextComponent::Array(Vec::deserialize( + SeqAccessDeserializer::new(seq), + )?)) + } +} + +impl TextComponent { + /// Renders the component (and nested `extra`) as plain text. + pub fn to_plain_text(&self) -> String { + let mut out = String::new(); + self.write_plain_text(&mut out); + out + } + + fn write_plain_text(&self, out: &mut String) { + match self { + TextComponent::String(s) => out.push_str(s), + TextComponent::Array(items) => { + for item in items { + item.write_plain_text(out); + } + } + TextComponent::Object { text, extra, .. } => { + out.push_str(text); + if let Some(extra) = extra { + for item in extra { + item.write_plain_text(out); + } + } + } + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum PlayerField { + Name(String), + Full(Player), +} + +#[derive(Serialize, Deserialize)] +pub struct Player { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct Property { + pub name: String, + pub value: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_text_component_string_variant() { + let json = json!("simple string"); + let component: TextComponent = serde_json::from_value(json).unwrap(); + + match component { + TextComponent::String(s) => assert_eq!(s, "simple string"), + _ => panic!("Expected string variant"), + } + } + + #[test] + fn test_text_component_array_variant() { + let json = json!([{"text": "part1"}, {"text": "part2"}]); + let component: TextComponent = serde_json::from_value(json).unwrap(); + + match component { + TextComponent::Array(arr) => { + assert_eq!(arr.len(), 2); + match &arr[0] { + TextComponent::Object { text, .. } => assert_eq!(text, "part1"), + _ => panic!("Expected object"), + } + } + _ => panic!("Expected array variant"), + } + } + + #[test] + fn test_description_to_plain_text_plain() { + let description = Description::Plain("A Minecraft Server".to_string()); + assert_eq!(description.to_plain_text(), "A Minecraft Server"); + } + + #[test] + fn test_description_to_plain_text_component_with_extra() { + let json = json!({ + "text": "Hello ", + "color": "green", + "extra": [{"text": "World"}, ["!", {"text": "!"}]] + }); + let component: TextComponent = serde_json::from_value(json).unwrap(); + let description = Description::Component(component); + assert_eq!(description.to_plain_text(), "Hello World!!"); + } + + #[test] + fn test_text_component_to_plain_text_string_variant() { + let component = TextComponent::String("simple".to_string()); + assert_eq!(component.to_plain_text(), "simple"); + } + + #[test] + fn test_description_serialize_keeps_content_shape() { + let plain = Description::Plain("hi".to_string()); + assert_eq!(serde_json::to_value(&plain).unwrap(), json!("hi")); + + let component = Description::Component(TextComponent::Object { + text: "hi".to_string(), + hat: None, + player: None, + shadow_color: None, + atlas: None, + sprite: None, + bold: None, + strikethrough: None, + obfuscated: None, + italic: None, + underlined: None, + font: None, + color: Some("green".to_string()), + extra: None, + }); + assert_eq!( + serde_json::to_value(&component).unwrap(), + json!({"text": "hi", "color": "green"}) + ); + } + + #[test] + fn test_description_empty_object() { + let description: Description = serde_json::from_value(json!({})).unwrap(); + match description { + Description::Component(TextComponent::Object { text, .. }) => { + assert_eq!(text, "") + } + _ => panic!("Expected component with empty text"), + } + } + + #[test] + fn test_description_ignores_unknown_fields() { + let description: Description = + serde_json::from_value(json!({"text": "a", "unknown": 1})).unwrap(); + match description { + Description::Component(TextComponent::Object { text, .. }) => assert_eq!(text, "a"), + _ => panic!("Expected component"), + } + } + + #[test] + fn test_description_rejects_duplicate_field() { + let result: Result = serde_json::from_str(r#"{"text": "a", "text": "b"}"#); + assert!(result.is_err()); + } + + #[test] + fn test_description_rejects_scalars() { + for raw in ["5", "true", "null"] { + let result: Result = serde_json::from_str(raw); + assert!(result.is_err(), "expected error for {raw}"); + } + } + + #[test] + fn test_description_rejects_null_text() { + let result: Result = serde_json::from_str(r#"{"text": null}"#); + assert!(result.is_err()); + } + + #[test] + fn test_description_accepts_padded_string() { + let description: Description = serde_json::from_str(r#" "hi" "#).unwrap(); + assert!(matches!(description, Description::Plain(s) if s == "hi")); + } + + #[test] + fn test_description_nested_mixed_extra() { + let description: Description = serde_json::from_value(json!({ + "text": "a", + "extra": ["b", {"text": "c"}, ["d"]] + })) + .unwrap(); + assert_eq!(description.to_plain_text(), "abcd"); + } + + #[test] + fn test_description_shadow_color_alias() { + let description: Description = + serde_json::from_value(json!({"text": "a", "shadowColor": 1})).unwrap(); + match description { + Description::Component(TextComponent::Object { shadow_color, .. }) => { + assert_eq!(shadow_color, Some(1)) + } + _ => panic!("Expected component"), + } + } +} diff --git a/src/java/config.rs b/src/java/config.rs index 1fe47aa..f1ec38e 100644 --- a/src/java/config.rs +++ b/src/java/config.rs @@ -1,11 +1,18 @@ use crate::config::{DEFAULT_PROTOCOL_VERSION, PingConfig, PingConfigBuilder}; use std::time::Duration; +/// Java-edition ping settings: common [`PingConfig`] plus handshake options. +/// +/// The `skip_*` flags avoid parsing (and allocating) the heaviest +/// payload fields — useful for fleet scans that only need +/// online/max counts and the MOTD. #[derive(Clone)] pub struct JavaPingConfig { common: PingConfig, protocol_version: i32, hostname: Option, + skip_favicon: bool, + skip_sample: bool, } impl JavaPingConfig { @@ -13,10 +20,12 @@ impl JavaPingConfig { JavaPingConfigBuilder::new() } - pub fn from(config: &PingConfigBuilder) -> JavaPingConfigBuilder { + /// Builds from shared [`PingConfig`] settings (timeout, IP filter). + pub fn from(config: &PingConfig) -> JavaPingConfigBuilder { JavaPingConfigBuilder::from(config) } + /// Protocol ID sent in the handshake (default: current supported version). pub fn protocol_version(&self) -> i32 { self.protocol_version } @@ -25,9 +34,21 @@ impl JavaPingConfig { &self.common } + /// Hostname override sent in the handshake instead of the resolved name. pub fn hostname(&self) -> &Option { &self.hostname } + + /// When `true`, the `favicon` field is skipped while parsing + /// (saves a ~20–130 KB base64 allocation per server). + pub fn skip_favicon(&self) -> bool { + self.skip_favicon + } + + /// When `true`, the `players.sample` list is skipped while parsing. + pub fn skip_sample(&self) -> bool { + self.skip_sample + } } impl Default for JavaPingConfig { @@ -40,6 +61,8 @@ pub struct JavaPingConfigBuilder { common: PingConfigBuilder, protocol_version: i32, hostname: Option, + skip_favicon: bool, + skip_sample: bool, } impl Default for JavaPingConfigBuilder { @@ -54,14 +77,21 @@ impl JavaPingConfigBuilder { common: PingConfigBuilder::new(), protocol_version: DEFAULT_PROTOCOL_VERSION, hostname: None, + skip_favicon: false, + skip_sample: false, } } - pub fn from(config: &PingConfigBuilder) -> Self { + pub fn from(config: &PingConfig) -> Self { JavaPingConfigBuilder { - common: config.clone(), + common: PingConfigBuilder { + timeout: config.timeout(), + ip_filter: config.ip_filter().cloned(), + }, protocol_version: DEFAULT_PROTOCOL_VERSION, hostname: None, + skip_favicon: false, + skip_sample: false, } } @@ -85,11 +115,25 @@ impl JavaPingConfigBuilder { self } + /// Skips the `favicon` field while parsing. + pub fn skip_favicon(mut self) -> Self { + self.skip_favicon = true; + self + } + + /// Skips the `players.sample` list while parsing. + pub fn skip_sample(mut self) -> Self { + self.skip_sample = true; + self + } + pub fn build(self) -> JavaPingConfig { JavaPingConfig { common: self.common.build(), hostname: self.hostname, protocol_version: self.protocol_version, + skip_favicon: self.skip_favicon, + skip_sample: self.skip_sample, } } } @@ -138,10 +182,28 @@ mod tests { } #[test] - fn test_java_ping_config_builder_from_ping_config_builder() { + fn test_java_ping_config_builder_skip_flags_default_off() { + let config = JavaPingConfigBuilder::new().build(); + assert!(!config.skip_favicon()); + assert!(!config.skip_sample()); + } + + #[test] + fn test_java_ping_config_builder_skip_flags() { + let config = JavaPingConfigBuilder::new() + .skip_favicon() + .skip_sample() + .build(); + assert!(config.skip_favicon()); + assert!(config.skip_sample()); + } + + #[test] + fn test_java_ping_config_builder_from_ping_config() { let base = PingConfigBuilder::new() .set_timeout(Duration::from_secs(10)) - .deny_non_public_ips(); + .deny_non_public_ips() + .build(); let config = JavaPingConfigBuilder::from(&base).build(); assert_eq!(config.common().timeout(), Duration::from_secs(10)); assert!(config.common().ip_filter().is_some()); diff --git a/src/java/mod.rs b/src/java/mod.rs index 3735cea..604dc7f 100644 --- a/src/java/mod.rs +++ b/src/java/mod.rs @@ -1,3 +1,4 @@ +pub mod component; pub mod config; pub mod model; pub mod pinger; diff --git a/src/java/model.rs b/src/java/model.rs index c4996a5..c346709 100644 --- a/src/java/model.rs +++ b/src/java/model.rs @@ -1,6 +1,9 @@ +pub use super::component::{Description, Player, PlayerField, Property, TextComponent}; +use serde::de::IgnoredAny; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; +/// Parsed Java status response plus measured `latency` (ms). #[derive(Serialize, Deserialize)] pub struct JavaPing { pub version: Version, @@ -17,111 +20,80 @@ pub struct JavaPing { pub connected_addr: Option, } -// Components -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum Description { - Plain(String), - Component(TextComponent), -} - -impl Description { - pub fn to_plain_text(&self) -> String { - match self { - Description::Plain(s) => s.clone(), - Description::Component(c) => c.to_plain_text(), - } - } +// Same payload as JavaPing, but favicon and player sample can be +// replaced with IgnoredAny to skip their allocation while parsing. +#[derive(Deserialize)] +pub(crate) struct RawJavaPing { + version: Version, + players: RawPlayers, + description: Description, + favicon: F, + #[serde(rename = "modinfo")] + mod_info: Option, } -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum TextComponent { - Object { - #[serde(default)] - text: String, - - #[serde(skip_serializing_if = "Option::is_none")] - hat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - player: Option, - #[serde(skip_serializing_if = "Option::is_none", alias = "shadowColor")] - shadow_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - atlas: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sprite: Option, - #[serde(skip_serializing_if = "Option::is_none")] - bold: Option, - #[serde(skip_serializing_if = "Option::is_none")] - strikethrough: Option, - #[serde(skip_serializing_if = "Option::is_none")] - obfuscated: Option, - #[serde(skip_serializing_if = "Option::is_none")] - italic: Option, - #[serde(skip_serializing_if = "Option::is_none")] - underlined: Option, - #[serde(skip_serializing_if = "Option::is_none")] - font: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - extra: Option>, - }, - String(String), - Array(Vec), +#[derive(Deserialize)] +pub(crate) struct RawPlayers { + online: i32, + max: i32, + sample: S, } -impl TextComponent { - pub fn to_plain_text(&self) -> String { - let mut out = String::new(); - self.write_plain_text(&mut out); - out - } - - fn write_plain_text(&self, out: &mut String) { - match self { - TextComponent::String(s) => out.push_str(s), - TextComponent::Array(items) => { - for item in items { - item.write_plain_text(out); - } - } - TextComponent::Object { text, extra, .. } => { - out.push_str(text); - if let Some(extra) = extra { - for item in extra { - item.write_plain_text(out); - } - } - } +impl From, Option>> for JavaPing { + fn from(raw: RawJavaPing, Option>) -> Self { + JavaPing { + version: raw.version, + players: Players { + online: raw.players.online, + max: raw.players.max, + sample: None, + }, + description: raw.description, + favicon: None, + mod_info: raw.mod_info, + latency: 0, + connected_addr: None, } } } -// Components end - -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum PlayerField { - Name(String), - Full(Player), -} -#[derive(Serialize, Deserialize)] -pub struct Player { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>, +impl From, Option>>> for JavaPing { + fn from(raw: RawJavaPing, Option>>) -> Self { + JavaPing { + version: raw.version, + players: Players { + online: raw.players.online, + max: raw.players.max, + sample: raw.players.sample, + }, + description: raw.description, + favicon: None, + mod_info: raw.mod_info, + latency: 0, + connected_addr: None, + } + } } -#[derive(Serialize, Deserialize)] -pub struct Property { - pub name: String, - pub value: String, +impl From, Option>> for JavaPing { + fn from(raw: RawJavaPing, Option>) -> Self { + JavaPing { + version: raw.version, + players: Players { + online: raw.players.online, + max: raw.players.max, + sample: None, + }, + description: raw.description, + favicon: raw.favicon, + mod_info: raw.mod_info, + latency: 0, + connected_addr: None, + } + } } +// Components live in `super::component`, re-exported above. #[derive(Serialize, Deserialize)] pub struct Version { pub name: String, @@ -262,34 +234,6 @@ mod tests { assert_eq!(ping.players.sample.as_ref().unwrap()[0].name, "Player1"); } - #[test] - fn test_text_component_string_variant() { - let json = json!("simple string"); - let component: TextComponent = serde_json::from_value(json).unwrap(); - - match component { - TextComponent::String(s) => assert_eq!(s, "simple string"), - _ => panic!("Expected string variant"), - } - } - - #[test] - fn test_text_component_array_variant() { - let json = json!([{"text": "part1"}, {"text": "part2"}]); - let component: TextComponent = serde_json::from_value(json).unwrap(); - - match component { - TextComponent::Array(arr) => { - assert_eq!(arr.len(), 2); - match &arr[0] { - TextComponent::Object { text, .. } => assert_eq!(text, "part1"), - _ => panic!("Expected object"), - } - } - _ => panic!("Expected array variant"), - } - } - #[test] fn test_version_deserialize() { let json = json!({"name": "1.20.1", "protocol": 775}); @@ -315,27 +259,52 @@ mod tests { assert!(players.sample.is_some()); } + fn skipped_json() -> serde_json::Value { + json!({ + "version": {"name": "1.20.1", "protocol": 775}, + "players": { + "online": 5, + "max": 20, + "sample": [{"name": "Player1", "id": "uuid1"}] + }, + "description": "Test", + "favicon": "data:image/png;base64,abc123", + "modinfo": null + }) + } + #[test] - fn test_description_to_plain_text_plain() { - let description = Description::Plain("A Minecraft Server".to_string()); - assert_eq!(description.to_plain_text(), "A Minecraft Server"); + fn test_raw_ping_skip_favicon_only() { + let raw: RawJavaPing, Option>> = + serde_json::from_value(skipped_json()).unwrap(); + let ping = JavaPing::from(raw); + + assert!(ping.favicon.is_none()); + assert_eq!(ping.players.sample.as_ref().unwrap().len(), 1); + assert_eq!(ping.version.name, "1.20.1"); } #[test] - fn test_description_to_plain_text_component_with_extra() { - let json = json!({ - "text": "Hello ", - "color": "green", - "extra": [{"text": "World"}, ["!", {"text": "!"}]] - }); - let component: TextComponent = serde_json::from_value(json).unwrap(); - let description = Description::Component(component); - assert_eq!(description.to_plain_text(), "Hello World!!"); + fn test_raw_ping_skip_sample_only() { + let raw: RawJavaPing, Option> = + serde_json::from_value(skipped_json()).unwrap(); + let ping = JavaPing::from(raw); + + assert_eq!( + ping.favicon, + Some("data:image/png;base64,abc123".to_string()) + ); + assert!(ping.players.sample.is_none()); } #[test] - fn test_text_component_to_plain_text_string_variant() { - let component = TextComponent::String("simple".to_string()); - assert_eq!(component.to_plain_text(), "simple"); + fn test_raw_ping_skip_both() { + let raw: RawJavaPing, Option> = + serde_json::from_value(skipped_json()).unwrap(); + let ping = JavaPing::from(raw); + + assert!(ping.favicon.is_none()); + assert!(ping.players.sample.is_none()); + assert_eq!(ping.players.online, 5); } } diff --git a/src/java/pinger.rs b/src/java/pinger.rs index 7c41d37..f754e21 100644 --- a/src/java/pinger.rs +++ b/src/java/pinger.rs @@ -3,28 +3,32 @@ use crate::common::dns::resolve_filtered_addrs; use crate::common::protocol::read_string; use crate::error::PingError; use crate::java::config::JavaPingConfig; -use crate::java::model::JavaPing; +use crate::java::model::{JavaPing, PlayerInfo, RawJavaPing}; use crate::java::protocol::{read_packet, write_ping_handshake, write_ping_request}; use bytes::BytesMut; use log::debug; +use serde::de::IgnoredAny; use std::time::Instant; -use tokio::io::{AsyncWriteExt, BufReader}; +use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; -use tokio::time::timeout; +use tokio::time::{Instant as TokioInstant, timeout_at}; impl MinecraftPinger { + /// Pings a Java-edition server (status handshake + request). + /// + /// Resolves `_minecraft._tcp.` SRV first, falls back to A/AAAA, + /// applies the configured IP filter, then reports `latency` (ms) and + /// the address actually connected to. The whole call is bounded by + /// a single deadline derived from the configured timeout. pub async fn ping_java_server( &self, ip: &str, port: u16, config: &JavaPingConfig, ) -> Result { - let rs = timeout( - config.common().timeout(), - self.ping_java_server_internal(ip, port, config), - ) - .await??; - Ok(rs) + let deadline = TokioInstant::now() + config.common().timeout(); + self.ping_java_server_internal(ip, port, config, deadline) + .await } async fn ping_java_server_internal( @@ -32,29 +36,25 @@ impl MinecraftPinger { ip: &str, port: u16, config: &JavaPingConfig, + deadline: TokioInstant, ) -> Result { debug!("Pinging server {}:{}", ip, port); - let (hostname, addrs) = - resolve_filtered_addrs(self, ip, port, "tcp", config.common().ip_filter()).await?; + let (hostname, addrs) = timeout_at( + deadline, + resolve_filtered_addrs(self, ip, port, "tcp", config.common().ip_filter()), + ) + .await??; - let stream_future = TcpStream::connect(&addrs[..]); - let mut stream = timeout(config.common().timeout(), stream_future) + let mut stream = timeout_at(deadline, TcpStream::connect(&addrs[..])) .await? .map_err(|e| { debug!("Connection error: {}", e); PingError::ConnectionRefused })?; + let _ = stream.set_nodelay(true); - stream.set_nodelay(true).unwrap_or_default(); - - debug!( - "Stream connected to {}", - stream - .peer_addr() - .map(|a| a.to_string()) - .unwrap_or_else(|_| "unknown".to_string()) - ); + debug!("Stream connected to {:?}", stream.peer_addr().ok()); let start_time = Instant::now(); @@ -71,25 +71,116 @@ impl MinecraftPinger { ); write_ping_request(&mut buffer); - stream - .write_all(&buffer.freeze()) - .await + timeout_at(deadline, stream.write_all(&buffer.freeze())) + .await? .map_err(|_| PingError::SendPacket)?; debug!("Stream all packets !"); let connected_addr = stream.peer_addr().ok(); - let mut buffered_reader = BufReader::new(&mut stream); - let mut packet = read_packet(&mut buffered_reader).await?; + let mut packet = timeout_at(deadline, read_packet(&mut stream)).await??; debug!("Received Packet ID: {}", packet.id()); let json = read_string(&mut packet.data)?; let latency = start_time.elapsed().as_millis() as u32; - let mut as_res = serde_json::from_str::(&json)?; + let mut as_res: JavaPing = match (config.skip_favicon(), config.skip_sample()) { + (false, false) => serde_json::from_str(&json)?, + (true, false) => serde_json::from_str::< + RawJavaPing, Option>>, + >(&json)? + .into(), + (false, true) => { + serde_json::from_str::, Option>>(&json)? + .into() + } + (true, true) => { + serde_json::from_str::, Option>>(&json)? + .into() + } + }; as_res.latency = latency; as_res.connected_addr = connected_addr; Ok(as_res) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::protocol::{write_string, write_var_int}; + use tokio::io::AsyncReadExt; + use tokio::net::TcpListener; + + const STATUS_BODY: &str = "{\"version\":{\"name\":\"1.20.1\",\"protocol\":775},\"players\":{\"online\":5,\"max\":20,\"sample\":[{\"name\":\"Player1\",\"id\":\"uuid1\"}]},\"description\":\"Test\",\"favicon\":\"data:image/png;base64,abc123\"}"; + + async fn fake_server(listener: TcpListener, pings: usize) { + for _ in 0..pings { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 512]; + let _ = socket.read(&mut buf).await.unwrap(); + + let mut payload = BytesMut::new(); + write_var_int(&mut payload, 0); + write_string(&mut payload, STATUS_BODY); + let mut response = BytesMut::new(); + write_var_int(&mut response, payload.len() as i32); + response.extend_from_slice(&payload); + socket.write_all(&response.freeze()).await.unwrap(); + } + } + + #[tokio::test] + async fn ping_loopback_skip_flags() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(fake_server(listener, 4)); + + let pinger = MinecraftPinger::new().unwrap(); + + let full = pinger + .ping_java_server("127.0.0.1", port, &JavaPingConfig::default()) + .await + .unwrap(); + assert!(full.favicon.is_some()); + assert_eq!(full.players.sample.as_ref().unwrap().len(), 1); + + let no_favicon = pinger + .ping_java_server( + "127.0.0.1", + port, + &JavaPingConfig::builder().skip_favicon().build(), + ) + .await + .unwrap(); + assert!(no_favicon.favicon.is_none()); + assert!(no_favicon.players.sample.is_some()); + + let no_sample = pinger + .ping_java_server( + "127.0.0.1", + port, + &JavaPingConfig::builder().skip_sample().build(), + ) + .await + .unwrap(); + assert!(no_sample.favicon.is_some()); + assert!(no_sample.players.sample.is_none()); + + let neither = pinger + .ping_java_server( + "127.0.0.1", + port, + &JavaPingConfig::builder() + .skip_favicon() + .skip_sample() + .build(), + ) + .await + .unwrap(); + assert!(neither.favicon.is_none()); + assert!(neither.players.sample.is_none()); + assert_eq!(neither.version.name, "1.20.1"); + } +} diff --git a/src/java/protocol.rs b/src/java/protocol.rs index 44f4617..52e87c3 100644 --- a/src/java/protocol.rs +++ b/src/java/protocol.rs @@ -1,9 +1,14 @@ -use crate::common::protocol::{read_var_int, read_var_int_stream, write_string, write_var_int}; +use crate::common::protocol::{ + CONTINUATION_BIT, DATA_MASK, read_var_int, var_int_len, write_string, write_var_int, +}; use crate::error::PingError; -use bytes::{BufMut, Bytes, BytesMut}; +use crate::error::PingError::ReadPacket; +use bytes::{Buf, BufMut, BytesMut}; use log::debug; use tokio::io::AsyncReadExt; +const MAX_PACKET_SIZE: usize = 1 << 20; + // Packets pub(crate) fn write_ping_handshake( buffer: &mut BytesMut, @@ -11,15 +16,18 @@ pub(crate) fn write_ping_handshake( port: &u16, protocol_version: &i32, ) { - let mut handshake = BytesMut::with_capacity(128); - handshake.put_u8(0x00); - write_var_int(&mut handshake, *protocol_version); // protocol version - write_string(&mut handshake, hostname); - handshake.put_u16(*port); // Server Port - write_var_int(&mut handshake, 1); // next state = 1 status - - write_var_int(buffer, handshake.len() as i32); - buffer.put(handshake.freeze()); + let payload_len = 1 + + var_int_len(*protocol_version) + + var_int_len(hostname.len() as i32) + + hostname.len() + + 2 + + var_int_len(1); + write_var_int(buffer, payload_len as i32); + buffer.put_u8(0x00); + write_var_int(buffer, *protocol_version); // protocol version + write_string(buffer, hostname); + buffer.put_u16(*port); // Server Port + write_var_int(buffer, 1); // next state = 1 status } pub(crate) fn write_ping_request(buffer: &mut BytesMut) { @@ -29,11 +37,11 @@ pub(crate) fn write_ping_request(buffer: &mut BytesMut) { pub(crate) struct Packet { id: u8, - pub data: Bytes, + pub data: BytesMut, } impl Packet { - pub fn new(id: u8, data: Bytes) -> Packet { + pub fn new(id: u8, data: BytesMut) -> Packet { Packet { id, data } } @@ -42,28 +50,75 @@ impl Packet { } } +fn decode_var_int_prefix(buf: &[u8]) -> Result, PingError> { + let mut result = 0i32; + for (index, byte) in buf.iter().enumerate().take(5) { + result |= ((*byte & DATA_MASK) as i32) << (index * 7); + if *byte & CONTINUATION_BIT == 0 { + return Ok(Some((result, index + 1))); + } + } + if buf.len() >= 5 { + return Err(ReadPacket("Shift security")); + } + Ok(None) +} + +#[inline] +async fn fill_buf( + stream: &mut R, + buf: &mut BytesMut, +) -> Result<(), PingError> { + if stream.read_buf(buf).await.map_err(|e| { + debug!("Read packet error 2 {e}"); + e + })? == 0 + { + return Err(ReadPacket("Unexpected EOF")); + } + Ok(()) +} + pub(crate) async fn read_packet( stream: &mut R, ) -> Result { - let length = read_var_int_stream(stream).await?; + let mut buf = BytesMut::with_capacity(256); + + // Reads in large chunks: the first read already holds + // the length VarInt and usually the whole packet. + let length = loop { + if let Some((value, width)) = decode_var_int_prefix(&buf)? { + buf.advance(width); + break value; + } + fill_buf(stream, &mut buf).await?; + }; + if length < 0 || length as usize > MAX_PACKET_SIZE { + return Err(ReadPacket("packet too big")); + } - // Lire exactement `length` bytes - let mut buf = vec![0u8; length as usize]; - stream.read_exact(&mut buf).await.map_err(|e| { - debug!("Read packet error 2 {e}"); - PingError::ReadPacket(e.to_string()) - })?; + let length = length as usize; + buf.reserve(length.saturating_sub(buf.len())); + + // The payload start may already sit in memory, + // so only the missing bytes get fetched. + while buf.len() < length { + fill_buf(stream, &mut buf).await?; + } - let mut data = Bytes::from(buf); + // Take the whole packet without splitting: split would share the + // allocation and force a copy later when building the JSON string. + let mut data = std::mem::replace(&mut buf, BytesMut::new()); + data.truncate(length); + let packet_id = read_var_int(&mut data)? as u8; - Ok(Packet::new(read_var_int(&mut data)? as u8, data)) + Ok(Packet::new(packet_id, data)) } #[cfg(test)] mod tests { use super::*; use bytes::BytesMut; - use tokio::io::BufReader; #[test] fn test_write_ping_handshake() { @@ -94,7 +149,7 @@ mod tests { #[test] fn test_packet_new_and_id() { - let packet = Packet::new(0x01, Bytes::from_static(b"test")); + let packet = Packet::new(0x01, BytesMut::from(&b"test"[..])); assert_eq!(packet.id(), 0x01); } @@ -104,7 +159,7 @@ mod tests { write_ping_request(&mut buffer); let data = buffer.freeze(); - let mut reader = BufReader::new(&data[..]); + let mut reader = &data[..]; let packet = read_packet(&mut reader).await.unwrap(); assert_eq!(packet.id(), 0x00); @@ -112,11 +167,37 @@ mod tests { #[tokio::test] async fn test_read_packet_invalid_length() { - let data = Bytes::from_static(&[ - 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, - ]); - let mut reader = BufReader::new(&data[..]); + let data = BytesMut::from( + &[ + 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, + ][..], + ); + let mut reader = &data[..]; let result = read_packet(&mut reader).await; assert!(result.is_err()); } + + #[tokio::test] + async fn test_read_packet_too_big() { + // VarInt(MAX_PACKET_SIZE + 1): rejected before any payload read. + let data = BytesMut::from(&[0x81, 0x80, 0x40, 0x00][..]); + let mut reader = &data[..]; + let result = read_packet(&mut reader).await; + assert!(matches!( + result, + Err(PingError::ReadPacket("packet too big")) + )); + } + + #[tokio::test] + async fn test_read_packet_negative_length() { + // VarInt(-1): rejected by the same guard. + let data = BytesMut::from(&[0xFF, 0xFF, 0xFF, 0xFF, 0x0F][..]); + let mut reader = &data[..]; + let result = read_packet(&mut reader).await; + assert!(matches!( + result, + Err(PingError::ReadPacket("packet too big")) + )); + } } diff --git a/src/lib.rs b/src/lib.rs index f4a027d..d7784c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,26 @@ +//! High-performance, asynchronous Minecraft server pinger. +//! +//! Supports Java (status protocol) and Bedrock (unconnected pong) editions. +//! [`MinecraftPinger`] owns the shared DNS resolver; clone it freely, +//! it is cheap (`Arc`-backed). +//! +//! ```rust,no_run +//! use minecraft_pinger::{MinecraftPinger, config::PingConfig}; +//! use minecraft_pinger::java::config::JavaPingConfig; +//! use std::time::Duration; +//! +//! #[tokio::main] +//! async fn main() { +//! let pinger = MinecraftPinger::new().unwrap(); +//! let config = PingConfig::builder() +//! .set_timeout(Duration::from_secs(3)) +//! .build(); +//! let java_config = JavaPingConfig::from(&config).build(); +//! let status = pinger.ping_java_server("mc.hypixel.net", 25565, &java_config).await.unwrap(); +//! println!("{}: {}/{}", status.version.name, status.players.online, status.players.max); +//! } +//! ``` + pub mod common; pub mod config; pub mod error; @@ -17,12 +40,17 @@ use hickory_resolver::config::{CLOUDFLARE, ResolverConfig}; use hickory_resolver::net::runtime::TokioRuntimeProvider; use std::sync::Arc; +/// Shared entry point for pinging Minecraft servers. +/// +/// Holds the DNS resolver (SRV + A/AAAA, Cloudflare) behind an `Arc`, +/// so cloning is cheap. Create once, reuse for batch scans. #[derive(Debug, Clone)] pub struct MinecraftPinger { dns_resolver: Arc>, } impl MinecraftPinger { + /// Builds a pinger with the default shared DNS resolver. pub fn new() -> Result { let resolver = Resolver::builder_with_config( ResolverConfig::udp_and_tcp(&CLOUDFLARE), diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index d0fb5e6..c5691cb 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -91,7 +91,7 @@ fn test_ip_filter_private_ips() { #[test] fn test_bedrock_ping_parsing() { let response_str = "MCPE;Test Server;589;1.20.0;10;20;1234567890;world;Survival;1;19132;123"; - let result = BedrockPing::try_from(response_str.to_string()).unwrap(); + let result = BedrockPing::try_from(response_str).unwrap(); assert_eq!(result.edition, "MCPE"); assert_eq!(result.motd, "Test Server"); @@ -128,7 +128,8 @@ fn test_config_builder_chaining() { fn test_java_ping_config_from_ping_config() { let base = PingConfigBuilder::new() .set_timeout(Duration::from_secs(10)) - .deny_non_public_ips(); + .deny_non_public_ips() + .build(); let config = JavaPingConfigBuilder::from(&base).build();