├── .github └── workflows │ └── build.yml ├── .gitignore ├── .idea ├── .gitignore ├── mc-query.iml ├── modules.xml └── vcs.xml ├── .vscode └── launch.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── resources └── server.properties ├── src ├── errors.rs ├── lib.rs ├── query.rs ├── rcon.rs ├── rcon │ ├── client.rs │ └── packet.rs ├── socket.rs ├── status.rs ├── status │ ├── data.rs │ └── packet.rs └── varint.rs └── test /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - uses: actions-rs/toolchain@v1 13 | with: 14 | toolchain: stable 15 | default: true 16 | override: true 17 | 18 | - run: cargo clippy -- --deny warnings 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /server 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/mc-query.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug unit tests in library 'mc-query'", 11 | "cargo": { 12 | "args": ["test", "--no-run", "--lib", "--package=mc-query"], 13 | "filter": { 14 | "name": "mc-query", 15 | "kind": "lib" 16 | } 17 | }, 18 | "args": [], 19 | "cwd": "${workspaceFolder}" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "async-trait" 7 | version = "0.1.68" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" 10 | dependencies = [ 11 | "proc-macro2", 12 | "quote", 13 | "syn", 14 | ] 15 | 16 | [[package]] 17 | name = "autocfg" 18 | version = "1.1.0" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 21 | 22 | [[package]] 23 | name = "bitflags" 24 | version = "1.3.2" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 27 | 28 | [[package]] 29 | name = "bytes" 30 | version = "1.4.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" 33 | dependencies = [ 34 | "serde", 35 | ] 36 | 37 | [[package]] 38 | name = "cfg-if" 39 | version = "1.0.0" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 42 | 43 | [[package]] 44 | name = "getrandom" 45 | version = "0.2.9" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" 48 | dependencies = [ 49 | "cfg-if", 50 | "libc", 51 | "wasi", 52 | ] 53 | 54 | [[package]] 55 | name = "hermit-abi" 56 | version = "0.2.6" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" 59 | dependencies = [ 60 | "libc", 61 | ] 62 | 63 | [[package]] 64 | name = "itoa" 65 | version = "1.0.6" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" 68 | 69 | [[package]] 70 | name = "libc" 71 | version = "0.2.141" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5" 74 | 75 | [[package]] 76 | name = "lock_api" 77 | version = "0.4.9" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" 80 | dependencies = [ 81 | "autocfg", 82 | "scopeguard", 83 | ] 84 | 85 | [[package]] 86 | name = "log" 87 | version = "0.4.17" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 90 | dependencies = [ 91 | "cfg-if", 92 | ] 93 | 94 | [[package]] 95 | name = "mc-query" 96 | version = "2.0.0" 97 | dependencies = [ 98 | "async-trait", 99 | "bytes", 100 | "paste", 101 | "rand", 102 | "serde", 103 | "serde_json", 104 | "thiserror", 105 | "tokio", 106 | ] 107 | 108 | [[package]] 109 | name = "mio" 110 | version = "0.8.6" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" 113 | dependencies = [ 114 | "libc", 115 | "log", 116 | "wasi", 117 | "windows-sys", 118 | ] 119 | 120 | [[package]] 121 | name = "num_cpus" 122 | version = "1.15.0" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" 125 | dependencies = [ 126 | "hermit-abi", 127 | "libc", 128 | ] 129 | 130 | [[package]] 131 | name = "parking_lot" 132 | version = "0.12.1" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 135 | dependencies = [ 136 | "lock_api", 137 | "parking_lot_core", 138 | ] 139 | 140 | [[package]] 141 | name = "parking_lot_core" 142 | version = "0.9.7" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" 145 | dependencies = [ 146 | "cfg-if", 147 | "libc", 148 | "redox_syscall", 149 | "smallvec", 150 | "windows-sys", 151 | ] 152 | 153 | [[package]] 154 | name = "paste" 155 | version = "1.0.15" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 158 | 159 | [[package]] 160 | name = "pin-project-lite" 161 | version = "0.2.9" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 164 | 165 | [[package]] 166 | name = "ppv-lite86" 167 | version = "0.2.17" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" 170 | 171 | [[package]] 172 | name = "proc-macro2" 173 | version = "1.0.56" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" 176 | dependencies = [ 177 | "unicode-ident", 178 | ] 179 | 180 | [[package]] 181 | name = "quote" 182 | version = "1.0.26" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" 185 | dependencies = [ 186 | "proc-macro2", 187 | ] 188 | 189 | [[package]] 190 | name = "rand" 191 | version = "0.8.5" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 194 | dependencies = [ 195 | "libc", 196 | "rand_chacha", 197 | "rand_core", 198 | ] 199 | 200 | [[package]] 201 | name = "rand_chacha" 202 | version = "0.3.1" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 205 | dependencies = [ 206 | "ppv-lite86", 207 | "rand_core", 208 | ] 209 | 210 | [[package]] 211 | name = "rand_core" 212 | version = "0.6.4" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 215 | dependencies = [ 216 | "getrandom", 217 | ] 218 | 219 | [[package]] 220 | name = "redox_syscall" 221 | version = "0.2.16" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 224 | dependencies = [ 225 | "bitflags", 226 | ] 227 | 228 | [[package]] 229 | name = "ryu" 230 | version = "1.0.13" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" 233 | 234 | [[package]] 235 | name = "scopeguard" 236 | version = "1.1.0" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 239 | 240 | [[package]] 241 | name = "serde" 242 | version = "1.0.160" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" 245 | dependencies = [ 246 | "serde_derive", 247 | ] 248 | 249 | [[package]] 250 | name = "serde_derive" 251 | version = "1.0.160" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" 254 | dependencies = [ 255 | "proc-macro2", 256 | "quote", 257 | "syn", 258 | ] 259 | 260 | [[package]] 261 | name = "serde_json" 262 | version = "1.0.96" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" 265 | dependencies = [ 266 | "itoa", 267 | "ryu", 268 | "serde", 269 | ] 270 | 271 | [[package]] 272 | name = "signal-hook-registry" 273 | version = "1.4.1" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" 276 | dependencies = [ 277 | "libc", 278 | ] 279 | 280 | [[package]] 281 | name = "smallvec" 282 | version = "1.10.0" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" 285 | 286 | [[package]] 287 | name = "socket2" 288 | version = "0.4.9" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" 291 | dependencies = [ 292 | "libc", 293 | "winapi", 294 | ] 295 | 296 | [[package]] 297 | name = "syn" 298 | version = "2.0.15" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822" 301 | dependencies = [ 302 | "proc-macro2", 303 | "quote", 304 | "unicode-ident", 305 | ] 306 | 307 | [[package]] 308 | name = "thiserror" 309 | version = "1.0.40" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" 312 | dependencies = [ 313 | "thiserror-impl", 314 | ] 315 | 316 | [[package]] 317 | name = "thiserror-impl" 318 | version = "1.0.40" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" 321 | dependencies = [ 322 | "proc-macro2", 323 | "quote", 324 | "syn", 325 | ] 326 | 327 | [[package]] 328 | name = "tokio" 329 | version = "1.27.0" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" 332 | dependencies = [ 333 | "autocfg", 334 | "bytes", 335 | "libc", 336 | "mio", 337 | "num_cpus", 338 | "parking_lot", 339 | "pin-project-lite", 340 | "signal-hook-registry", 341 | "socket2", 342 | "tokio-macros", 343 | "windows-sys", 344 | ] 345 | 346 | [[package]] 347 | name = "tokio-macros" 348 | version = "2.0.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" 351 | dependencies = [ 352 | "proc-macro2", 353 | "quote", 354 | "syn", 355 | ] 356 | 357 | [[package]] 358 | name = "unicode-ident" 359 | version = "1.0.8" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" 362 | 363 | [[package]] 364 | name = "wasi" 365 | version = "0.11.0+wasi-snapshot-preview1" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 368 | 369 | [[package]] 370 | name = "winapi" 371 | version = "0.3.9" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 374 | dependencies = [ 375 | "winapi-i686-pc-windows-gnu", 376 | "winapi-x86_64-pc-windows-gnu", 377 | ] 378 | 379 | [[package]] 380 | name = "winapi-i686-pc-windows-gnu" 381 | version = "0.4.0" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 384 | 385 | [[package]] 386 | name = "winapi-x86_64-pc-windows-gnu" 387 | version = "0.4.0" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 390 | 391 | [[package]] 392 | name = "windows-sys" 393 | version = "0.45.0" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 396 | dependencies = [ 397 | "windows-targets", 398 | ] 399 | 400 | [[package]] 401 | name = "windows-targets" 402 | version = "0.42.2" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 405 | dependencies = [ 406 | "windows_aarch64_gnullvm", 407 | "windows_aarch64_msvc", 408 | "windows_i686_gnu", 409 | "windows_i686_msvc", 410 | "windows_x86_64_gnu", 411 | "windows_x86_64_gnullvm", 412 | "windows_x86_64_msvc", 413 | ] 414 | 415 | [[package]] 416 | name = "windows_aarch64_gnullvm" 417 | version = "0.42.2" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 420 | 421 | [[package]] 422 | name = "windows_aarch64_msvc" 423 | version = "0.42.2" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 426 | 427 | [[package]] 428 | name = "windows_i686_gnu" 429 | version = "0.42.2" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 432 | 433 | [[package]] 434 | name = "windows_i686_msvc" 435 | version = "0.42.2" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 438 | 439 | [[package]] 440 | name = "windows_x86_64_gnu" 441 | version = "0.42.2" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 444 | 445 | [[package]] 446 | name = "windows_x86_64_gnullvm" 447 | version = "0.42.2" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 450 | 451 | [[package]] 452 | name = "windows_x86_64_msvc" 453 | version = "0.42.2" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 456 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mc-query" 3 | version = "2.0.0" 4 | edition = "2021" 5 | authors = ["Ari Prakash"] 6 | description = "Implementations of Server List Ping, Query, and RCON for minecraft servers" 7 | documentation = "https://docs.rs/mc-query" 8 | readme = "README.md" 9 | homepage = "https://github.com/ariscript/mc-query" 10 | repository = "https://github.com/ariscript/mc-query" 11 | license = "MIT OR Apache-2.0" 12 | keywords = ["minecraft", "rcon", "query"] 13 | categories = ["api-bindings", "network-programming"] 14 | exclude = ["/test", "/resources"] 15 | 16 | [dependencies] 17 | async-trait = "0.1.68" 18 | bytes = { version = "1.4.0", features = ["serde"] } 19 | paste = "1.0.15" 20 | rand = "0.8.5" 21 | serde = { version = "1.0.160", features = ["derive"] } 22 | serde_json = "1.0.96" 23 | thiserror = "1.0.40" 24 | tokio = { version = "1.27.0", features = ["full"] } 25 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Ari Prakash 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ari Prakash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mc-query 2 | 3 | ![Crates.io](https://img.shields.io/crates/v/mc-query?style=for-the-badge) 4 | ![Crates.io](https://img.shields.io/crates/d/mc-query?style=for-the-badge) 5 | ![Crates.io](https://img.shields.io/crates/l/mc-query?style=for-the-badge) 6 | 7 | ![docs.rs](https://img.shields.io/docsrs/mc-query?style=for-the-badge) 8 | 9 | Implementations of [Server List ping](https://wiki.vg/Server_List_Ping), [Query](https://wiki.vg/Query), and [RCON](https://wiki.vg/RCON) using the Minecraft networking protocol. 10 | 11 | Maybe in the future there will be a CLI to access these features as well. 12 | 13 | ## Installation 14 | 15 | To use this library, just run `cargo add mc-query`. 16 | 17 | ## Usage 18 | 19 | You can read the docs [here](https://docs.rs/mc-query). 20 | 21 | ## Examples 22 | 23 | ### Using `status` to get basic server information 24 | 25 | ```rs 26 | use mc_query::status; 27 | use tokio::io::Result; 28 | 29 | #[tokio::main] 30 | async fn main() -> Result<()> { 31 | let data = status("mc.hypixel.net", 25565).await?; 32 | println!("{data:#?}"); 33 | 34 | Ok(()) 35 | } 36 | ``` 37 | 38 | ### Using `RconClient` to run commands via RCON 39 | 40 | ```rs 41 | use mc_query::rcon::RconClient; 42 | use tokio::io::Result; 43 | 44 | #[tokio::main] 45 | async fn main() -> Result<()> { 46 | let mut client = RconClient::new("localhost", 25565); 47 | client.authenticate("supersecretrconpassword").await?; 48 | 49 | let response = client.run_command("time set 0").await?; 50 | println!("{response}"); 51 | 52 | Ok(()) 53 | } 54 | ``` 55 | 56 | ### Using `stat_basic` to query the server 57 | 58 | ```rs 59 | use mc_query::query; 60 | use tokio;:io::Result; 61 | 62 | #[tokio::main] 63 | async fn main() -> Result<()> { 64 | let res = stat_basic("localhost", 25565).await?; 65 | println!( 66 | "Server has {} out of {} players online", 67 | res.num_players, 68 | res.max_players 69 | ); 70 | 71 | Ok(()) 72 | } 73 | ``` 74 | 75 | ### Using `stat_full` to query the server 76 | 77 | ```rs 78 | use mc_query::query; 79 | use tokio;:io::Result; 80 | 81 | #[tokio::main] 82 | async fn main() -> Result<()> { 83 | let res = stat_full("localhost", 25565).await?; 84 | println!("Online players: {:#?}, res.players); 85 | 86 | Ok(()) 87 | } 88 | ``` 89 | 90 | ## Reference 91 | 92 | - [wiki.vg](https://wiki.vg) - documentation of the various protocols implemented in this crate 93 | 94 | ## Testing 95 | 96 | Some tests in this library require a minecraft server to be running on `localhost`. 97 | If you are contributing a feature or bugfix that involves one of these tests, 98 | run the convienient testing script `./test` (or `py -3 test` on Windows). 99 | You can also just run a minecraft server without the cargo tests (useful for debugging with IDEs) with `./test --server-only true`. 100 | 101 | This requires a decently modern version of Python 3, and Java 17 or higher to run the server. 102 | 103 | ## License 104 | 105 | Licensed under either of 106 | 107 | - Apache License, Version 2.0 108 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 109 | - MIT license 110 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 111 | 112 | at your option. 113 | 114 | ## Contribution 115 | 116 | Unless you explicitly state otherwise, any contribution intentionally submitted 117 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 118 | dual licensed as above, without any additional terms or conditions. 119 | 120 | ## Mojang 121 | 122 | This project is in no way involved with or endorsed by Mojang Synergies AB or Microsoft Corporation. 123 | Any use of their services (including running some tests in this library) requires you to agree to their [terms](https://minecraft.net/eula). 124 | -------------------------------------------------------------------------------- /resources/server.properties: -------------------------------------------------------------------------------- 1 | #Minecraft server properties 2 | #Fri Jul 29 21:53:53 EDT 2022 3 | allow-flight=false 4 | allow-nether=true 5 | broadcast-console-to-ops=true 6 | broadcast-rcon-to-ops=true 7 | difficulty=easy 8 | enable-command-block=false 9 | enable-jmx-monitoring=false 10 | enable-query=true 11 | enable-rcon=true 12 | enable-status=true 13 | enforce-secure-profile=true 14 | enforce-whitelist=false 15 | entity-broadcast-range-percentage=100 16 | force-gamemode=false 17 | function-permission-level=2 18 | gamemode=survival 19 | generate-structures=true 20 | generator-settings={} 21 | hardcore=false 22 | hide-online-players=false 23 | level-name=world 24 | level-seed= 25 | level-type=minecraft\:normal 26 | max-chained-neighbor-updates=1000000 27 | max-players=20 28 | max-tick-time=60000 29 | max-world-size=29999984 30 | motd=A Minecraft Server 31 | network-compression-threshold=256 32 | online-mode=true 33 | op-permission-level=4 34 | player-idle-timeout=0 35 | prevent-proxy-connections=false 36 | previews-chat=false 37 | pvp=true 38 | query.port=25565 39 | rate-limit=0 40 | rcon.password=mc-query-test 41 | rcon.port=25575 42 | require-resource-pack=false 43 | resource-pack= 44 | resource-pack-prompt= 45 | resource-pack-sha1= 46 | server-ip= 47 | server-port=25565 48 | simulation-distance=10 49 | spawn-animals=true 50 | spawn-monsters=true 51 | spawn-npcs=true 52 | spawn-protection=16 53 | sync-chunk-writes=true 54 | text-filtering-config= 55 | use-native-transport=true 56 | view-distance=10 57 | white-list=false 58 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! All the errors defined by this crate. 2 | 3 | use std::io::{self, ErrorKind}; 4 | use thiserror::Error; 5 | 6 | /// An error from the Minecraft networking protocol. 7 | #[derive(Error, Debug)] 8 | pub enum MinecraftProtocolError { 9 | /// `VarInt` data was invalid according to the spec. 10 | #[error("invalid varint data")] 11 | InvalidVarInt, 12 | 13 | /// Received invalid state information from the server. 14 | #[error("invalid state")] 15 | InvalidState, 16 | 17 | /// Received incorrectly formatted status response from the server. 18 | #[error("invalid status response")] 19 | InvalidStatusResponse, 20 | } 21 | 22 | impl From for io::Error { 23 | fn from(err: MinecraftProtocolError) -> Self { 24 | io::Error::new(ErrorKind::InvalidData, err) 25 | } 26 | } 27 | 28 | /// An error from the RCON protocol. 29 | #[derive(Error, Debug)] 30 | pub enum RconProtocolError { 31 | /// Received non-ASCII payload data from the server. 32 | /// 33 | /// Note: some servers (for example Craftbukkit for Minecraft 1.4.7) reply 34 | /// with the section sign (0xa7) as a prefix for the payload. This error 35 | /// will not be returned in that case. 36 | #[error("non-ascii payload")] 37 | NonAsciiPayload, 38 | 39 | /// Authentication failed. You probably entered the wrong RCON password. 40 | #[error("authentication failed")] 41 | AuthFailed, 42 | 43 | /// Invalid or unexpected packet type received from the server. 44 | #[error("invalid packet type")] 45 | InvalidPacketType, 46 | 47 | /// Other kind of invalid response as defined by the spec. 48 | #[error("invalid rcon response")] 49 | InvalidRconResponse, 50 | 51 | /// Payload too long. 52 | /// 53 | /// | Direction | Payload Length limit | 54 | /// | ----------- | -------------------- | 55 | /// | Serverbound | 1446 | 56 | /// | Clientbound | 4096 | 57 | #[error("payload too long")] 58 | PayloadTooLong, 59 | 60 | /// Mismatch with the given request ID. 61 | /// 62 | /// Note: the server replies with a request ID of -1 in the case of an 63 | /// authentication failure. In that case, `AuthFailed` will be returned. 64 | /// This variant is returned if any *other* request ID was received. 65 | #[error("request id mismatch")] 66 | RequestIdMismatch, 67 | } 68 | 69 | impl From for io::Error { 70 | fn from(err: RconProtocolError) -> Self { 71 | io::Error::new(ErrorKind::InvalidData, err) 72 | } 73 | } 74 | 75 | /// An error from the Query protocol. 76 | #[derive(Error, Debug)] 77 | pub enum QueryProtocolError { 78 | /// Received invalid packet type. 79 | /// Valid types are 9 for handshake, 0 for stat 80 | #[error("invalid packet type")] 81 | InvalidPacketType, 82 | 83 | /// Unexpected packet type. 84 | #[error("unexpected packet type")] 85 | UnexpectedPacketType, 86 | 87 | /// Mismatch with the generated session ID. 88 | #[error("session id mismatch")] 89 | SessionIdMismatch, 90 | 91 | /// Received invalid challenge token from server. 92 | #[error("invalid challenge token")] 93 | InvalidChallengeToken, 94 | 95 | /// Invalid integer. 96 | /// Did not receive valid characters to parse as an integer in the string 97 | #[error("cannot parse int")] 98 | CannotParseInt, 99 | 100 | /// Invalid UTF8. 101 | /// Did not receive valid UTF from the server when a string was expected 102 | #[error("invalid UTF-8")] 103 | InvalidUtf8, 104 | 105 | /// Invalid key/value section. 106 | /// Expecting something like [this](https://wiki.vg/Query#K.2C_V_section) 107 | #[error("invalid key/value section")] 108 | InvalidKeyValueSection, 109 | } 110 | 111 | impl From for io::Error { 112 | fn from(err: QueryProtocolError) -> Self { 113 | io::Error::new(ErrorKind::InvalidData, err) 114 | } 115 | } 116 | 117 | pub(crate) fn timeout_err() -> io::Result { 118 | Err(io::Error::new(ErrorKind::TimedOut, "connection timed out")) 119 | } 120 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Implementations of [Server List ping](https://wiki.vg/Server_List_Ping), 2 | //! [Query](https://wiki.vg/Query), and [RCON](https://wiki.vg/RCON) using the 3 | //! Minecraft networking protocol. 4 | 5 | #![warn(missing_docs)] 6 | #![warn(clippy::pedantic)] 7 | #![allow(clippy::cast_possible_truncation)] 8 | #![allow(clippy::cast_possible_wrap)] 9 | #![allow(clippy::cast_sign_loss)] 10 | #![allow(clippy::cast_lossless)] 11 | 12 | macro_rules! create_timeout { 13 | ($name:ident, $ret:ty) => { 14 | ::paste::paste! { 15 | #[doc = concat!("Similar to [`", stringify!($name), "`]")] 16 | /// but with an added argument for timeout. 17 | /// 18 | /// Note that timeouts are not precise, and may vary on the order 19 | /// of milliseconds, because of the way the async event loop works. 20 | /// 21 | /// # Arguments 22 | /// * `host` - A string slice that holds the hostname of the server to connect to. 23 | /// * `port` - The port to connect to on that server. 24 | /// 25 | /// # Errors 26 | /// Returns `Err` on any condition that 27 | #[doc = concat!("[`", stringify!($name), "`]")] 28 | /// does, and also when the response is not fully recieved within `dur`. 29 | pub async fn [<$name _with_timeout>]( 30 | host: &str, 31 | port: u16, 32 | dur: ::std::time::Duration, 33 | ) -> ::std::io::Result<$ret> { 34 | use crate::errors::timeout_err; 35 | use ::tokio::time::timeout; 36 | 37 | timeout(dur, $name(host, port)) 38 | .await 39 | .unwrap_or(timeout_err::<$ret>()) 40 | } 41 | } 42 | }; 43 | } 44 | 45 | pub mod errors; 46 | pub mod query; 47 | pub mod rcon; 48 | mod socket; 49 | pub mod status; 50 | mod varint; 51 | 52 | pub use status::status; 53 | -------------------------------------------------------------------------------- /src/query.rs: -------------------------------------------------------------------------------- 1 | //! Implementation of the [Query](https://wiki.vg/Query) protocol. 2 | 3 | use bytes::{Buf, BufMut, Bytes, BytesMut}; 4 | use rand::random; 5 | use std::collections::HashMap; 6 | use std::time::Duration; 7 | use tokio::io; 8 | use tokio::net::UdpSocket; 9 | use tokio::time::timeout; 10 | 11 | use crate::errors::QueryProtocolError; 12 | 13 | const QUERY_MAGIC: u16 = 0xfe_fd; 14 | const SESSION_ID_MASK: u32 = 0x0f_0f_0f_0f; 15 | 16 | /// A response from the server's basic query. 17 | /// Taken from [wiki.vg](https://wiki.vg/Query#Response_2) 18 | #[derive(Debug)] 19 | pub struct BasicStatResponse { 20 | /// The "motd" - message shown in the server list by the client. 21 | pub motd: String, 22 | 23 | /// The server's game type. 24 | /// Vanilla servers hardcode this to "SMP". 25 | pub game_type: String, 26 | 27 | /// The server's world/map name. 28 | pub map: String, 29 | 30 | /// The current number of online players. 31 | pub num_players: usize, 32 | 33 | /// Maximum players online this server allows. 34 | pub max_players: usize, 35 | 36 | /// The port the serer is running on. 37 | pub host_port: u16, 38 | 39 | /// The server's IP address. 40 | pub host_ip: String, 41 | } 42 | 43 | /// A response from the server's full query. 44 | /// Taken from [wiki.vg](https://wiki.vg/Query#Response_3) 45 | #[derive(Debug)] 46 | pub struct FullStatResponse { 47 | /// The "motd" - message shown in the server list by the client. 48 | pub motd: String, 49 | 50 | /// The server's game type. 51 | /// Vanilla servers hardcode this to "SMP". 52 | pub game_type: String, 53 | 54 | /// The server's game ID. 55 | /// Vanilla servers hardcode this to "MINECRAFT". 56 | pub game_id: String, 57 | 58 | /// The server's game version. 59 | pub version: String, 60 | 61 | /// The plugins the server has installed. 62 | /// Vanilla servers return an empty string. 63 | /// Other server platforms may have their own format for this field. 64 | pub plugins: String, 65 | 66 | /// The server's world/map name. 67 | pub map: String, 68 | 69 | /// The current number of online players. 70 | pub num_players: usize, 71 | 72 | /// Maximum players online this server allows. 73 | pub max_players: usize, 74 | 75 | /// The port the server is running on. 76 | pub host_port: u16, 77 | 78 | /// The server's IP address. 79 | pub host_ip: String, 80 | 81 | /// The current list of online players. 82 | pub players: Vec, 83 | } 84 | 85 | async fn stat_send(sock: &UdpSocket, bytes: &[u8]) -> io::Result { 86 | sock.send(bytes).await?; 87 | Box::pin(timeout(Duration::from_millis(250), recv_packet(sock))).await? 88 | } 89 | 90 | /// Perform a basic stat query of the server per the [Query Protocol](https://wiki.vg/Query#Basic_Stat). 91 | /// Note that the server must have `query-enabled=true` set in its properties to get a response. 92 | /// The `query.port` property might also be different from `server.port`. 93 | /// 94 | /// # Arguments 95 | /// * `host` - the hostname/IP of thr server to query 96 | /// * `port` - the port that the server's Query is running on 97 | /// 98 | /// # Errors 99 | /// Will return `Err` if there was a network error, if the challenge token wasn't obtainable, or if 100 | /// invalid data was recieved. 101 | /// 102 | /// # Examples 103 | /// ``` 104 | /// use mc_query::query; 105 | /// use tokio::io::Result; 106 | /// 107 | /// #[tokio::main] 108 | /// async fn main() -> Result<()> { 109 | /// let res = query::stat_basic("localhost", 25565).await?; 110 | /// println!("The server has {} players online out of {}", res.num_players, res.num_players); 111 | /// 112 | /// Ok(()) 113 | /// } 114 | /// ``` 115 | pub async fn stat_basic(host: &str, port: u16) -> io::Result { 116 | let socket = UdpSocket::bind("0.0.0.0:0").await?; 117 | socket.connect(format!("{host}:{port}")).await?; 118 | 119 | let (token, session) = Box::pin(handshake(&socket)).await?; 120 | 121 | let mut bytes = BytesMut::new(); 122 | bytes.put_u16(QUERY_MAGIC); 123 | bytes.put_u8(0); // packet type 0 - stat 124 | bytes.put_i32(session); 125 | bytes.put_i32(token); 126 | 127 | let mut res = match stat_send(&socket, &bytes).await { 128 | Ok(v) => v, 129 | Err(_) => stat_send(&socket, &bytes).await?, 130 | }; 131 | 132 | validate_packet(&mut res, 0, session)?; 133 | 134 | let motd = get_string(&mut res)?; 135 | let game_type = get_string(&mut res)?; 136 | let map = get_string(&mut res)?; 137 | let num_players = get_string(&mut res)? 138 | .parse() 139 | .map_err::(|_| QueryProtocolError::CannotParseInt.into())?; 140 | let max_players = get_string(&mut res)? 141 | .parse() 142 | .map_err::(|_| QueryProtocolError::CannotParseInt.into())?; 143 | 144 | let host_port = res.get_u16_le(); // shorts are little endian per protocol 145 | 146 | let host_ip = get_string(&mut res)?; 147 | 148 | Ok(BasicStatResponse { 149 | motd, 150 | game_type, 151 | map, 152 | num_players, 153 | max_players, 154 | host_port, 155 | host_ip, 156 | }) 157 | } 158 | 159 | /// Perform a full stat query of the server per the [Query Protocol](https://wiki.vg/Query#Full_stat). 160 | /// Note that the server must have `query-enabled=true` set in its properties to get a response. 161 | /// The `query.port` property might also be different from `server.port`. 162 | /// 163 | /// # Arguments 164 | /// * `host` - the hostname/IP of thr server to query 165 | /// * `port` - the port that the server's Query is running on 166 | /// 167 | /// # Errors 168 | /// Will return `Err` if there was a network error, if the challenge token wasn't obtainable, or 169 | /// if invalid data was recieved. 170 | /// 171 | /// # Examples 172 | /// ``` 173 | /// use mc_query::query; 174 | /// use tokio::io::Result; 175 | /// 176 | /// #[tokio::main] 177 | /// async fn main() -> Result<()> { 178 | /// let res = query::stat_full("localhost", 25565).await?; 179 | /// println!("The server has {} players online out of {}", res.num_players, res.num_players); 180 | /// 181 | /// Ok(()) 182 | /// } 183 | /// ``` 184 | pub async fn stat_full(host: &str, port: u16) -> io::Result { 185 | let socket = UdpSocket::bind("0.0.0.0:0").await?; 186 | socket.connect(format!("{host}:{port}")).await?; 187 | 188 | let (token, session) = Box::pin(handshake(&socket)).await?; 189 | 190 | let mut bytes = BytesMut::new(); 191 | bytes.put_u16(QUERY_MAGIC); 192 | bytes.put_u8(0); // packet type 0 - stat 193 | bytes.put_i32(session); 194 | bytes.put_i32(token); 195 | bytes.put_u32(0); // 4 extra bytes required for full stat vs. basic 196 | 197 | let mut res = match stat_send(&socket, &bytes).await { 198 | Ok(v) => v, 199 | Err(_) => stat_send(&socket, &bytes).await?, 200 | }; 201 | 202 | validate_packet(&mut res, 0, session)?; 203 | 204 | // skip 11 meaningless padding bytes 205 | res.advance(11); 206 | 207 | // K,V section 208 | let mut kv = HashMap::new(); 209 | loop { 210 | let key = get_string(&mut res)?; 211 | if key.is_empty() { 212 | break; 213 | } 214 | let value = get_string(&mut res)?; 215 | kv.insert(key, value); 216 | } 217 | 218 | // excuse this horrendous code, I don't know of a better way 219 | let motd = kv 220 | .remove("hostname") 221 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 222 | let game_type = kv 223 | .remove("gametype") 224 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 225 | let game_id = kv 226 | .remove("game_id") 227 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 228 | let version = kv 229 | .remove("version") 230 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 231 | let plugins = kv 232 | .remove("plugins") 233 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 234 | let map = kv 235 | .remove("map") 236 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 237 | let num_players = kv 238 | .remove("numplayers") 239 | .ok_or(QueryProtocolError::InvalidKeyValueSection)? 240 | .parse() 241 | .map_err(|_| QueryProtocolError::CannotParseInt)?; 242 | let max_players = kv 243 | .remove("maxplayers") 244 | .ok_or(QueryProtocolError::InvalidKeyValueSection)? 245 | .parse() 246 | .map_err(|_| QueryProtocolError::CannotParseInt)?; 247 | let host_port = kv 248 | .remove("hostport") 249 | .ok_or(QueryProtocolError::InvalidKeyValueSection)? 250 | .parse() 251 | .map_err(|_| QueryProtocolError::CannotParseInt)?; 252 | let host_ip = kv 253 | .remove("hostip") 254 | .ok_or(QueryProtocolError::InvalidKeyValueSection)?; 255 | 256 | // skip 10 meaningless padding bytes 257 | for _ in 0..10 { 258 | res.get_u8(); 259 | } 260 | 261 | // players section 262 | let mut players = vec![]; 263 | loop { 264 | let username = get_string(&mut res)?; 265 | if username.is_empty() { 266 | break; 267 | } 268 | players.push(username); 269 | } 270 | 271 | Ok(FullStatResponse { 272 | motd, 273 | game_type, 274 | game_id, 275 | version, 276 | plugins, 277 | map, 278 | num_players, 279 | max_players, 280 | host_port, 281 | host_ip, 282 | players, 283 | }) 284 | } 285 | 286 | create_timeout!(stat_basic, BasicStatResponse); 287 | create_timeout!(stat_full, FullStatResponse); 288 | 289 | /// Perform a handshake request per 290 | /// 291 | /// # Returns 292 | /// A tuple `(challenge_token, session_id)` to be used in subsequent server interactions 293 | /// 294 | /// # Errors 295 | /// Returns `Err` if there was a network error, or if the returned token was not valid. 296 | async fn handshake(socket: &UdpSocket) -> io::Result<(i32, i32)> { 297 | // generate new token per interaction to avoid reset problems 298 | #[allow(clippy::cast_possible_wrap)] // this is fine, we don't care about the value 299 | let session_id = (random::() & SESSION_ID_MASK) as i32; 300 | 301 | let mut req = BytesMut::with_capacity(7); 302 | req.put_u16(QUERY_MAGIC); 303 | req.put_u8(9); // packet type 9 - handshake 304 | req.put_i32(session_id); 305 | // no payload for handshake requests 306 | 307 | socket.send(&req).await?; 308 | 309 | let mut response = Box::pin(recv_packet(socket)).await?; 310 | validate_packet(&mut response, 9, session_id)?; 311 | 312 | let token_str = get_string(&mut response)?; 313 | 314 | token_str 315 | .parse() 316 | .map(|t| (t, session_id)) 317 | .map_err(|_| QueryProtocolError::CannotParseInt.into()) 318 | } 319 | 320 | async fn recv_packet(socket: &UdpSocket) -> io::Result { 321 | let mut buf = [0u8; 65536]; 322 | socket.recv(&mut buf).await?; 323 | 324 | Ok(Bytes::copy_from_slice(&buf)) 325 | } 326 | 327 | fn validate_packet(packet: &mut Bytes, expected_type: u8, expected_session: i32) -> io::Result<()> { 328 | let recv_type = packet.get_u8(); 329 | if recv_type != expected_type { 330 | return Err(QueryProtocolError::InvalidPacketType.into()); 331 | } 332 | 333 | let recv_session = packet.get_i32(); 334 | if recv_session != expected_session { 335 | return Err(QueryProtocolError::SessionIdMismatch.into()); 336 | } 337 | 338 | Ok(()) 339 | } 340 | 341 | fn get_string(bytes: &mut Bytes) -> io::Result { 342 | let mut buf = vec![]; 343 | loop { 344 | let byte = bytes.get_u8(); 345 | if byte == 0 { 346 | break; 347 | } 348 | buf.push(byte); 349 | } 350 | 351 | String::from_utf8(buf).map_err(|_| QueryProtocolError::InvalidUtf8.into()) 352 | } 353 | 354 | #[cfg(test)] 355 | mod tests { 356 | use tokio::io; 357 | 358 | use super::{stat_basic, stat_full}; 359 | 360 | #[tokio::test] 361 | async fn test_stat_basic() -> io::Result<()> { 362 | let response = stat_basic("localhost", 25565).await?; 363 | println!("{response:#?}"); 364 | 365 | Ok(()) 366 | } 367 | 368 | #[tokio::test] 369 | async fn test_stat_full() -> io::Result<()> { 370 | let response = stat_full("localhost", 25565).await?; 371 | println!("{response:#?}"); 372 | 373 | Ok(()) 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/rcon.rs: -------------------------------------------------------------------------------- 1 | //! Enables remote command execution for minecraft servers. 2 | //! See the documentation for [`RconClient`] for more information. 3 | 4 | mod client; 5 | mod packet; 6 | 7 | #[allow(clippy::module_name_repetitions)] 8 | pub use client::RconClient; 9 | 10 | const MAX_LEN_CLIENTBOUND: usize = 4096; 11 | const MAX_LEN_SERVERBOUND: usize = 1446; 12 | -------------------------------------------------------------------------------- /src/rcon/client.rs: -------------------------------------------------------------------------------- 1 | //! Implementation of the [RCON](https://wiki.vg/RCON) protocol. 2 | 3 | use super::{ 4 | packet::{RconPacket, RconPacketType}, 5 | MAX_LEN_CLIENTBOUND, 6 | }; 7 | use crate::errors::{timeout_err, RconProtocolError}; 8 | use bytes::{BufMut, BytesMut}; 9 | use std::time::Duration; 10 | use tokio::{ 11 | io::{self, AsyncReadExt, AsyncWriteExt, Error}, 12 | net::TcpStream, 13 | time::timeout, 14 | }; 15 | 16 | /// Struct that stores the connection and other state of the RCON protocol with the server. 17 | /// 18 | /// # Examples 19 | /// 20 | /// ```no_run 21 | /// use mc_query::rcon::RconClient; 22 | /// use tokio::io::Result; 23 | /// 24 | /// #[tokio::main] 25 | /// async fn main() -> Result<()> { 26 | /// let mut client = RconClient::new("localhost", 25575).await?; 27 | /// client.authenticate("password").await?; 28 | /// 29 | /// let output = client.run_command("time set day").await?; 30 | /// println!("{output}"); 31 | /// 32 | /// Ok(()) 33 | /// } 34 | /// ``` 35 | #[allow(clippy::module_name_repetitions)] 36 | #[derive(Debug)] 37 | pub struct RconClient { 38 | socket: TcpStream, 39 | timeout: Option, 40 | } 41 | 42 | impl RconClient { 43 | /// Construct an [`RconClient`] that connects to the given host and port. 44 | /// Note: to authenticate use the `authenticate` method, this method does not take a password. 45 | /// 46 | /// Clients constructed this way will wait arbitrarily long (maybe forever!) to recieve 47 | /// a response from the server. To set a timeout, see [`with_timeout`] or [`set_timeout`]. 48 | /// 49 | /// # Arguments 50 | /// * `host` - A string slice that holds the hostname of the server to connect to. 51 | /// * `port` - The port to connect to. 52 | /// 53 | /// # Errors 54 | /// Returns `Err` if there was a network error. 55 | pub async fn new(host: &str, port: u16) -> io::Result { 56 | let connection = TcpStream::connect(format!("{host}:{port}")).await?; 57 | 58 | Ok(Self { 59 | socket: connection, 60 | timeout: None, 61 | }) 62 | } 63 | 64 | /// Construct an [`RconClient`] that connects to the given host and port, and a connection 65 | /// timeout. 66 | /// Note: to authenticate use the `authenticate` method, this method does not take a password. 67 | /// 68 | /// Note that timeouts are not precise, and may vary on the order of milliseconds, because 69 | /// of the way the async event loop works. 70 | /// 71 | /// # Arguments 72 | /// * `host` - A string slice that holds the hostname of the server to connect to. 73 | /// * `port` - The port to connect to. 74 | /// * `timeout` - A duration to wait for each response to arrive in. 75 | /// 76 | /// # Errors 77 | /// Returns `Err` if there was a network error. 78 | pub async fn with_timeout(host: &str, port: u16, timeout: Duration) -> io::Result { 79 | let mut client = Self::new(host, port).await?; 80 | client.set_timeout(Some(timeout)); 81 | 82 | Ok(client) 83 | } 84 | 85 | /// Change the timeout for future requests. 86 | /// 87 | /// # Arguments 88 | /// * `timeout` - an option specifying the duration to wait for a response. 89 | /// if none, the client may wait forever. 90 | pub fn set_timeout(&mut self, timeout: Option) { 91 | self.timeout = timeout; 92 | } 93 | 94 | /// Disconnect from the server and close the RCON connection. 95 | /// 96 | /// # Errors 97 | /// Returns `Err` if there was an issue closing the connection. 98 | pub async fn disconnect(mut self) -> io::Result<()> { 99 | self.socket.shutdown().await 100 | } 101 | 102 | /// Authenticate with the server, with the given password. 103 | /// 104 | /// If authentication fails, this method will return [`RconProtocolError::AuthFailed`]. 105 | /// 106 | /// # Arguments 107 | /// * `password` - A string slice that holds the RCON password. 108 | /// 109 | /// # Errors 110 | /// Returns the raw `tokio::io::Error` if there was a network error. 111 | /// Returns an apprpriate [`RconProtocolError`] if the authentication failed for other reasons. 112 | /// Also returns an error if a timeout is set, and the response is not recieved in that timeframe. 113 | pub async fn authenticate(&mut self, password: &str) -> io::Result<()> { 114 | let to = self.timeout; 115 | let fut = self.authenticate_raw(password); 116 | 117 | match to { 118 | None => fut.await, 119 | Some(d) => timeout(d, fut).await.unwrap_or(timeout_err()), 120 | } 121 | } 122 | 123 | /// Run the given command on the server and return the result. 124 | /// 125 | /// # Arguments 126 | /// * `command` - A string slice that holds the command to run. Must be ASCII and under 1446 bytes in length. 127 | /// 128 | /// # Errors 129 | /// Returns an error if there was a network issue or an [`RconProtocolError`] for other failures. 130 | /// Also returns an error if a timeout was set and a response was not recieved in that timeframe. 131 | pub async fn run_command(&mut self, command: &str) -> io::Result { 132 | let to = self.timeout; 133 | let fut = self.run_command_raw(command); 134 | 135 | match to { 136 | None => fut.await, 137 | Some(d) => timeout(d, fut).await.unwrap_or(timeout_err()), 138 | } 139 | } 140 | 141 | async fn authenticate_raw(&mut self, password: &str) -> io::Result<()> { 142 | let packet = 143 | RconPacket::new(1, RconPacketType::Login, password.to_string()).map_err(Error::from)?; 144 | 145 | self.write_packet(packet).await?; 146 | 147 | let packet = self.read_packet().await?; 148 | 149 | if !matches!(packet.packet_type, RconPacketType::RunCommand) { 150 | return Err(RconProtocolError::InvalidPacketType.into()); 151 | } 152 | 153 | if packet.request_id == -1 { 154 | return Err(RconProtocolError::AuthFailed.into()); 155 | } else if packet.request_id != 1 { 156 | return Err(RconProtocolError::RequestIdMismatch.into()); 157 | } 158 | 159 | Ok(()) 160 | } 161 | 162 | async fn run_command_raw(&mut self, command: &str) -> io::Result { 163 | let packet = RconPacket::new(1, RconPacketType::RunCommand, command.to_string()) 164 | .map_err(Error::from)?; 165 | 166 | self.write_packet(packet).await?; 167 | 168 | let mut full_payload = String::new(); 169 | 170 | loop { 171 | let recieved = self.read_packet().await?; 172 | 173 | if recieved.request_id == -1 { 174 | return Err(RconProtocolError::AuthFailed.into()); 175 | } else if recieved.request_id != 1 { 176 | return Err(RconProtocolError::RequestIdMismatch.into()); 177 | } 178 | 179 | full_payload.push_str(&recieved.payload); 180 | 181 | // wiki says this method of determining if this is the end of the 182 | // response is not 100% reliable, but this is the best solution imo 183 | // if this ends up being a problem, this can be changed later 184 | if recieved.payload.len() < MAX_LEN_CLIENTBOUND { 185 | break; 186 | } 187 | } 188 | 189 | Ok(full_payload) 190 | } 191 | 192 | /// Read a packet from the socket. 193 | async fn read_packet(&mut self) -> io::Result { 194 | let len = self.socket.read_i32_le().await?; 195 | 196 | let mut bytes = BytesMut::new(); 197 | bytes.put_i32_le(len); 198 | 199 | for _ in 0..len { 200 | let current = self.socket.read_u8().await?; 201 | bytes.put_u8(current); 202 | } 203 | 204 | RconPacket::try_from(bytes.freeze()).map_err(Error::from) 205 | } 206 | 207 | /// Write a packet to the socket. 208 | /// 209 | /// # Arguments 210 | /// * `packet` - An owned [`RconPacket`] to write to the socket. 211 | async fn write_packet(&mut self, packet: RconPacket) -> io::Result<()> { 212 | let bytes = packet.bytes(); 213 | 214 | self.socket.write_all(&bytes).await 215 | } 216 | } 217 | 218 | #[cfg(test)] 219 | mod tests { 220 | use super::RconClient; 221 | use tokio::io; 222 | 223 | #[tokio::test] 224 | async fn test_rcon_command() -> io::Result<()> { 225 | let mut client = RconClient::new("localhost", 25575).await?; 226 | client.authenticate("mc-query-test").await?; 227 | let response = client.run_command("time set day").await?; 228 | 229 | println!("recieved response: {response}"); 230 | 231 | Ok(()) 232 | } 233 | 234 | #[tokio::test] 235 | async fn test_rcon_unauthenticated() -> io::Result<()> { 236 | let mut client = RconClient::new("localhost", 25575).await?; 237 | let result = client.run_command("time set day").await; 238 | 239 | assert!(result.is_err()); 240 | 241 | Ok(()) 242 | } 243 | 244 | #[tokio::test] 245 | async fn test_rcon_incorrect_password() -> io::Result<()> { 246 | let mut client = RconClient::new("localhost", 25575).await?; 247 | let result = client.authenticate("incorrect").await; 248 | 249 | assert!(result.is_err()); 250 | 251 | Ok(()) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/rcon/packet.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::RconProtocolError; 2 | use bytes::{Buf, BufMut, Bytes, BytesMut}; 3 | use std::mem::size_of; 4 | 5 | use super::{MAX_LEN_CLIENTBOUND, MAX_LEN_SERVERBOUND}; 6 | 7 | #[derive(Debug)] 8 | pub(super) enum RconPacketType { 9 | Response, 10 | Login, 11 | RunCommand, 12 | } 13 | 14 | impl From for i32 { 15 | fn from(packet_type: RconPacketType) -> Self { 16 | match packet_type { 17 | RconPacketType::Response => 0, 18 | RconPacketType::RunCommand => 2, 19 | RconPacketType::Login => 3, 20 | } 21 | } 22 | } 23 | 24 | impl TryFrom for RconPacketType { 25 | type Error = RconProtocolError; 26 | 27 | fn try_from(value: i32) -> Result { 28 | match value { 29 | 0 => Ok(RconPacketType::Response), 30 | 2 => Ok(RconPacketType::RunCommand), 31 | 3 => Ok(RconPacketType::Login), 32 | _ => Err(RconProtocolError::InvalidPacketType), 33 | } 34 | } 35 | } 36 | 37 | #[derive(Debug)] 38 | pub(super) struct RconPacket { 39 | pub request_id: i32, 40 | pub packet_type: RconPacketType, 41 | pub payload: String, 42 | } 43 | 44 | impl RconPacket { 45 | pub fn new( 46 | request_id: i32, 47 | packet_type: RconPacketType, 48 | payload: String, 49 | ) -> Result { 50 | if !payload.is_ascii() { 51 | return Err(RconProtocolError::NonAsciiPayload); 52 | } 53 | 54 | if payload.len() > Ord::max(MAX_LEN_CLIENTBOUND, MAX_LEN_SERVERBOUND) { 55 | return Err(RconProtocolError::PayloadTooLong); 56 | } 57 | 58 | Ok(Self { 59 | request_id, 60 | packet_type, 61 | payload, 62 | }) 63 | } 64 | 65 | pub fn bytes(self) -> Bytes { 66 | Bytes::from(self) 67 | } 68 | } 69 | 70 | impl TryFrom for RconPacket { 71 | type Error = RconProtocolError; 72 | 73 | fn try_from(mut bytes: Bytes) -> Result { 74 | let len = bytes.get_i32_le(); // length of remaining packet (not including this integer) 75 | let request_id = bytes.get_i32_le(); 76 | let packet_type = bytes.get_i32_le(); 77 | 78 | let mut payload = String::new(); 79 | loop { 80 | let current = bytes.get_u8(); 81 | if current == 0 { 82 | // null terminated ASCII string, so stop reading here 83 | break; 84 | } 85 | 86 | payload.push(current as char); 87 | } 88 | 89 | // if the payload is already normal ASCII (without 0xa7), no need to 90 | // check each character to be ASCII or 0xa7 91 | if !payload.is_ascii() { 92 | for c in payload.chars() { 93 | // 0xa7 is an acceptable (though non-ASCII) character 94 | if !c.is_ascii() && (c as u8) != 0xa7 { 95 | return Err(RconProtocolError::NonAsciiPayload); 96 | } 97 | } 98 | } 99 | 100 | let pad = bytes.get_u8(); // there must be a remaining 0 byte as padding 101 | if pad != 0 { 102 | return Err(RconProtocolError::InvalidRconResponse); 103 | } 104 | 105 | // validate if the lengths match 106 | if get_remaining_length(&payload) != len { 107 | return Err(RconProtocolError::InvalidRconResponse); 108 | } 109 | 110 | Self::new(request_id, packet_type.try_into()?, payload) 111 | } 112 | } 113 | 114 | impl From for Bytes { 115 | fn from(packet: RconPacket) -> Self { 116 | let len = get_remaining_length(&packet.payload); 117 | let packet_type: i32 = packet.packet_type.into(); 118 | 119 | let mut bytes = BytesMut::new(); 120 | 121 | bytes.put_i32_le(len); 122 | bytes.put_i32_le(packet.request_id); 123 | bytes.put_i32_le(packet_type); 124 | bytes.put(packet.payload.as_bytes()); 125 | bytes.put_u16(0x00_00); 126 | 127 | bytes.freeze() 128 | } 129 | } 130 | 131 | /// Get the *remaining length* of the packet given its payload. 132 | /// 133 | /// Remaining length here refers to the length of the packet in bytes excluding 134 | /// the first four bytes which communicate this value. So it refers to the 135 | /// length of the packet *after* the length field. 136 | /// 137 | /// As the remainder of the packet is composed of two [i32]s (request ID and type), 138 | /// the payload, and **TWO** 0 bytes (because rust strings are not null-terminated), 139 | /// it is the size of two [i32]s + the length of the payload + 2. 140 | fn get_remaining_length(payload: &str) -> i32 { 141 | (payload.len() + size_of::() * 2 + 2) as i32 142 | } 143 | -------------------------------------------------------------------------------- /src/socket.rs: -------------------------------------------------------------------------------- 1 | use crate::varint::{VarInt, CONTINUE_BIT}; 2 | use async_trait::async_trait; 3 | use bytes::{BufMut, BytesMut}; 4 | use std::io::{Error, ErrorKind}; 5 | use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, Result}; 6 | 7 | /// Trait to allow for reading and writing `VarInt`s from the socket. 8 | /// 9 | /// The type is specified [in wiki.vg](https://wiki.vg/Protocol#VarInt_and_VarLong). 10 | #[async_trait] 11 | pub(crate) trait ReadWriteVarInt { 12 | /// Read a [VarInt] from the socket. 13 | /// Returns the parsed value as [i32] in a [Result]. 14 | async fn read_varint(&mut self) -> Result; 15 | } 16 | 17 | /// Trait to allow for reading and writing strings from the socket. 18 | /// 19 | /// The format for strings is specified [in this table in wiki.vg](https://wiki.vg/Protocol#Data_types). 20 | /// It is a UTF-8 string prefixed with its size in bytes as a [`VarInt`]. 21 | #[async_trait] 22 | pub(crate) trait ReadWriteMinecraftString { 23 | /// Read a [String] from the socket. 24 | /// Returns the parsed value recieved from the socket in a [Result]. 25 | async fn read_mc_string(&mut self) -> Result; 26 | } 27 | 28 | #[async_trait] 29 | impl ReadWriteVarInt for T 30 | where 31 | T: AsyncRead + AsyncWrite + Unpin + Send, 32 | { 33 | async fn read_varint(&mut self) -> Result { 34 | let mut bytes = BytesMut::with_capacity(5); 35 | 36 | loop { 37 | let current = self.read_u8().await?; 38 | bytes.put_u8(current); 39 | 40 | if current & CONTINUE_BIT == 0 { 41 | break; 42 | } 43 | } 44 | 45 | VarInt::new(bytes.freeze()) 46 | .try_into() 47 | .map_err(|err| Error::new(ErrorKind::InvalidData, err)) 48 | } 49 | } 50 | 51 | #[async_trait] 52 | impl ReadWriteMinecraftString for T 53 | where 54 | T: AsyncRead + AsyncWrite + Unpin + Send, 55 | { 56 | async fn read_mc_string(&mut self) -> Result { 57 | let len = self.read_varint().await?; 58 | let mut buffer = vec![0; len as usize]; 59 | self.read_exact(&mut buffer).await?; 60 | 61 | String::from_utf8(buffer).map_err(|err| Error::new(ErrorKind::InvalidData, err)) 62 | } 63 | } -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | //! Get the status of a server using the [Server List Ping](https://wiki.vg/Server_List_Ping) protocol. 2 | //! See documentation for [`status`] for more information. 3 | 4 | pub mod data; 5 | mod packet; 6 | 7 | use crate::{ 8 | errors::MinecraftProtocolError, 9 | socket::{ReadWriteMinecraftString, ReadWriteVarInt}, 10 | varint::VarInt, 11 | }; 12 | use tokio::{ 13 | io::{self, AsyncWriteExt, Interest}, 14 | net::TcpStream, 15 | }; 16 | 17 | use self::{ 18 | data::StatusResponse, 19 | packet::{Packet, PacketId}, 20 | }; 21 | 22 | /// Ping the server for information following the [Server List Ping](https://wiki.vg/Server_List_Ping) protocol. 23 | /// 24 | /// # Arguments 25 | /// * `host` - A string slice that holds the hostname of the server to connect to. 26 | /// * `port` - The port to connect to on that server. 27 | /// 28 | /// # Errors 29 | /// Returns `Err` if there was a network issue or the server sent invalid data. 30 | /// 31 | /// # Examples 32 | /// ``` 33 | /// use mc_query::status; 34 | /// use tokio::io::Result; 35 | /// 36 | /// #[tokio::main] 37 | /// async fn main() -> Result<()> { 38 | /// let data = status("mc.hypixel.net", 25565).await?; 39 | /// println!("{data:#?}"); 40 | /// 41 | /// Ok(()) 42 | /// } 43 | /// ``` 44 | pub async fn status(host: &str, port: u16) -> io::Result { 45 | let mut socket = TcpStream::connect(format!("{host}:{port}")).await?; 46 | 47 | socket 48 | .ready(Interest::READABLE | Interest::WRITABLE) 49 | .await?; 50 | 51 | // handshake packet 52 | // https://wiki.vg/Server_List_Ping#Handshake 53 | let handshake = Packet::builder(PacketId::Handshake) 54 | .add_varint(&VarInt::from(-1)) 55 | .add_string(host) 56 | .add_u16(port) 57 | .add_varint(&VarInt::from(PacketId::Status)) 58 | .build(); 59 | 60 | socket.write_all(&handshake.bytes()).await?; 61 | 62 | // status request packet 63 | // https://wiki.vg/Server_List_Ping#Status_Request 64 | let status_request = Packet::builder(PacketId::Handshake).build(); 65 | socket.write_all(&status_request.bytes()).await?; 66 | 67 | // listen to status response 68 | // https://wiki.vg/Server_List_Ping#Status_Response 69 | let _len = socket.read_varint().await?; 70 | let id = socket.read_varint().await?; 71 | 72 | if id != 0 { 73 | return Err(MinecraftProtocolError::InvalidStatusResponse.into()); 74 | } 75 | 76 | let data = socket.read_mc_string().await?; 77 | socket.shutdown().await?; 78 | 79 | serde_json::from_str::(&data) 80 | .map_err(|_| MinecraftProtocolError::InvalidStatusResponse.into()) 81 | } 82 | 83 | create_timeout!(status, StatusResponse); 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | use super::status; 88 | use tokio::io::Result; 89 | 90 | #[tokio::test] 91 | async fn test_hypixel_status() -> Result<()> { 92 | let data = status("mc.hypixel.net", 25565).await?; 93 | println!("{data:#?}"); 94 | 95 | Ok(()) 96 | } 97 | 98 | #[tokio::test] 99 | async fn test_local_status() -> Result<()> { 100 | let data = status("localhost", 25565).await?; 101 | println!("{data:#?}"); 102 | 103 | Ok(()) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/status/data.rs: -------------------------------------------------------------------------------- 1 | //! Implementation of the [Server List Ping](https://wiki.vg/Server_List_Ping) protocol 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Response from the server with status information. 6 | /// Represents [this JSON object](https://wiki.vg/Server_List_Ping#Status_Response) 7 | /// to be serialized and deserialized. 8 | #[derive(Debug, Serialize, Deserialize)] 9 | pub struct StatusResponse { 10 | /// Information about the game and protocol version. 11 | /// See [Version] for more information. 12 | pub version: Version, 13 | 14 | // Information about players on the server. 15 | /// See [Players] for more information. 16 | pub players: Players, 17 | 18 | /// The "motd" - message shown in the server list by the client. 19 | #[serde(rename = "description")] 20 | pub motd: Option, 21 | 22 | /// URI to the server's favicon. 23 | pub favicon: Option, 24 | 25 | /// Does the server preview chat? 26 | #[serde(rename = "previewsChat")] 27 | pub previews_chat: Option, 28 | 29 | /// Does the server use signed chat messages? 30 | /// Only returned for servers post 1.19.1 31 | #[serde(rename = "enforcesSecureChat")] 32 | pub enforces_secure_chat: Option, 33 | } 34 | 35 | /// Struct that stores information about players on the server. 36 | /// 37 | /// Not intended to be used directly, but only as a part of [`StatusResponse`]. 38 | #[derive(Debug, Serialize, Deserialize)] 39 | pub struct Players { 40 | /// The maximum number of players allowed on the server. 41 | pub max: u32, 42 | 43 | /// The number of players currently online. 44 | pub online: u32, 45 | 46 | /// A listing of some online Players. 47 | /// See [Sample] for more information. 48 | pub sample: Option>, 49 | } 50 | 51 | /// A player listed on the server's list ping information. 52 | /// 53 | /// Not intended to be used directly, but only as a part of [`StatusResponse`]. 54 | #[derive(Debug, Serialize, Deserialize)] 55 | pub struct Sample { 56 | /// The player's username. 57 | pub name: String, 58 | 59 | /// The player's UUID. 60 | pub id: String, 61 | } 62 | 63 | /// Struct that stores version information about the server. 64 | /// 65 | /// Not intended to be used directly, but only as a part of [`StatusResponse`]. 66 | #[derive(Debug, Serialize, Deserialize)] 67 | pub struct Version { 68 | /// The game version (e.g: 1.19.1) 69 | pub name: String, 70 | /// The version of the [Protocol](https://wiki.vg/Protocol) being used. 71 | /// 72 | /// See [the wiki.vg page](https://wiki.vg/Protocol_version_numbers) for a 73 | /// reference on what versions these correspond to. 74 | pub protocol: i64, 75 | } 76 | 77 | /// Represents a chat object (the MOTD is sent as a chat object). 78 | #[derive(Debug, Serialize, Deserialize)] 79 | #[serde(untagged)] 80 | pub enum ChatObject { 81 | /// An individual chat object 82 | Object(ChatComponentObject), 83 | 84 | /// Vector of multiple chat objects 85 | Array(Vec), 86 | 87 | /// Unknown data - raw JSON 88 | JsonPrimitive(serde_json::Value), 89 | } 90 | 91 | /// A piece of a `ChatObject` 92 | #[derive(Debug, Serialize, Deserialize)] 93 | pub struct ChatComponentObject { 94 | /// Text of the chat message 95 | pub text: Option, 96 | 97 | /// Translation key if the message needs to pull from the language file. 98 | /// See [wiki.vg](https://wiki.vg/Chat#Translation_component) 99 | pub translate: Option, 100 | 101 | /// Displays the keybind for the specified key, or the string itself if unknown. 102 | pub keybind: Option, 103 | 104 | /// Should the text be rendered **bold**? 105 | pub bold: Option, 106 | 107 | /// Should the text be rendered *italic*? 108 | pub italic: Option, 109 | 110 | /// Should the text be rendered __underlined__? 111 | pub underlined: Option, 112 | 113 | /// Should the text be rendered as ~~strikethrough~~ 114 | pub strikethrough: Option, 115 | 116 | /// Should the text be rendered as obfuscated? 117 | /// Switching randomly between characters of the same width 118 | pub obfuscated: Option, 119 | 120 | /// The font to use to render, comes in three options: 121 | /// * `minecraft:uniform` - Unicode font 122 | /// * `minecraft:alt` - enchanting table font 123 | /// * `minecraft:default` - font based on resource pack (1.16+) 124 | /// 125 | /// Any other value can be ignored 126 | pub font: Option, 127 | 128 | /// The color to display the chat item in. 129 | /// Can be a [chat color](https://wiki.vg/Chat#Colors), 130 | /// [format code](https://wiki.vg/Chat#Styles), 131 | /// or any valid web color 132 | pub color: Option, 133 | 134 | /// Text to insert into the chat box when shift-clicking this component 135 | pub insertion: Option, 136 | 137 | /// Defines an event that occurs when this chat item is clicked 138 | #[serde(rename = "clickEvent")] 139 | pub click_event: Option, 140 | 141 | /// Defines an event that occurs when this chat item is hovered on 142 | #[serde(rename = "hoverEvent")] 143 | pub hover_event: Option, 144 | 145 | /// Sibling components to this chat item. 146 | /// If present, will not be empty 147 | pub extra: Option>, 148 | } 149 | 150 | /// `ClickEvent` data for a chat component 151 | #[derive(Debug, Serialize, Deserialize)] 152 | pub struct ChatClickEvent { 153 | // These are not renamed on purpose. (server returns them in snake_case) 154 | /// Opens the URL in the user's default browser. Protocol must be `http` or `https` 155 | pub open_url: Option, 156 | 157 | /// Runs the command. 158 | /// Simply causes the user to say the string in chat - 159 | /// so only has command effect if it starts with / 160 | /// 161 | /// Irrelevant for motd purposes. 162 | pub run_command: Option, 163 | 164 | /// Replaces the content of the user's chat box with the given text. 165 | /// 166 | /// Irrelevant for motd purposes. 167 | pub suggest_command: Option, 168 | 169 | /// Copies the given text into the client's clipboard. 170 | pub copy_to_clipboard: Option, 171 | } 172 | 173 | /// `HoverEvent` data for a chat component 174 | #[derive(Debug, Serialize, Deserialize)] 175 | pub struct ChatHoverEvent { 176 | // These are not renamed on purpose. (server returns them in snake_case) 177 | /// Text to show when the item is hovered over 178 | pub show_text: Option>, 179 | 180 | /// Same as `show_text`, but for servers < 1.16 181 | pub value: Option>, 182 | 183 | /// Displays the item of the given NBT 184 | pub show_item: Option, 185 | 186 | /// Displays information about the entity with the given NBT 187 | pub show_entity: Option, 188 | } 189 | -------------------------------------------------------------------------------- /src/status/packet.rs: -------------------------------------------------------------------------------- 1 | use crate::{errors::MinecraftProtocolError, varint::VarInt}; 2 | use bytes::{BufMut, Bytes, BytesMut}; 3 | 4 | #[derive(Debug)] 5 | pub(super) enum PacketId { 6 | Handshake = 0, 7 | Status = 1, 8 | } 9 | 10 | impl From for u8 { 11 | fn from(id: PacketId) -> Self { 12 | match id { 13 | PacketId::Handshake => 0, 14 | PacketId::Status => 1, 15 | } 16 | } 17 | } 18 | 19 | impl TryFrom for PacketId { 20 | type Error = MinecraftProtocolError; 21 | 22 | fn try_from(value: u8) -> Result { 23 | match value { 24 | 0 => Ok(Self::Handshake), 25 | 1 => Ok(Self::Status), 26 | _ => Err(MinecraftProtocolError::InvalidState), 27 | } 28 | } 29 | } 30 | 31 | impl From for VarInt { 32 | fn from(id: PacketId) -> Self { 33 | let number: u8 = id.into(); 34 | VarInt::from(number as i32) 35 | } 36 | } 37 | 38 | #[derive(Debug)] 39 | pub(super) struct Packet { 40 | id: u8, 41 | payload: Bytes, 42 | } 43 | 44 | impl Packet { 45 | pub fn builder(id: PacketId) -> PacketBuilder { 46 | PacketBuilder::new(id) 47 | } 48 | 49 | pub fn bytes(self) -> Bytes { 50 | self.into() 51 | } 52 | } 53 | 54 | impl From for Bytes { 55 | fn from(packet: Packet) -> Self { 56 | let len: i32 = (VarInt::from(packet.id as i32).bytes().len() + packet.payload.len()) as i32; 57 | let mut bytes = BytesMut::new(); 58 | 59 | bytes.extend_from_slice(&VarInt::from(len).bytes()); 60 | bytes.put_u8(packet.id); 61 | bytes.extend_from_slice(&packet.payload); 62 | 63 | bytes.freeze() 64 | } 65 | } 66 | 67 | #[derive(Debug)] 68 | pub(super) struct PacketBuilder { 69 | id: PacketId, 70 | bytes: BytesMut, 71 | } 72 | 73 | impl PacketBuilder { 74 | pub fn new(id: PacketId) -> Self { 75 | Self { 76 | id, 77 | bytes: BytesMut::new(), 78 | } 79 | } 80 | 81 | pub fn add_varint(mut self, varint: &VarInt) -> Self { 82 | self.bytes.extend_from_slice(varint); 83 | self 84 | } 85 | 86 | pub fn add_string(self, string: &str) -> Self { 87 | let mut inst = self.add_varint(&VarInt::from(string.len() as i32)); 88 | inst.bytes.put(string.as_bytes()); 89 | inst 90 | } 91 | 92 | pub fn add_u16(mut self, short: u16) -> Self { 93 | self.bytes.put_u16(short); 94 | self 95 | } 96 | 97 | pub fn build(self) -> Packet { 98 | Packet { 99 | id: self.id.into(), 100 | payload: self.bytes.freeze(), 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/varint.rs: -------------------------------------------------------------------------------- 1 | use bytes::Bytes; 2 | use std::ops::Deref; 3 | 4 | use crate::errors::MinecraftProtocolError; 5 | 6 | pub(crate) const SEGMENT_BITS: u8 = 0x7f; // 0111 1111 7 | pub(crate) const CONTINUE_BIT: u8 = 0x80; // 1000 0000 8 | 9 | pub(crate) struct VarInt { 10 | bytes: Bytes, 11 | } 12 | 13 | impl VarInt { 14 | pub(crate) fn new(bytes: Bytes) -> Self { 15 | Self { bytes } 16 | } 17 | 18 | pub(crate) fn bytes(&self) -> Bytes { 19 | self.bytes.clone() 20 | } 21 | } 22 | 23 | impl From for VarInt { 24 | fn from(value: i32) -> Self { 25 | let mut value = (value as u64) & 0xffff_ffff; 26 | let mut buffer = vec![]; 27 | 28 | loop { 29 | let temp = (value & SEGMENT_BITS as u64) as u8; 30 | value >>= 7; 31 | 32 | if value != 0 { 33 | buffer.push(temp | CONTINUE_BIT); 34 | } else { 35 | buffer.push(temp); 36 | } 37 | 38 | if value == 0 { 39 | break; 40 | } 41 | } 42 | 43 | Self { 44 | bytes: Bytes::from(Box::from(buffer)), 45 | } 46 | } 47 | } 48 | 49 | impl TryInto for VarInt { 50 | type Error = MinecraftProtocolError; 51 | 52 | fn try_into(self) -> Result { 53 | let mut value: i32 = 0; 54 | let mut position = 0; 55 | 56 | for current_byte in self.bytes { 57 | value |= ((current_byte & SEGMENT_BITS) as i32) << position; 58 | 59 | if current_byte & CONTINUE_BIT == 0 { 60 | return Ok(value); 61 | } 62 | 63 | position += 7; 64 | if position >= 32 { 65 | return Err(MinecraftProtocolError::InvalidVarInt); 66 | } 67 | } 68 | 69 | unreachable!(); 70 | } 71 | } 72 | 73 | impl From for Bytes { 74 | fn from(varint: VarInt) -> Self { 75 | varint.bytes 76 | } 77 | } 78 | 79 | impl Deref for VarInt { 80 | type Target = [u8]; 81 | 82 | fn deref(&self) -> &Self::Target { 83 | &self.bytes 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use super::VarInt; 90 | use crate::errors::MinecraftProtocolError; 91 | use bytes::Bytes; 92 | use std::collections::HashMap; 93 | 94 | #[test] 95 | fn test_into_varint() { 96 | let cases = HashMap::from([ 97 | (0, b"\x00".as_slice()), 98 | (1, b"\x01"), 99 | (2, b"\x02"), 100 | (127, b"\x7f"), 101 | (128, b"\x80\x01"), 102 | (255, b"\xff\x01"), 103 | (25565, b"\xdd\xc7\x01"), 104 | (2097151, b"\xff\xff\x7f"), 105 | (i32::MAX, b"\xff\xff\xff\xff\x07"), 106 | (-1, b"\xff\xff\xff\xff\x0f"), 107 | (i32::MIN, b"\x80\x80\x80\x80\x08"), 108 | ]); 109 | 110 | for (k, v) in cases { 111 | let varint: VarInt = k.into(); 112 | assert_eq!(varint.bytes.len(), v.len()); 113 | assert_eq!(varint.bytes, v); 114 | } 115 | } 116 | 117 | #[test] 118 | fn test_from_varint() { 119 | let cases = HashMap::from([ 120 | (0, b"\x00".as_slice()), 121 | (1, b"\x01"), 122 | (2, b"\x02"), 123 | (127, b"\x7f"), 124 | (128, b"\x80\x01"), 125 | (255, b"\xff\x01"), 126 | (25565, b"\xdd\xc7\x01"), 127 | (2097151, b"\xff\xff\x7f"), 128 | (i32::MAX, b"\xff\xff\xff\xff\x07"), 129 | (-1, b"\xff\xff\xff\xff\x0f"), 130 | (i32::MIN, b"\x80\x80\x80\x80\x08"), 131 | ]) 132 | .into_iter() 133 | .map(|(k, v)| { 134 | ( 135 | k, 136 | VarInt { 137 | bytes: Bytes::from(v), 138 | }, 139 | ) 140 | }) 141 | .collect::>(); 142 | 143 | for (k, v) in cases { 144 | let x: Result = v.try_into(); 145 | 146 | if let Err(MinecraftProtocolError::InvalidVarInt) = x { 147 | panic!("{k} as VarInt returned Err during conversion"); 148 | } 149 | let x = x.unwrap(); 150 | 151 | assert_eq!(x, k); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /test: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | from argparse import ArgumentParser 4 | from hashlib import sha1 5 | from os import mkdir 6 | from pathlib import Path 7 | from signal import SIGINT, signal 8 | from shutil import which 9 | from subprocess import PIPE, run, Popen 10 | from sys import argv, stderr, exit 11 | 12 | JAR_URL = "https://piston-data.mojang.com/v1/objects/450698d1863ab5180c25d7c804ef0fe6369dd1ba/server.jar" 13 | JAR_SHA1 = JAR_URL.split("/")[-2] 14 | 15 | parser = ArgumentParser(description="mc-query test utility", prog=argv[0]) 16 | parser.add_argument( 17 | "--server-only", help="runs server only, without running cargo tests" 18 | ) 19 | args = parser.parse_args() 20 | 21 | if args.server_only: 22 | print("running server only, not cargo tests.") 23 | 24 | print("ensuring server directory exists...") 25 | if not Path("server").is_dir(): 26 | mkdir("server") 27 | 28 | print("checking for server.jar...") 29 | if not Path("server/server.jar").is_file(): 30 | print("server/server.jar not found... fetching server") 31 | run([which("curl"), JAR_URL, "-o", "server/server.jar"]) 32 | else: 33 | print("server.jar found. validating...") 34 | 35 | sha1sum = sha1() 36 | with open("server/server.jar", "rb") as f: 37 | block = f.read(2**16) 38 | while len(block) != 0: 39 | sha1sum.update(block) 40 | block = f.read(2**16) 41 | 42 | if sha1sum.hexdigest() != JAR_SHA1: 43 | print("could not verify integrity of server.jar... exiting...", file=stderr) 44 | exit(1) 45 | 46 | print("ensuring configuration files...") 47 | with open("server/eula.txt", "w") as f: 48 | f.writelines(["eula=true"]) 49 | 50 | with open("server/server.properties", "w") as f, open( 51 | "resources/server.properties", "r" 52 | ) as source: 53 | f.write(source.read()) 54 | 55 | print("starting server...") 56 | process = Popen( 57 | [which("java"), "-Xmx1G", "-jar", "server.jar", "nogui"], 58 | cwd="./server", 59 | stdin=PIPE, 60 | stdout=PIPE, 61 | stderr=PIPE, 62 | text=True, 63 | ) 64 | 65 | 66 | def signal_handler(_s, _f): 67 | global process 68 | process.kill() 69 | 70 | 71 | signal(SIGINT, signal_handler) 72 | 73 | for line in process.stdout: 74 | print(f"server log: {line}", end="") 75 | if (not args.server_only) and "RCON running on 0.0.0.0:25575" in line: 76 | print("server RCON is ready, starting tests") 77 | break 78 | 79 | if not args.server_only: 80 | run([which("cargo"), "test", "--", "--show-output"]) 81 | 82 | process.kill() 83 | --------------------------------------------------------------------------------