Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8d08936
fix: security OOM
Flash303 Oct 25, 2026
850dae2
feat: less heap usage
Flash303 Oct 25, 2026
74430b4
feat: better timeout
Flash303 Oct 25, 2026
6992556
opt: remove useless buffer
Flash303 Oct 25, 2026
b50fc18
opt: add ping_bedrock_with_socket
Flash303 Oct 25, 2026
3fb2f5c
opt: parallel srv dns lookup
Flash303 Oct 25, 2026
3c6c356
opt: config to skip favicon & sample
Flash303 Oct 25, 2026
c5e8e7d
opt: readpacket error String -> &str
Flash303 Oct 25, 2026
143c6d2
opt: blocked endpoint error String -> Vec<IpAddr>
Flash303 Oct 25, 2026
2a84b46
test: security
Flash303 Oct 25, 2026
e6ceeb9
fix: truncated buffer security
Flash303 Oct 25, 2026
92247ff
move "take" to method
Flash303 Oct 25, 2026
898114e
tests: fake server
Flash303 Oct 25, 2026
e470089
opt: dns
Flash303 Oct 25, 2026
bd64ba9
opt: incremental GUID
Flash303 Oct 25, 2026
5b4a5c5
opt: string alloc
Flash303 Oct 25, 2026
e692a32
opt: remove one clone
Flash303 Oct 25, 2026
06d232c
opt: inline decode method
Flash303 Oct 25, 2026
7f2a4d4
no delay unwarp
Flash303 Oct 25, 2026
e66b6ff
config: breaking change
Flash303 Oct 25, 2026
b2ff56f
opt: motd serialisation
Flash303 Oct 25, 2026
57973f1
opt: bedrock parsing
Flash303 Oct 25, 2026
5adfb46
opt: remove handshake buffer
Flash303 Oct 25, 2026
e8e0f73
opt: inline
Flash303 Oct 25, 2026
e7c36bc
feat: add docs
Flash303 Oct 25, 2026
90aab0e
feat: bench tool
Flash303 Oct 25, 2026
a2f4966
chore: upgrade version
Flash303 Oct 25, 2026
6561d09
docs: update readme
Flash303 Oct 25, 2026
5c0242f
update git ignore
Flash303 Oct 25, 2026
f665566
fmt
Flash303 Oct 25, 2026
c5a6b44
fix: use from str trait
Flash303 Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
target/
.idea/
.idea/
.local/
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -25,4 +25,3 @@ thiserror = "2.*"
bytes = "1.*"

log = "0.4.*"
rand = "0.10.*"
108 changes: 95 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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();

Expand All @@ -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) => {
Expand All @@ -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.
Expand All @@ -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<String>` | 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<IpAddr>)`: 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<IpAddr>)`: 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<String>` shim delegates to it).
- Dependency `rand` removed (Bedrock GUIDs use an atomic counter).

## 📄 License

Expand Down
132 changes: 132 additions & 0 deletions examples/bench_fleet.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Fleet bench: pings every server in a JSON list and writes results.
//! Usage: bench_fleet <servers.json> <out.json> [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<u32>,
elapsed_ms: u64,
err: Option<String>,
}

#[derive(serde::Serialize)]
struct Report {
wall_ms: u64,
success: usize,
failed: usize,
rows: Vec<Row>,
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
let args: Vec<String> = std::env::args().collect();
let list_path = args.get(1).cloned().expect("missing <servers.json>");
let out_path = args.get(2).cloned().expect("missing <out.json>");
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<Server> = 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<Row> = 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<u32>, err: Option<String>, 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
);
}
Loading
Loading