Server projects post launch fixes (#5481)

* Vendor async-minecraft-ping and fix servers returning protocol version -1

* Don't have automod reject server projects

* fmt

* Add region to search facets

* remove AMP .github
This commit is contained in:
aecsocket
2026-03-08 00:17:38 +00:00
committed by GitHub
parent 507d03eeba
commit ace2659861
20 changed files with 1836 additions and 25 deletions

121
Cargo.lock generated
View File

@@ -378,6 +378,15 @@ dependencies = [
"libc",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -643,12 +652,13 @@ dependencies = [
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "git+https://github.com/jsvana/async-minecraft-ping?rev=56a64a8a59de854fb81cc0f9f66c2285873d960c#56a64a8a59de854fb81cc0f9f66c2285873d960c"
dependencies = [
"anyhow",
"async-trait",
"hickory-resolver 0.24.4",
"serde",
"serde_json",
"structopt",
"thiserror 1.0.69",
"tokio",
]
@@ -864,6 +874,17 @@ dependencies = [
"webpki-roots 1.0.3",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -1581,6 +1602,21 @@ dependencies = [
"libloading 0.8.8",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width 0.1.14",
"vec_map",
]
[[package]]
name = "clap"
version = "4.5.48"
@@ -1600,7 +1636,7 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
"strsim 0.11.1",
]
[[package]]
@@ -2200,7 +2236,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.106",
]
@@ -2214,7 +2250,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.106",
]
@@ -2227,7 +2263,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.106",
]
@@ -3764,6 +3800,15 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -3776,6 +3821,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -4447,7 +4501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e96d2465363ed2d81857759fc864cf6bb7997f79327aec028d65bd7989393685"
dependencies = [
"ahash 0.8.12",
"clap",
"clap 4.5.48",
"crossbeam-channel",
"crossbeam-utils",
"dashmap",
@@ -4562,7 +4616,7 @@ version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"hermit-abi 0.5.2",
"libc",
"windows-sys 0.59.0",
]
@@ -4830,7 +4884,7 @@ dependencies = [
"bytes",
"censor",
"chrono",
"clap",
"clap 4.5.48",
"clickhouse",
"color-eyre",
"color-thief",
@@ -5418,7 +5472,7 @@ name = "modrinth-maxmind"
version = "0.0.0"
dependencies = [
"bytes",
"clap",
"clap 4.5.48",
"directories",
"eyre",
"flate2",
@@ -5826,7 +5880,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"hermit-abi 0.5.2",
"libc",
]
@@ -6834,7 +6888,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"hermit-abi 0.5.2",
"pin-project-lite",
"rustix 1.1.2",
"windows-sys 0.61.2",
@@ -9312,6 +9366,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -9341,6 +9401,30 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "structopt"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10"
dependencies = [
"clap 2.34.0",
"lazy_static",
"structopt-derive",
]
[[package]]
name = "structopt-derive"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0"
dependencies = [
"heck 0.3.3",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "strum"
version = "0.27.2"
@@ -10043,6 +10127,15 @@ dependencies = [
"url",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "theseus"
version = "1.0.0-local"
@@ -11184,6 +11277,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version-compare"
version = "0.2.0"

View File

@@ -33,7 +33,7 @@ arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { git = "https://github.com/jsvana/async-minecraft-ping", rev = "56a64a8a59de854fb81cc0f9f66c2285873d960c" }
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",

View File

@@ -176,9 +176,9 @@ pub async fn init_client_with_database(
latency_ms Nullable(UInt32),
description Nullable(String),
version_name Nullable(String),
version_protocol Nullable(UInt32),
players_online Nullable(UInt32),
players_max Nullable(UInt32)
version_protocol Nullable(Int32),
players_online Nullable(Int32),
players_max Nullable(Int32)
)
ENGINE = {engine}
{ttl}

View File

@@ -387,13 +387,13 @@ pub struct JavaServerPingData {
/// Reported version name of the server.
pub version_name: String,
/// Reported version protocol number of the server.
pub version_protocol: u32,
pub version_protocol: i32,
/// Description/MOTD of the server as shown in the server list.
pub description: String,
/// Number of players online at the time.
pub players_online: u32,
pub players_online: i32,
/// Maximum number of players allowed on the server.
pub players_max: u32,
pub players_max: i32,
}
component::relations! {

View File

@@ -656,7 +656,7 @@ impl AutomatedModerationQueue {
)
.await?;
if mod_messages.should_reject(first_time) {
if mod_messages.should_reject(first_time) && !is_server_project {
ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::StatusChange {

View File

@@ -305,9 +305,9 @@ struct ServerPingRecord {
latency_ms: Option<u32>,
description: Option<String>,
version_name: Option<String>,
version_protocol: Option<u32>,
players_online: Option<u32>,
players_max: Option<u32>,
version_protocol: Option<i32>,
players_online: Option<i32>,
players_max: Option<i32>,
}
#[cfg(test)]

View File

@@ -641,6 +641,7 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[
"client_side",
"server_side",
"minecraft_server.country",
"minecraft_server.region",
"minecraft_server.languages",
"minecraft_java_server.content.kind",
"minecraft_java_server.content.supported_game_versions",

View File

@@ -973,8 +973,8 @@ async fn _get_server_status_new(
};
let players = ServerPlayers {
max: status.players.max.cast_signed(),
online: status.players.online.cast_signed(),
max: status.players.max,
online: status.players.online,
sample: status
.players
.sample

View File

@@ -43,7 +43,7 @@ pub struct ServerGameProfile {
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerVersion {
pub name: String,
pub protocol: u32,
pub protocol: i32,
#[serde(skip_deserializing)]
pub legacy: bool,
}

View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

View File

@@ -0,0 +1 @@
edition = "2018"

View File

@@ -0,0 +1,34 @@
[package]
name = "async-minecraft-ping"
version = "0.8.0"
authors = ["Jay Vana <jaysvana@gmail.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
description = "An async Rust client for the Minecraft ServerListPing protocol"
readme = "README.md"
repository = "https://github.com/jsvana/async-minecraft-ping/"
keywords = ["mc", "minecraft", "serverlistping"]
categories = ["api-bindings", "asynchronous"]
[dependencies]
async-trait = "0.1"
hickory-resolver = { version = "0.24", optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
[dependencies.tokio]
version = "1.15"
features = ["io-util", "net", "time"]
[dev-dependencies]
anyhow = "1.0"
structopt = "0.3"
[dev-dependencies.tokio]
version = "1.15"
features = ["io-util", "macros", "net", "rt-multi-thread"]
[features]
default = []
srv = ["hickory-resolver"]

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,72 @@
> [!NOTE]
>
> This is a vendored version of [`async-minecraft-ping`](https://github.com/jsvana/async-minecraft-ping) with modifications for our own purposes.
>
> This directory is licensed under the same license as the original project.
# async-minecraft-ping
[![crates.io](https://img.shields.io/crates/v/async-minecraft-ping)][crate]
[![docs.rs](https://docs.rs/async-minecraft-ping/badge.svg)][docs]
[![CI](https://github.com/jsvana/async-minecraft-ping/actions/workflows/ci.yml/badge.svg)](https://github.com/jsvana/async-minecraft-ping/actions/workflows/ci.yml)
![crates.io](https://img.shields.io/crates/l/async-minecraft-ping/0.1.0)
An async [ServerListPing](https://wiki.vg/Server_List_Ping) client implementation in Rust.
## Usage
See [the example](./examples/status.rs).
```rust
let config = ConnectionConfig::build("mc.example.com")
.with_port(25565)
.with_timeout(Duration::from_secs(5));
let connection = config.connect().await?;
let status = connection.status().await?;
println!(
"{} of {} player(s) online",
status.status.players.online, status.status.players.max
);
```
## Features
### SRV Record Lookup
Enable the `srv` feature to support automatic SRV record resolution for Minecraft servers:
```toml
[dependencies]
async-minecraft-ping = { version = "0.8", features = ["srv"] }
```
```rust
let config = ConnectionConfig::build("skyblock.net")
.with_srv_lookup() // Resolves _minecraft._tcp.skyblock.net
.connect()
.await?;
```
When SRV lookup is enabled, the library queries `_minecraft._tcp.<address>` for an SRV record. If found, it uses the target host and port from the record. If not found, it falls back to the original address and port.
## License
Licensed under either of
* Apache License, Version 2.0
([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license
([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
[crate]: https://crates.io/crates/async-minecraft-ping
[docs]: https://docs.rs/async-minecraft-ping

View File

@@ -0,0 +1,48 @@
use anyhow::Result;
use structopt::StructOpt;
use async_minecraft_ping::ConnectionConfig;
#[derive(Debug, StructOpt)]
#[structopt(name = "example")]
struct Args {
/// Server to connect to
#[structopt()]
address: String,
/// Port to connect to
#[structopt(short = "p", long = "port")]
port: Option<u16>,
/// Enable SRV record lookup (requires 'srv' feature)
#[cfg(feature = "srv")]
#[structopt(long = "srv")]
srv_lookup: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::from_args();
let mut config = ConnectionConfig::build(args.address);
if let Some(port) = args.port {
config = config.with_port(port);
}
#[cfg(feature = "srv")]
if args.srv_lookup {
config = config.with_srv_lookup();
}
let connection = config.connect().await?;
let connection = connection.status().await?;
println!(
"{} of {} player(s) online",
connection.status.players.online, connection.status.players.max
);
connection.ping(42).await?;
Ok(())
}

View File

@@ -0,0 +1,6 @@
mod protocol;
mod server;
pub use server::{
connect, ConnectionConfig, ServerDescription, ServerError, ServerPlayer, ServerPlayers,
ServerVersion, StatusConnection, StatusResponse,
};

View File

@@ -0,0 +1,576 @@
//! This module defines various methods to read and
//! write packets in Minecraft's
//! [ServerListPing](https://wiki.vg/Server_List_Ping)
//! protocol.
use std::io::Cursor;
use std::time::Duration;
use async_trait::async_trait;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
#[derive(Error, Debug)]
pub enum ProtocolError {
#[error("error reading or writing data")]
Io(#[from] std::io::Error),
#[error("invalid packet length")]
InvalidPacketLength,
#[error("invalid varint data")]
InvalidVarInt,
#[error("invalid packet (expected ID {expected:?}, actual ID {actual:?})")]
InvalidPacketId { expected: usize, actual: usize },
#[error("invalid ServerListPing response body (invalid UTF-8)")]
InvalidResponseBody,
#[error("connection timed out")]
Timeout(#[from] tokio::time::error::Elapsed),
}
/// State represents the desired next state of the
/// exchange.
///
/// It's a bit silly now as there's only
/// one entry, but technically there is more than
/// one type that can be sent here.
#[derive(Clone, Copy)]
pub enum State {
Status,
}
impl From<State> for usize {
fn from(state: State) -> Self {
match state {
State::Status => 1,
}
}
}
/// RawPacket is the underlying wrapper of data that
/// gets read from and written to the socket.
///
/// Typically, the flow looks like this:
/// 1. Construct a specific packet (HandshakePacket
/// for example).
/// 2. Write that packet's contents to a byte buffer.
/// 3. Construct a RawPacket using that byte buffer.
/// 4. Write the RawPacket to the socket.
struct RawPacket {
id: usize,
data: Box<[u8]>,
}
impl RawPacket {
fn new(id: usize, data: Box<[u8]>) -> Self {
RawPacket { id, data }
}
}
/// AsyncWireReadExt adds varint and varint-backed
/// string support to things that implement AsyncRead.
#[async_trait]
pub trait AsyncWireReadExt {
async fn read_varint(&mut self) -> Result<usize, ProtocolError>;
async fn read_string(&mut self) -> Result<String, ProtocolError>;
}
#[async_trait]
impl<R: AsyncRead + Unpin + Send + Sync> AsyncWireReadExt for R {
async fn read_varint(&mut self) -> Result<usize, ProtocolError> {
let mut read = 0;
let mut result = 0;
loop {
let read_value = self.read_u8().await?;
let value = read_value & 0b0111_1111;
result |= (value as usize) << (7 * read);
read += 1;
if read > 5 {
return Err(ProtocolError::InvalidVarInt);
}
if (read_value & 0b1000_0000) == 0 {
return Ok(result);
}
}
}
async fn read_string(&mut self) -> Result<String, ProtocolError> {
let length = self.read_varint().await?;
let mut buffer = vec![0; length];
self.read_exact(&mut buffer).await?;
Ok(String::from_utf8(buffer).map_err(|_| ProtocolError::InvalidResponseBody)?)
}
}
/// AsyncWireWriteExt adds varint and varint-backed
/// string support to things that implement AsyncWrite.
#[async_trait]
pub trait AsyncWireWriteExt {
async fn write_varint(&mut self, int: usize) -> Result<(), ProtocolError>;
async fn write_string(&mut self, string: &str) -> Result<(), ProtocolError>;
}
#[async_trait]
impl<W: AsyncWrite + Unpin + Send + Sync> AsyncWireWriteExt for W {
async fn write_varint(&mut self, int: usize) -> Result<(), ProtocolError> {
let mut int = (int as u64) & 0xFFFF_FFFF;
let mut written = 0;
let mut buffer = [0; 5];
loop {
let temp = (int & 0b0111_1111) as u8;
int >>= 7;
if int != 0 {
buffer[written] = temp | 0b1000_0000;
} else {
buffer[written] = temp;
}
written += 1;
if int == 0 {
break;
}
}
self.write_all(&buffer[0..written]).await?;
Ok(())
}
async fn write_string(&mut self, string: &str) -> Result<(), ProtocolError> {
self.write_varint(string.len()).await?;
self.write_all(string.as_bytes()).await?;
Ok(())
}
}
/// PacketId is used to allow AsyncWriteRawPacket
/// to generically get a packet's ID.
pub trait PacketId {
fn get_packet_id(&self) -> usize;
}
/// ExpectedPacketId is used to allow AsyncReadRawPacket
/// to generically get a packet's expected ID.
pub trait ExpectedPacketId {
fn get_expected_packet_id() -> usize;
}
/// AsyncReadFromBuffer is used to allow
/// AsyncReadRawPacket to generically read a
/// packet's specific data from a buffer.
#[async_trait]
pub trait AsyncReadFromBuffer: Sized {
async fn read_from_buffer(buffer: Vec<u8>) -> Result<Self, ProtocolError>;
}
/// AsyncWriteToBuffer is used to allow
/// AsyncWriteRawPacket to generically write a
/// packet's specific data into a buffer.
#[async_trait]
pub trait AsyncWriteToBuffer {
async fn write_to_buffer(&self) -> Result<Vec<u8>, ProtocolError>;
}
/// AsyncReadRawPacket is the core piece of
/// the read side of the protocol. It allows
/// the user to construct a specific packet
/// from something that implements AsyncRead.
#[async_trait]
pub trait AsyncReadRawPacket {
async fn read_packet<T: ExpectedPacketId + AsyncReadFromBuffer + Send + Sync>(
&mut self,
) -> Result<T, ProtocolError>;
async fn read_packet_with_timeout<T: ExpectedPacketId + AsyncReadFromBuffer + Send + Sync>(
&mut self,
timeout: Duration,
) -> Result<T, ProtocolError>;
}
#[async_trait]
impl<R: AsyncRead + Unpin + Send + Sync> AsyncReadRawPacket for R {
async fn read_packet<T: ExpectedPacketId + AsyncReadFromBuffer + Send + Sync>(
&mut self,
) -> Result<T, ProtocolError> {
let length = self.read_varint().await?;
if length == 0 {
return Err(ProtocolError::InvalidPacketLength);
}
let packet_id = self.read_varint().await?;
let expected_packet_id = T::get_expected_packet_id();
if packet_id != expected_packet_id {
return Err(ProtocolError::InvalidPacketId {
expected: expected_packet_id,
actual: packet_id,
});
}
let mut buffer = vec![0; length - 1];
self.read_exact(&mut buffer).await?;
T::read_from_buffer(buffer).await
}
async fn read_packet_with_timeout<T: ExpectedPacketId + AsyncReadFromBuffer + Send + Sync>(
&mut self,
timeout: Duration,
) -> Result<T, ProtocolError> {
tokio::time::timeout(timeout, self.read_packet()).await?
}
}
/// AsyncWriteRawPacket is the core piece of
/// the write side of the protocol. It allows
/// the user to write a specific packet to
/// something that implements AsyncWrite.
#[async_trait]
pub trait AsyncWriteRawPacket {
async fn write_packet<T: PacketId + AsyncWriteToBuffer + Send + Sync>(
&mut self,
packet: T,
) -> Result<(), ProtocolError>;
async fn write_packet_with_timeout<T: PacketId + AsyncWriteToBuffer + Send + Sync>(
&mut self,
packet: T,
timeout: Duration,
) -> Result<(), ProtocolError>;
}
#[async_trait]
impl<W: AsyncWrite + Unpin + Send + Sync> AsyncWriteRawPacket for W {
async fn write_packet<T: PacketId + AsyncWriteToBuffer + Send + Sync>(
&mut self,
packet: T,
) -> Result<(), ProtocolError> {
let packet_buffer = packet.write_to_buffer().await?;
let raw_packet = RawPacket::new(packet.get_packet_id(), packet_buffer.into_boxed_slice());
let mut buffer: Cursor<Vec<u8>> = Cursor::new(Vec::new());
buffer.write_varint(raw_packet.id).await?;
buffer.write_all(&raw_packet.data).await?;
let inner = buffer.into_inner();
self.write_varint(inner.len()).await?;
self.write_all(&inner).await?;
Ok(())
}
async fn write_packet_with_timeout<T: PacketId + AsyncWriteToBuffer + Send + Sync>(
&mut self,
packet: T,
timeout: Duration,
) -> Result<(), ProtocolError> {
tokio::time::timeout(timeout, self.write_packet(packet)).await?
}
}
/// HandshakePacket is the first of two packets
/// to be sent during a status check for
/// ServerListPing.
pub struct HandshakePacket {
pub packet_id: usize,
pub protocol_version: usize,
pub server_address: String,
pub server_port: u16,
pub next_state: State,
}
impl HandshakePacket {
pub fn new(protocol_version: usize, server_address: String, server_port: u16) -> Self {
Self {
packet_id: 0,
protocol_version,
server_address,
server_port,
next_state: State::Status,
}
}
}
#[async_trait]
impl AsyncWriteToBuffer for HandshakePacket {
async fn write_to_buffer(&self) -> Result<Vec<u8>, ProtocolError> {
let mut buffer = Cursor::new(Vec::<u8>::new());
buffer.write_varint(self.protocol_version).await?;
buffer.write_string(&self.server_address).await?;
buffer.write_u16(self.server_port).await?;
buffer.write_varint(self.next_state.into()).await?;
Ok(buffer.into_inner())
}
}
impl PacketId for HandshakePacket {
fn get_packet_id(&self) -> usize {
self.packet_id
}
}
/// RequestPacket is the second of two packets
/// to be sent during a status check for
/// ServerListPing.
pub struct RequestPacket {
pub packet_id: usize,
}
impl RequestPacket {
pub fn new() -> Self {
Self { packet_id: 0 }
}
}
#[async_trait]
impl AsyncWriteToBuffer for RequestPacket {
async fn write_to_buffer(&self) -> Result<Vec<u8>, ProtocolError> {
Ok(Vec::new())
}
}
impl PacketId for RequestPacket {
fn get_packet_id(&self) -> usize {
self.packet_id
}
}
/// ResponsePacket is the response from the
/// server to a status check for
/// ServerListPing.
pub struct ResponsePacket {
#[allow(dead_code)]
pub packet_id: usize,
pub body: String,
}
impl ExpectedPacketId for ResponsePacket {
fn get_expected_packet_id() -> usize {
0
}
}
#[async_trait]
impl AsyncReadFromBuffer for ResponsePacket {
async fn read_from_buffer(buffer: Vec<u8>) -> Result<Self, ProtocolError> {
let mut reader = Cursor::new(buffer);
let body = reader.read_string().await?;
Ok(ResponsePacket { packet_id: 0, body })
}
}
pub struct PingPacket {
pub packet_id: usize,
pub payload: u64,
}
impl PingPacket {
pub fn new(payload: u64) -> Self {
Self {
packet_id: 1,
payload,
}
}
}
#[async_trait]
impl AsyncWriteToBuffer for PingPacket {
async fn write_to_buffer(&self) -> Result<Vec<u8>, ProtocolError> {
let mut buffer = Cursor::new(Vec::<u8>::new());
buffer.write_u64(self.payload).await?;
Ok(buffer.into_inner())
}
}
impl PacketId for PingPacket {
fn get_packet_id(&self) -> usize {
self.packet_id
}
}
pub struct PongPacket {
#[allow(dead_code)]
pub packet_id: usize,
pub payload: u64,
}
impl ExpectedPacketId for PongPacket {
fn get_expected_packet_id() -> usize {
1
}
}
#[async_trait]
impl AsyncReadFromBuffer for PongPacket {
async fn read_from_buffer(buffer: Vec<u8>) -> Result<Self, ProtocolError> {
let mut reader = Cursor::new(buffer);
let payload = reader.read_u64().await?;
Ok(PongPacket {
packet_id: 0,
payload,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[tokio::test]
async fn test_varint_roundtrip() {
let test_cases = vec![
0usize, 1, 127, 128, 255, 256, 16383, 16384, 2097151, 2097152, 268435455,
];
for value in test_cases {
let mut buffer = Cursor::new(Vec::new());
buffer.write_varint(value).await.unwrap();
let mut reader = Cursor::new(buffer.into_inner());
let result = reader.read_varint().await.unwrap();
assert_eq!(value, result, "Varint roundtrip failed for {}", value);
}
}
#[tokio::test]
async fn test_varint_encoding() {
// Test specific known encodings
let cases = vec![
(0usize, vec![0x00]),
(1, vec![0x01]),
(127, vec![0x7f]),
(128, vec![0x80, 0x01]),
(255, vec![0xff, 0x01]),
(25565, vec![0xdd, 0xc7, 0x01]),
(2097151, vec![0xff, 0xff, 0x7f]),
];
for (value, expected) in cases {
let mut buffer = Cursor::new(Vec::new());
buffer.write_varint(value).await.unwrap();
assert_eq!(
buffer.into_inner(),
expected,
"Varint encoding failed for {}",
value
);
}
}
#[tokio::test]
async fn test_string_roundtrip() {
let test_cases = vec![
"",
"hello",
"localhost",
"mc.example.com",
"こんにちは", // Unicode
];
for s in test_cases {
let mut buffer = Cursor::new(Vec::new());
buffer.write_string(s).await.unwrap();
let mut reader = Cursor::new(buffer.into_inner());
let result = reader.read_string().await.unwrap();
assert_eq!(s, result, "String roundtrip failed for {:?}", s);
}
}
#[tokio::test]
async fn test_handshake_packet_serialization() {
let packet = HandshakePacket::new(578, "localhost".to_string(), 25565);
let buffer = packet.write_to_buffer().await.unwrap();
// Verify the buffer contains expected data
let mut reader = Cursor::new(buffer);
// Protocol version (578 as varint)
let protocol = reader.read_varint().await.unwrap();
assert_eq!(protocol, 578);
// Server address
let address = reader.read_string().await.unwrap();
assert_eq!(address, "localhost");
// Server port (big-endian u16)
let port = reader.read_u16().await.unwrap();
assert_eq!(port, 25565);
// Next state (1 for status)
let state = reader.read_varint().await.unwrap();
assert_eq!(state, 1);
}
#[tokio::test]
async fn test_request_packet_serialization() {
let packet = RequestPacket::new();
let buffer = packet.write_to_buffer().await.unwrap();
// Request packet has no data
assert!(buffer.is_empty());
}
#[tokio::test]
async fn test_ping_packet_serialization() {
let packet = PingPacket::new(12345678);
let buffer = packet.write_to_buffer().await.unwrap();
// Ping packet contains a u64 payload (8 bytes, big-endian)
assert_eq!(buffer.len(), 8);
let mut reader = Cursor::new(buffer);
let payload = reader.read_u64().await.unwrap();
assert_eq!(payload, 12345678);
}
#[tokio::test]
async fn test_response_packet_deserialization() {
// Create a buffer with a JSON string
let json = r#"{"version":{"name":"1.20.4","protocol":765}}"#;
let mut buffer = Cursor::new(Vec::new());
buffer.write_string(json).await.unwrap();
let packet = ResponsePacket::read_from_buffer(buffer.into_inner())
.await
.unwrap();
assert_eq!(packet.body, json);
}
#[tokio::test]
async fn test_pong_packet_deserialization() {
let payload: u64 = 987654321;
let buffer = payload.to_be_bytes().to_vec();
let packet = PongPacket::read_from_buffer(buffer).await.unwrap();
assert_eq!(packet.payload, payload);
}
#[tokio::test]
async fn test_invalid_varint() {
// A varint with more than 5 continuation bytes is invalid
let invalid = vec![0x80, 0x80, 0x80, 0x80, 0x80, 0x80];
let mut reader = Cursor::new(invalid);
let result = reader.read_varint().await;
assert!(matches!(result, Err(ProtocolError::InvalidVarInt)));
}
}

View File

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

View File

@@ -0,0 +1,320 @@
//! Integration tests using mocked TCP streams
use std::io::Cursor;
use std::time::Duration;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
// Re-implement the wire protocol traits for testing
// since they're not exported from the library
#[async_trait]
trait AsyncWireWriteExt {
async fn write_varint(&mut self, int: usize) -> std::io::Result<()>;
async fn write_string(&mut self, string: &str) -> std::io::Result<()>;
}
#[async_trait]
impl<W: tokio::io::AsyncWrite + Unpin + Send + Sync> AsyncWireWriteExt for W {
async fn write_varint(&mut self, int: usize) -> std::io::Result<()> {
let mut int = (int as u64) & 0xFFFF_FFFF;
let mut written = 0;
let mut buffer = [0; 5];
loop {
let temp = (int & 0b0111_1111) as u8;
int >>= 7;
if int != 0 {
buffer[written] = temp | 0b1000_0000;
} else {
buffer[written] = temp;
}
written += 1;
if int == 0 {
break;
}
}
self.write_all(&buffer[0..written]).await?;
Ok(())
}
async fn write_string(&mut self, string: &str) -> std::io::Result<()> {
self.write_varint(string.len()).await?;
self.write_all(string.as_bytes()).await?;
Ok(())
}
}
#[allow(dead_code)]
#[async_trait]
trait AsyncWireReadExt {
async fn read_varint(&mut self) -> std::io::Result<usize>;
async fn read_string(&mut self) -> std::io::Result<String>;
}
#[async_trait]
impl<R: tokio::io::AsyncRead + Unpin + Send + Sync> AsyncWireReadExt for R {
async fn read_varint(&mut self) -> std::io::Result<usize> {
let mut read = 0;
let mut result = 0;
loop {
let mut buf = [0u8; 1];
self.read_exact(&mut buf).await?;
let read_value = buf[0];
let value = read_value & 0b0111_1111;
result |= (value as usize) << (7 * read);
read += 1;
if read > 5 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"invalid varint",
));
}
if (read_value & 0b1000_0000) == 0 {
return Ok(result);
}
}
}
async fn read_string(&mut self) -> std::io::Result<String> {
let length = self.read_varint().await?;
let mut buffer = vec![0; length];
self.read_exact(&mut buffer).await?;
String::from_utf8(buffer)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid utf8"))
}
}
/// Helper to write a raw packet (length-prefixed with packet ID)
async fn write_raw_packet(
stream: &mut DuplexStream,
packet_id: usize,
data: &[u8],
) -> std::io::Result<()> {
let mut packet_buffer = Cursor::new(Vec::new());
packet_buffer.write_varint(packet_id).await?;
packet_buffer.write_all(data).await?;
let inner = packet_buffer.into_inner();
stream.write_varint(inner.len()).await?;
stream.write_all(&inner).await?;
Ok(())
}
/// Helper to read a raw packet and return (packet_id, data)
async fn read_raw_packet(stream: &mut DuplexStream) -> std::io::Result<(usize, Vec<u8>)> {
let length = stream.read_varint().await?;
if length == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"empty packet",
));
}
let packet_id = stream.read_varint().await?;
let mut data = vec![0u8; length - 1]; // -1 for the packet_id varint (assuming single byte)
if !data.is_empty() {
stream.read_exact(&mut data).await?;
}
Ok((packet_id, data))
}
/// Simulate a Minecraft server that responds to status requests
async fn mock_server(mut stream: DuplexStream, response_json: &str) {
// Read handshake packet (id=0)
let (packet_id, _data) = read_raw_packet(&mut stream).await.unwrap();
assert_eq!(packet_id, 0, "Expected handshake packet");
// Read request packet (id=0)
let (packet_id, _data) = read_raw_packet(&mut stream).await.unwrap();
assert_eq!(packet_id, 0, "Expected request packet");
// Send response packet (id=0) with JSON
let mut response_data = Cursor::new(Vec::new());
response_data.write_string(response_json).await.unwrap();
write_raw_packet(&mut stream, 0, &response_data.into_inner())
.await
.unwrap();
// Read ping packet (id=1)
let (packet_id, data) = read_raw_packet(&mut stream).await.unwrap();
assert_eq!(packet_id, 1, "Expected ping packet");
// Send pong packet (id=1) with same payload
write_raw_packet(&mut stream, 1, &data).await.unwrap();
}
/// Simulate a server that sends invalid JSON
#[allow(dead_code)]
async fn mock_server_invalid_json(mut stream: DuplexStream) {
// Read handshake
let _ = read_raw_packet(&mut stream).await.unwrap();
// Read request
let _ = read_raw_packet(&mut stream).await.unwrap();
// Send invalid JSON response
let mut response_data = Cursor::new(Vec::new());
response_data.write_string("not valid json").await.unwrap();
write_raw_packet(&mut stream, 0, &response_data.into_inner())
.await
.unwrap();
}
/// Simulate a server that returns wrong pong payload
#[allow(dead_code)]
async fn mock_server_wrong_pong(mut stream: DuplexStream, response_json: &str) {
// Read handshake
let _ = read_raw_packet(&mut stream).await.unwrap();
// Read request
let _ = read_raw_packet(&mut stream).await.unwrap();
// Send valid response
let mut response_data = Cursor::new(Vec::new());
response_data.write_string(response_json).await.unwrap();
write_raw_packet(&mut stream, 0, &response_data.into_inner())
.await
.unwrap();
// Read ping
let _ = read_raw_packet(&mut stream).await.unwrap();
// Send pong with different payload
let wrong_payload: u64 = 99999;
write_raw_packet(&mut stream, 1, &wrong_payload.to_be_bytes())
.await
.unwrap();
}
fn valid_status_json() -> &'static str {
r#"{"version":{"name":"1.20.4","protocol":765},"players":{"max":20,"online":5},"description":"Test Server"}"#
}
#[tokio::test]
async fn test_mock_server_protocol() {
// Test that our mock server correctly handles the Minecraft protocol
let (mut client_stream, server_stream) = tokio::io::duplex(1024);
// Spawn mock server
let server_handle = tokio::spawn(mock_server(server_stream, valid_status_json()));
// Simulate client sending handshake packet (id=0)
let mut handshake_data = Cursor::new(Vec::new());
handshake_data.write_varint(578).await.unwrap(); // protocol version
handshake_data.write_string("localhost").await.unwrap(); // server address
handshake_data.write_u16(25565).await.unwrap(); // port
handshake_data.write_varint(1).await.unwrap(); // next state (status)
write_raw_packet(&mut client_stream, 0, &handshake_data.into_inner())
.await
.unwrap();
// Send request packet (id=0, empty)
write_raw_packet(&mut client_stream, 0, &[]).await.unwrap();
// Read response packet
let (packet_id, data) = read_raw_packet(&mut client_stream).await.unwrap();
assert_eq!(packet_id, 0);
// Parse the JSON from response
let mut cursor = Cursor::new(data);
let json_str = cursor.read_string().await.unwrap();
assert!(json_str.contains("1.20.4"));
assert!(json_str.contains("Test Server"));
// Send ping packet (id=1)
let ping_payload: u64 = 12345;
write_raw_packet(&mut client_stream, 1, &ping_payload.to_be_bytes())
.await
.unwrap();
// Read pong packet
let (packet_id, data) = read_raw_packet(&mut client_stream).await.unwrap();
assert_eq!(packet_id, 1);
assert_eq!(data.len(), 8);
let pong_payload = u64::from_be_bytes(data.try_into().unwrap());
assert_eq!(pong_payload, ping_payload);
server_handle.await.unwrap();
}
#[tokio::test]
async fn test_status_json_parsing_plain_description() {
use async_minecraft_ping::StatusResponse;
let json = r#"{
"version": {"name": "1.20.4", "protocol": 765},
"players": {"max": 100, "online": 42},
"description": "Plain text MOTD"
}"#;
let response: StatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.version.name, "1.20.4");
assert_eq!(response.version.protocol, 765);
assert_eq!(response.players.max, 100);
assert_eq!(response.players.online, 42);
}
#[tokio::test]
async fn test_status_json_parsing_object_description() {
use async_minecraft_ping::StatusResponse;
let json = r#"{
"version": {"name": "1.19.4", "protocol": 762},
"players": {"max": 50, "online": 10, "sample": [{"name": "Notch", "id": "069a79f4-44e9-4726-a5be-fca90e38aaf5"}]},
"description": {"text": "Object MOTD"},
"favicon": "data:image/png;base64,abc123"
}"#;
let response: StatusResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.version.name, "1.19.4");
assert_eq!(response.players.online, 10);
assert!(response.players.sample.is_some());
assert_eq!(response.players.sample.as_ref().unwrap().len(), 1);
assert_eq!(response.players.sample.as_ref().unwrap()[0].name, "Notch");
assert!(response.favicon.is_some());
}
#[tokio::test]
async fn test_connection_config_builder_chain() {
use async_minecraft_ping::ConnectionConfig;
let _config = ConnectionConfig::build("mc.example.com")
.with_port(25566)
.with_protocol_version(47)
.with_timeout(Duration::from_secs(5));
// We can't directly inspect private fields, but we can verify
// the builder pattern works without panicking
}
#[tokio::test]
async fn test_connection_refused() {
use async_minecraft_ping::ConnectionConfig;
// Try to connect to a port that's definitely not listening
let result = ConnectionConfig::build("127.0.0.1")
.with_port(1) // Port 1 is privileged and unlikely to have a server
.with_timeout(Duration::from_millis(100))
.connect()
.await;
assert!(result.is_err());
}
#[cfg(feature = "srv")]
#[tokio::test]
async fn test_srv_lookup_fallback() {
use async_minecraft_ping::ConnectionConfig;
// This domain definitely doesn't have an SRV record
// The library should fall back to the original address
let result = ConnectionConfig::build("127.0.0.1")
.with_port(1)
.with_timeout(Duration::from_millis(100))
.with_srv_lookup()
.connect()
.await;
// Should fail to connect (no server), but shouldn't fail on SRV lookup
assert!(result.is_err());
}