From ace265986155927c07001a5d15a0cdb944530f62 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 8 Mar 2026 00:17:38 +0000 Subject: [PATCH] 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 --- Cargo.lock | 121 +++- Cargo.toml | 2 +- apps/labrinth/src/clickhouse/mod.rs | 6 +- apps/labrinth/src/models/exp/minecraft.rs | 6 +- apps/labrinth/src/queue/moderation.rs | 2 +- apps/labrinth/src/queue/server_ping.rs | 6 +- apps/labrinth/src/search/indexing/mod.rs | 1 + packages/app-lib/src/api/worlds.rs | 4 +- packages/app-lib/src/util/server_ping.rs | 2 +- packages/async-minecraft-ping/.gitignore | 2 + packages/async-minecraft-ping/.rustfmt.toml | 1 + packages/async-minecraft-ping/Cargo.toml | 34 ++ packages/async-minecraft-ping/LICENSE-APACHE | 201 ++++++ packages/async-minecraft-ping/LICENSE-MIT | 23 + packages/async-minecraft-ping/README.md | 72 +++ .../async-minecraft-ping/examples/status.rs | 48 ++ packages/async-minecraft-ping/src/lib.rs | 6 + packages/async-minecraft-ping/src/protocol.rs | 576 ++++++++++++++++++ packages/async-minecraft-ping/src/server.rs | 428 +++++++++++++ .../async-minecraft-ping/tests/integration.rs | 320 ++++++++++ 20 files changed, 1836 insertions(+), 25 deletions(-) create mode 100644 packages/async-minecraft-ping/.gitignore create mode 100644 packages/async-minecraft-ping/.rustfmt.toml create mode 100644 packages/async-minecraft-ping/Cargo.toml create mode 100644 packages/async-minecraft-ping/LICENSE-APACHE create mode 100644 packages/async-minecraft-ping/LICENSE-MIT create mode 100644 packages/async-minecraft-ping/README.md create mode 100644 packages/async-minecraft-ping/examples/status.rs create mode 100644 packages/async-minecraft-ping/src/lib.rs create mode 100644 packages/async-minecraft-ping/src/protocol.rs create mode 100644 packages/async-minecraft-ping/src/server.rs create mode 100644 packages/async-minecraft-ping/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 7ba5a9fce..07534c054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 04639aa10..0991e2324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index 3d922d0b9..a892653ed 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -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} diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index a66333c76..1b3c9d999 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -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! { diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 2e7c02709..f006793e9 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -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 { diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index b08f21b10..c65ceb80b 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -305,9 +305,9 @@ struct ServerPingRecord { latency_ms: Option, description: Option, version_name: Option, - version_protocol: Option, - players_online: Option, - players_max: Option, + version_protocol: Option, + players_online: Option, + players_max: Option, } #[cfg(test)] diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs index 85e506088..b334c9b0c 100644 --- a/apps/labrinth/src/search/indexing/mod.rs +++ b/apps/labrinth/src/search/indexing/mod.rs @@ -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", diff --git a/packages/app-lib/src/api/worlds.rs b/packages/app-lib/src/api/worlds.rs index dcd3b287c..16b87965b 100644 --- a/packages/app-lib/src/api/worlds.rs +++ b/packages/app-lib/src/api/worlds.rs @@ -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 diff --git a/packages/app-lib/src/util/server_ping.rs b/packages/app-lib/src/util/server_ping.rs index 76fcdd56a..d03991fbc 100644 --- a/packages/app-lib/src/util/server_ping.rs +++ b/packages/app-lib/src/util/server_ping.rs @@ -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, } diff --git a/packages/async-minecraft-ping/.gitignore b/packages/async-minecraft-ping/.gitignore new file mode 100644 index 000000000..96ef6c0b9 --- /dev/null +++ b/packages/async-minecraft-ping/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/packages/async-minecraft-ping/.rustfmt.toml b/packages/async-minecraft-ping/.rustfmt.toml new file mode 100644 index 000000000..32a9786fa --- /dev/null +++ b/packages/async-minecraft-ping/.rustfmt.toml @@ -0,0 +1 @@ +edition = "2018" diff --git a/packages/async-minecraft-ping/Cargo.toml b/packages/async-minecraft-ping/Cargo.toml new file mode 100644 index 000000000..70a91e25a --- /dev/null +++ b/packages/async-minecraft-ping/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "async-minecraft-ping" +version = "0.8.0" +authors = ["Jay Vana "] +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"] diff --git a/packages/async-minecraft-ping/LICENSE-APACHE b/packages/async-minecraft-ping/LICENSE-APACHE new file mode 100644 index 000000000..16fe87b06 --- /dev/null +++ b/packages/async-minecraft-ping/LICENSE-APACHE @@ -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. diff --git a/packages/async-minecraft-ping/LICENSE-MIT b/packages/async-minecraft-ping/LICENSE-MIT new file mode 100644 index 000000000..31aa79387 --- /dev/null +++ b/packages/async-minecraft-ping/LICENSE-MIT @@ -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. diff --git a/packages/async-minecraft-ping/README.md b/packages/async-minecraft-ping/README.md new file mode 100644 index 000000000..ae8804ac4 --- /dev/null +++ b/packages/async-minecraft-ping/README.md @@ -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.
` 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 diff --git a/packages/async-minecraft-ping/examples/status.rs b/packages/async-minecraft-ping/examples/status.rs new file mode 100644 index 000000000..3715926e7 --- /dev/null +++ b/packages/async-minecraft-ping/examples/status.rs @@ -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, + + /// 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(()) +} diff --git a/packages/async-minecraft-ping/src/lib.rs b/packages/async-minecraft-ping/src/lib.rs new file mode 100644 index 000000000..5cd0650a3 --- /dev/null +++ b/packages/async-minecraft-ping/src/lib.rs @@ -0,0 +1,6 @@ +mod protocol; +mod server; +pub use server::{ + connect, ConnectionConfig, ServerDescription, ServerError, ServerPlayer, ServerPlayers, + ServerVersion, StatusConnection, StatusResponse, +}; diff --git a/packages/async-minecraft-ping/src/protocol.rs b/packages/async-minecraft-ping/src/protocol.rs new file mode 100644 index 000000000..b161e3515 --- /dev/null +++ b/packages/async-minecraft-ping/src/protocol.rs @@ -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 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; + async fn read_string(&mut self) -> Result; +} + +#[async_trait] +impl AsyncWireReadExt for R { + async fn read_varint(&mut self) -> Result { + 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 { + 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 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) -> Result; +} + +/// 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, 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( + &mut self, + ) -> Result; + + async fn read_packet_with_timeout( + &mut self, + timeout: Duration, + ) -> Result; +} + +#[async_trait] +impl AsyncReadRawPacket for R { + async fn read_packet( + &mut self, + ) -> Result { + 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( + &mut self, + timeout: Duration, + ) -> Result { + 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( + &mut self, + packet: T, + ) -> Result<(), ProtocolError>; + + async fn write_packet_with_timeout( + &mut self, + packet: T, + timeout: Duration, + ) -> Result<(), ProtocolError>; +} + +#[async_trait] +impl AsyncWriteRawPacket for W { + async fn write_packet( + &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> = 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( + &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, ProtocolError> { + let mut buffer = Cursor::new(Vec::::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, 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) -> Result { + 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, ProtocolError> { + let mut buffer = Cursor::new(Vec::::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) -> Result { + 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))); + } +} diff --git a/packages/async-minecraft-ping/src/server.rs b/packages/async-minecraft-ping/src/server.rs new file mode 100644 index 000000000..03d3f184b --- /dev/null +++ b/packages/async-minecraft-ping/src/server.rs @@ -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 for ServerError { + fn from(_err: protocol::ProtocolError) -> Self { + ServerError::ProtocolError + } +} + +/// Contains information about the server version. +#[derive(Debug, Deserialize)] +pub struct ServerVersion { + /// The server's Minecraft version, i.e. "1.15.2". + pub name: String, + + /// The server's ServerListPing protocol version. + pub protocol: i32, +} + +/// Contains information about a player. +#[derive(Debug, Deserialize)] +pub struct ServerPlayer { + /// The player's in-game name. + pub name: String, + + /// The player's UUID. + pub id: String, +} + +/// Contains information about the currently online +/// players. +#[derive(Debug, Deserialize)] +pub struct ServerPlayers { + /// The configured maximum number of players for the + /// server. + pub max: i32, + + /// The number of players currently online. + pub online: i32, + + /// An optional list of player information for + /// currently online players. + pub sample: Option>, +} + +/// Contains the server's MOTD. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ServerDescription { + Plain(String), + Object { text: String }, +} + +/// The decoded JSON response from a status query over +/// ServerListPing. +#[derive(Debug, Deserialize)] +pub struct StatusResponse { + /// Information about the server's version. + pub version: ServerVersion, + + /// Information about currently online players. + pub players: ServerPlayers, + + /// Single-field struct containing the server's MOTD. + pub description: ServerDescription, + + /// Optional field containing a path to the server's + /// favicon. + pub favicon: Option, +} + +const LATEST_PROTOCOL_VERSION: usize = 578; +const DEFAULT_PORT: u16 = 25565; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Builder for a Minecraft +/// ServerListPing connection. +pub struct ConnectionConfig { + protocol_version: usize, + address: String, + port: u16, + timeout: Duration, + #[cfg(feature = "srv")] + srv_lookup: bool, +} + +impl ConnectionConfig { + /// Initiates the Minecraft server + /// connection build process. + pub fn build>(address: T) -> Self { + ConnectionConfig { + protocol_version: LATEST_PROTOCOL_VERSION, + address: address.into(), + port: DEFAULT_PORT, + timeout: DEFAULT_TIMEOUT, + #[cfg(feature = "srv")] + srv_lookup: false, + } + } + + /// Sets a specific + /// protocol version for the connection to + /// use. If not specified, the latest version + /// will be used. + pub fn with_protocol_version(mut self, protocol_version: usize) -> Self { + self.protocol_version = protocol_version; + self + } + + /// Sets a specific port for the + /// connection to use. If not specified, the + /// default port of 25565 will be used. + pub fn with_port(mut self, port: u16) -> Self { + self.port = port; + self + } + + /// Sets a specific timeout for the + /// connection to use. If not specified, the + /// timeout defaults to two seconds. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Enables SRV record lookup for the connection. + /// + /// When enabled, the library will query DNS for an SRV record + /// at `_minecraft._tcp.
`. If found, the target host + /// and port from the SRV record will be used instead of the + /// configured address and port. + /// + /// This feature requires the `srv` feature to be enabled. + #[cfg(feature = "srv")] + pub fn with_srv_lookup(mut self) -> Self { + self.srv_lookup = true; + self + } + + /// Connects to the server and consumes the builder. + pub async fn connect(self) -> Result { + let (address, port) = self.resolve_address().await; + + let stream = tokio::time::timeout( + self.timeout, + TcpStream::connect(format!("{}:{}", address, port)), + ) + .await + .map_err(|_| ServerError::ConnectionTimedOut)? + .map_err(|_| ServerError::FailedToConnect)?; + + Ok(StatusConnection { + stream, + protocol_version: self.protocol_version, + address, + port, + timeout: self.timeout, + }) + } + + #[cfg(feature = "srv")] + async fn resolve_address(&self) -> (String, u16) { + if !self.srv_lookup { + return (self.address.clone(), self.port); + } + + // Try to resolve SRV record, fall back to original address on any failure + match self.lookup_srv().await { + Some((host, port)) => (host, port), + None => (self.address.clone(), self.port), + } + } + + #[cfg(not(feature = "srv"))] + async fn resolve_address(&self) -> (String, u16) { + (self.address.clone(), self.port) + } + + #[cfg(feature = "srv")] + async fn lookup_srv(&self) -> Option<(String, u16)> { + use hickory_resolver::TokioAsyncResolver; + + let resolver = TokioAsyncResolver::tokio_from_system_conf().ok()?; + let srv_name = format!("_minecraft._tcp.{}", self.address); + + let lookup = tokio::time::timeout(self.timeout, resolver.srv_lookup(&srv_name)) + .await + .ok()? + .ok()?; + + let record = lookup.iter().next()?; + let target = record.target().to_string(); + // Remove trailing dot from DNS name + let host = target.trim_end_matches('.').to_string(); + let port = record.port(); + + Some((host, port)) + } +} + +/// Convenience wrapper for easily connecting +/// to a server on the default port with +/// the latest protocol version. +pub async fn connect(address: String) -> Result { + ConnectionConfig::build(address).connect().await +} + +/// Wraps a built connection +pub struct StatusConnection { + stream: TcpStream, + protocol_version: usize, + address: String, + port: u16, + timeout: Duration, +} + +impl StatusConnection { + /// Sends and reads the packets for the + /// ServerListPing status call. + /// + /// Consumes the connection and returns a type + /// that can only issue pings. The resulting + /// status body is accessible via the `status` + /// property on `PingConnection`. + pub async fn status(mut self) -> Result { + let handshake = protocol::HandshakePacket::new( + self.protocol_version, + self.address.to_string(), + self.port, + ); + + self.stream + .write_packet_with_timeout(handshake, self.timeout) + .await?; + + self.stream + .write_packet_with_timeout(protocol::RequestPacket::new(), self.timeout) + .await?; + + let response: protocol::ResponsePacket = + self.stream.read_packet_with_timeout(self.timeout).await?; + + let status: StatusResponse = serde_json::from_str(&response.body) + .map_err(|_| ServerError::InvalidJson(response.body))?; + + Ok(PingConnection { + stream: self.stream, + protocol_version: self.protocol_version, + address: self.address, + port: self.port, + status, + timeout: self.timeout, + }) + } +} + +/// Wraps a built connection +/// +/// Constructed by calling `status()` on +/// a `StatusConnection` struct. +#[allow(dead_code)] +pub struct PingConnection { + stream: TcpStream, + protocol_version: usize, + address: String, + port: u16, + timeout: Duration, + pub status: StatusResponse, +} + +impl PingConnection { + /// Sends a ping to the Minecraft server with the + /// provided payload and asserts that the returned + /// payload is the same. + /// + /// Server closes the connection after a ping call, + /// so this method consumes the connection. + pub async fn ping(mut self, payload: u64) -> Result<(), ServerError> { + let ping = protocol::PingPacket::new(payload); + + self.stream + .write_packet_with_timeout(ping, self.timeout) + .await?; + + let pong: protocol::PongPacket = self.stream.read_packet_with_timeout(self.timeout).await?; + + if pong.payload != payload { + return Err(ServerError::MismatchedPayload { + expected: payload, + actual: pong.payload, + }); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_description_plain() { + let json = r#""A Minecraft Server""#; + let desc: ServerDescription = serde_json::from_str(json).unwrap(); + assert!(matches!(desc, ServerDescription::Plain(s) if s == "A Minecraft Server")); + } + + #[test] + fn test_server_description_object() { + let json = r#"{"text":"A Minecraft Server"}"#; + let desc: ServerDescription = serde_json::from_str(json).unwrap(); + assert!(matches!(desc, ServerDescription::Object { text } if text == "A Minecraft Server")); + } + + #[test] + fn test_status_response_minimal() { + let json = r#"{ + "version": {"name": "1.20.4", "protocol": 765}, + "players": {"max": 20, "online": 5}, + "description": "Welcome to the server" + }"#; + + let response: StatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.version.name, "1.20.4"); + assert_eq!(response.version.protocol, 765); + assert_eq!(response.players.max, 20); + assert_eq!(response.players.online, 5); + assert!(response.players.sample.is_none()); + assert!(response.favicon.is_none()); + } + + #[test] + fn test_status_response_with_players() { + let json = r#"{ + "version": {"name": "1.20.4", "protocol": 765}, + "players": { + "max": 20, + "online": 2, + "sample": [ + {"name": "Player1", "id": "uuid-1"}, + {"name": "Player2", "id": "uuid-2"} + ] + }, + "description": {"text": "Welcome"} + }"#; + + let response: StatusResponse = serde_json::from_str(json).unwrap(); + let sample = response.players.sample.unwrap(); + assert_eq!(sample.len(), 2); + assert_eq!(sample[0].name, "Player1"); + assert_eq!(sample[1].name, "Player2"); + } + + #[test] + fn test_status_response_with_favicon() { + let json = r#"{ + "version": {"name": "1.20.4", "protocol": 765}, + "players": {"max": 20, "online": 0}, + "description": "Test", + "favicon": "data:image/png;base64,iVBORw0KGgo=" + }"#; + + let response: StatusResponse = serde_json::from_str(json).unwrap(); + assert!(response.favicon.is_some()); + assert!(response.favicon.unwrap().starts_with("data:image/png")); + } + + #[test] + fn test_connection_config_defaults() { + let config = ConnectionConfig::build("localhost"); + assert_eq!(config.address, "localhost"); + assert_eq!(config.port, DEFAULT_PORT); + assert_eq!(config.timeout, DEFAULT_TIMEOUT); + assert_eq!(config.protocol_version, LATEST_PROTOCOL_VERSION); + } + + #[test] + fn test_connection_config_with_port() { + let config = ConnectionConfig::build("localhost").with_port(12345); + assert_eq!(config.port, 12345); + } + + #[test] + fn test_connection_config_with_timeout() { + let config = ConnectionConfig::build("localhost").with_timeout(Duration::from_secs(10)); + assert_eq!(config.timeout, Duration::from_secs(10)); + } + + #[test] + fn test_connection_config_with_protocol_version() { + let config = ConnectionConfig::build("localhost").with_protocol_version(47); + assert_eq!(config.protocol_version, 47); + } + + #[cfg(feature = "srv")] + #[test] + fn test_connection_config_with_srv_lookup() { + let config = ConnectionConfig::build("localhost").with_srv_lookup(); + assert!(config.srv_lookup); + } +} diff --git a/packages/async-minecraft-ping/tests/integration.rs b/packages/async-minecraft-ping/tests/integration.rs new file mode 100644 index 000000000..58d81e950 --- /dev/null +++ b/packages/async-minecraft-ping/tests/integration.rs @@ -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 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; + async fn read_string(&mut self) -> std::io::Result; +} + +#[async_trait] +impl AsyncWireReadExt for R { + async fn read_varint(&mut self) -> std::io::Result { + 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 { + 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)> { + 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()); +}