53 Commits

Author SHA1 Message Date
timvisee
69de7a95bf Bump version to 0.2.5 2021-11-25 15:04:39 +01:00
timvisee
3e7e5c4c03 Fix Windows CI builds having incorrect feature configuration 2021-11-25 15:04:21 +01:00
timvisee
4d774cd254 Update dependencies 2021-11-25 14:54:05 +01:00
timvisee
a1324d22a7 Various fixes and improvements 2021-11-25 14:41:43 +01:00
timvisee
a500f50064 Use probed server settings in lobby join game packet 2021-11-25 14:36:50 +01:00
timvisee
4ef1481f2b Fix unsupported dimension data in lobby, use proper default world name 2021-11-25 14:21:29 +01:00
timvisee
addfb1c135 Don't use lobby join method if server isn't probed yet when required 2021-11-25 14:20:58 +01:00
timvisee
956c428251 Add compile time feature compatibility check, remove from build.rs 2021-11-25 14:00:59 +01:00
timvisee
270362b152 Use consistent documentation file names 2021-11-25 13:51:24 +01:00
timvisee
93f75adc5c Update lobby limitations, describe whitelist issue with probe user 2021-11-25 13:50:15 +01:00
timvisee
40fe5b5dd2 Cleanup documentation 2021-11-25 13:42:57 +01:00
timvisee
e7d2c6f64c Resolve clippy warnings 2021-11-25 13:39:52 +01:00
timvisee
084c3c5f8b Fix compilation error 2021-11-25 13:27:44 +01:00
timvisee
d39ad9c913 Update TODO 2021-11-25 12:59:52 +01:00
timvisee
00b6cddd78 Change ban list reload log message from info to debug 2021-11-25 12:59:51 +01:00
timvisee
b8744aaf57 Fix protocol versions being handled incorrectly 2021-11-25 12:59:51 +01:00
timvisee
51d3ecf148 Report online mode error if lobby receives server encryption request 2021-11-25 12:59:51 +01:00
timvisee
75c7a09249 Handle unrecognized login plugin requests messages in lobby 2021-11-25 12:59:50 +01:00
timvisee
f6d60318e8 Support Minecraft v1.16.3 to v1.17.1 in lobby and probe logic 2021-11-25 12:59:50 +01:00
timvisee
b404ab0a87 Support multiple Minecraft protocol versions in lobby and probe logic 2021-11-25 12:59:50 +01:00
timvisee
cf6bd526d9 Dynamically build lobby dimension from probed server dimension codec 2021-11-25 12:59:48 +01:00
timvisee
f513957bff Add config option to enable server probing on start 2021-11-25 12:59:32 +01:00
timvisee
9b1f2a7011 Probe server on lazymc start to get up-to-date server details for Forge 2021-11-25 12:59:32 +01:00
timvisee
8b09faae3d Constrain supported lobby versions 2021-11-25 12:59:32 +01:00
timvisee
e23a61ab0f Replay client handshake when lobby connects to real server 2021-11-25 12:59:31 +01:00
timvisee
78a36978f5 Fix broken italic text in Windows usage guide 2021-11-24 14:09:03 +01:00
timvisee
0ba46caf5c Bump version to 0.2.4 2021-11-24 14:05:27 +01:00
timvisee
6b23490919 Resolve clippy warnings 2021-11-24 14:02:08 +01:00
timvisee
9e08ed6cda Update dependencies 2021-11-24 13:57:19 +01:00
timvisee
3271db1cb3 Resolve compile warning 2021-11-24 13:55:23 +01:00
timvisee
cf0e3ef15b Make server directory relative to configuration file directory 2021-11-24 13:53:38 +01:00
timvisee
ee21eb45fd Bump minimum supported Minecraft version to 1.7.2 2021-11-24 13:28:06 +01:00
timvisee
aebb5563e0 Only send status response favicon to client versions that support it 2021-11-24 13:27:42 +01:00
timvisee
20fb6ee715 Always include favicon in status response, fall back to default icon
See https://github.com/timvisee/lazymc/issues/11#issuecomment-977814539
2021-11-24 13:20:49 +01:00
timvisee
ea2dbc905c Add Minecraft default server icon, extracted from Minecraft 1.17.1 2021-11-24 13:02:41 +01:00
timvisee
eb66265670 Handle SIGTERM exit code as successful 2021-11-23 13:00:10 +01:00
timvisee
df101ce53b Fix typo in Cargo.toml key 2021-11-22 20:38:30 +01:00
timvisee
8f2ce9b4b8 Fix compilation error without lobby feature 2021-11-22 20:36:46 +01:00
timvisee
20902e6a94 Derive correct UUID for offline players in lobby logic (2/2) 2021-11-22 20:20:08 +01:00
timvisee
3e933f7566 Derive correct UUID for offline players in lobby logic 2021-11-22 20:14:29 +01:00
timvisee
46fa594065 Update features in README 2021-11-22 19:00:36 +01:00
timvisee
1d92964802 Bump version to 0.2.3 2021-11-22 18:55:50 +01:00
timvisee
9b8d569628 Update dependencies 2021-11-22 18:53:52 +01:00
timvisee
94f2fa01e2 Update TODO 2021-11-22 18:53:23 +01:00
timvisee
9b71052b61 Add extras document with recommendations and tips after installing 2021-11-22 18:51:06 +01:00
timvisee
0049ad456c Bump rcon to 0.5.2, which now includes changes from our fork 2021-11-22 18:36:12 +01:00
timvisee
0f2d7720af Add documentation for proper IP proxying with PROXY header 2021-11-22 18:35:40 +01:00
timvisee
723ebabcfb Minor monitoring tweaks 2021-11-22 17:57:44 +01:00
timvisee
f95682fcd5 Only enable RCON by default on Windows 2021-11-22 17:57:28 +01:00
timvisee
d5c854d16f Add option to send HAProxy header with RCON connections 2021-11-22 17:55:51 +01:00
timvisee
493e24ff4d Send proxy header with monitor requests 2021-11-22 16:53:35 +01:00
timvisee
6916800aeb Add send_proxy_v2 option to send HAProxy header to server 2021-11-22 14:37:07 +01:00
timvisee
e7c31f2619 Add protocol version documentation 2021-11-22 13:37:21 +01:00
54 changed files with 2464 additions and 568 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
# Test server
/mcserver
/bettermc

View File

@@ -69,7 +69,7 @@ check-windows:
script:
- cargo check --locked --verbose
- cargo check --locked --no-default-features --features rcon --verbose
- cargo check --locked --no-default-features --features lobby --verbose
- cargo check --locked --no-default-features --features rcon,lobby --verbose
# Build using Rust stable on Linux
build-x86_64-linux-gnu:
@@ -247,7 +247,7 @@ test-cargo-x86_64-windows:
script:
- cargo test --locked --verbose
- cargo test --locked --no-default-features --features rcon --verbose
- cargo test --locked --no-default-features --features lobby --verbose
- cargo test --locked --no-default-features --features rcon,lobby --verbose
# Release binaries on GitLab as generic package
release-gitlab-generic-package:

View File

@@ -1,5 +1,32 @@
# Changelog
## 0.2.5 (2021-11-25)
- Add support Minecraft 1.16.3 to 1.17.1 with lobby join method
- Add support for Forge client/server to lobby join method (partial)
- Probe server on start with fake user to fetch server settings improving compatibility
- Improve lobby compatibility, send probed server data to client when possible
- Skip lobby join method if server probe is not yet finished
- Generate lobby dimension configuration on the fly based on server dimensions
- Fix unsupported lobby dimension configuration values for some Minecraft versions
- Demote IP ban list reload message from info to debug
- Update dependencies
## 0.2.4 (2021-11-24)
- Fix status response issues with missing server icon, fall back to default icon
- Fix incorrect UUID for players in lobby logic
- Make server directory relative to configuration file path
- Assume SIGTERM exit code for server process to be successful on Unix
- Update features in README
- Update dependencies
## 0.2.3 (2021-11-22)
- Add support for `PROXY` header to notify Minecraft server of real client IP
- Only enable RCON by default on Windows
- Update dependencies
## 0.2.2 (2021-11-18)
- Add server favicon to status response

154
Cargo.lock generated
View File

@@ -19,9 +19,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.45"
version = "1.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee10e43ae4a853c0a3591d4e2ada1719e553be18199d9da9d4a83f5927c2f5c7"
checksum = "62e1f47f7dc0422027a4e370dd4548d4d66b26782e513e98dca1e689e058a80e"
[[package]]
name = "async-channel"
@@ -175,6 +175,15 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.1.0"
@@ -298,9 +307,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.2.1"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a"
checksum = "3825b1e8580894917dc4468cb634a1b4e9745fddc854edad72d9c04644c0319f"
dependencies = [
"cfg-if 1.0.0",
]
@@ -391,6 +400,21 @@ dependencies = [
"syn",
]
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array",
]
[[package]]
name = "doc-comment"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "dotenv"
version = "0.15.0"
@@ -512,9 +536,9 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
[[package]]
name = "futures"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12aa0eb539080d55c3f2d45a67c3b58b6b0773c1a3ca2dfec66d58c97fd66ca"
checksum = "8cd0210d8c325c245ff06fd95a3b13689a1a276ac8cfa8e8720cb840bfb84b9e"
dependencies = [
"futures-channel",
"futures-core",
@@ -527,9 +551,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da6ba8c3bb3c165d3c7319fc1cc8304facf1fb8db99c5de877183c08a273888"
checksum = "7fc8cd39e3dbf865f7340dce6a2d401d24fd37c6fe6c4f0ee0de8bfca2252d27"
dependencies = [
"futures-core",
"futures-sink",
@@ -537,15 +561,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d"
checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445"
[[package]]
name = "futures-executor"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45025be030969d763025784f7f355043dc6bc74093e4ecc5000ca4dc50d8745c"
checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97"
dependencies = [
"futures-core",
"futures-task",
@@ -554,9 +578,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "522de2a0fe3e380f1bc577ba0474108faf3f6b18321dbf60b3b9c39a75073377"
checksum = "e481354db6b5c353246ccf6a728b0c5511d752c08da7260546fc0933869daa11"
[[package]]
name = "futures-lite"
@@ -575,23 +599,22 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36ea153c13024fe480590b3e3d4cad89a0cfacecc24577b68f86c6ced9c2bc11"
checksum = "996c6442437b62d21a32cd9906f9c41e7dc1e19a9579843fad948696769305af"
[[package]]
name = "futures-task"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99"
checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12"
[[package]]
name = "futures-util"
version = "0.3.17"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481"
checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e"
dependencies = [
"autocfg 1.0.1",
"futures-channel",
"futures-core",
"futures-io",
@@ -603,6 +626,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.3"
@@ -753,9 +786,10 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "lazymc"
version = "0.2.2"
version = "0.2.5"
dependencies = [
"anyhow",
"async-std",
"base64",
"bytes",
"chrono",
@@ -767,10 +801,12 @@ dependencies = [
"futures",
"libc",
"log",
"md-5",
"minecraft-protocol",
"named-binary-tag",
"notify",
"pretty_env_logger",
"proxy-protocol",
"quartz_nbt",
"rand 0.8.4",
"rcon",
@@ -787,9 +823,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.107"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbe5e23404da5b4f555ef85ebed98fb4083e55a00c317800bc2a50ede9f3d219"
checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119"
[[package]]
name = "linked-hash-map"
@@ -807,6 +843,17 @@ dependencies = [
"value-bag",
]
[[package]]
name = "md-5"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15"
dependencies = [
"block-buffer",
"digest",
"opaque-debug",
]
[[package]]
name = "md5"
version = "0.6.1"
@@ -822,7 +869,7 @@ checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
[[package]]
name = "minecraft-protocol"
version = "0.1.0"
source = "git+https://github.com/timvisee/rust-minecraft-protocol?rev=356ea54#356ea5424374c5a7249be2f0f13fd3e0e2db5b58"
source = "git+https://github.com/timvisee/rust-minecraft-protocol?rev=edfdf87#edfdf876c0c21be02afdd885e3400983f3137ec9"
dependencies = [
"byteorder",
"minecraft-protocol-derive",
@@ -835,7 +882,7 @@ dependencies = [
[[package]]
name = "minecraft-protocol-derive"
version = "0.0.0"
source = "git+https://github.com/timvisee/rust-minecraft-protocol?rev=356ea54#356ea5424374c5a7249be2f0f13fd3e0e2db5b58"
source = "git+https://github.com/timvisee/rust-minecraft-protocol?rev=edfdf87#edfdf876c0c21be02afdd885e3400983f3137ec9"
dependencies = [
"proc-macro2",
"quote",
@@ -1001,6 +1048,12 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
[[package]]
name = "opaque-debug"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "os_str_bytes"
version = "4.2.0"
@@ -1090,6 +1143,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "proxy-protocol"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e50c72c21c738f5c5f350cc33640aee30bf7cd20f9d9da20ed41bce2671d532"
dependencies = [
"bytes",
"snafu",
]
[[package]]
name = "quartz_nbt"
version = "0.2.4"
@@ -1277,9 +1340,9 @@ dependencies = [
[[package]]
name = "rcon"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465a6f903164a399084787547a026b83e7937bc576d8acdbd9e41ebf5de90a85"
checksum = "6b7fdd146f86bd90fa2d4cf83a28b45f058e90bcf11ed0cce134e757928771e6"
dependencies = [
"async-std",
"bytes",
@@ -1364,9 +1427,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.70"
version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e277c495ac6cd1a01a58d0a0c574568b4d1ddf14f59965c6a58b8d96400b54f3"
checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527"
dependencies = [
"itoa",
"ryu",
@@ -1394,6 +1457,27 @@ version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
[[package]]
name = "snafu"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7"
dependencies = [
"doc-comment",
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "socket2"
version = "0.4.2"
@@ -1412,9 +1496,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.81"
version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966"
checksum = "8daf5dd0bb60cbd4137b1b587d2fc0ae729bc07cf01cd70b36a1ed5ade3b9d59"
dependencies = [
"proc-macro2",
"quote",
@@ -1520,6 +1604,12 @@ dependencies = [
"serde",
]
[[package]]
name = "typenum"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec"
[[package]]
name = "unicase"
version = "2.6.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "lazymc"
version = "0.2.2"
version = "0.2.5"
authors = ["Tim Visee <3a4fb3964f@sinenomine.email>"]
license = "GPL-3.0"
readme = "README.md"
@@ -24,11 +24,11 @@ default = ["rcon", "lobby"]
# RCON support
# Allow use of RCON to manage (stop) server.
# Required on Windows.
rcon = ["rust_rcon"]
rcon = ["rust_rcon", "async-std"]
# Lobby support
# Add lobby join method, keeps client in fake lobby world until server is ready.
lobby = ["named-binary-tag", "quartz_nbt", "uuid"]
lobby = ["md-5", "uuid"]
[dependencies]
anyhow = "1.0"
@@ -42,9 +42,12 @@ dotenv = "0.15"
flate2 = { version = "1.0", default-features = false, features = ["default"] }
futures = { version = "0.3", default-features = false, features = ["executor"] }
log = "0.4"
minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "356ea54" }
minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "edfdf87" }
named-binary-tag = "0.6"
notify = "4.0"
pretty_env_logger = "0.4"
proxy-protocol = "0.5"
quartz_nbt = "0.2"
rand = "0.8"
serde = "1.0"
serde_json = "1.0"
@@ -55,11 +58,11 @@ toml = "0.5"
version-compare = "0.1"
# Feature: rcon
rust_rcon = { package = "rcon", version = "0.5", optional = true }
rust_rcon = { package = "rcon", version = "0.5.2", optional = true }
async-std = { version = "1.9.0", default-features = false, optional = true }
# Feature: lobby
named-binary-tag = { version = "0.6", optional = true }
quartz_nbt = { version = "0.2", optional = true }
md-5 = { version = "0.9", optional = true }
uuid = { version = "0.7", optional = true, features = ["v3"] }
[target.'cfg(unix)'.dependencies]

View File

@@ -35,7 +35,7 @@ https://user-images.githubusercontent.com/856222/141378688-882082be-9efa-4cfe-81
## Features
- Very efficient, lightweight & low-profile (~3KB RAM)
- Supports Minecraft Java Edition 1.6+, supports modded (e.g. Forge, FTB)
- Supports Minecraft Java Edition 1.7.2+, supports modded (e.g. Forge, FTB)
- Configure joining client occupation methods:
- Hold: hold clients when server starts, relay when ready, without them noticing
- Kick: kick clients when server starts, with a starting message
@@ -43,8 +43,9 @@ https://user-images.githubusercontent.com/856222/141378688-882082be-9efa-4cfe-81
- _Lobby: keep client in emulated server with lobby world, teleport to real server when ready ([experimental*](./docs/join-method-lobby.md))_
- Customizable MOTD and login messages
- Automatically manages `server.properties` (host, port and RCON settings)
- Automatically block banned IPs from server within `lazymc`
- Graceful server sleep/shutdown through RCON (with `SIGTERM` fallback on Linux/Unix)
- Automatically block banned IPs from server within lazymc
- Graceful server sleep/shutdown through RCON or `SIGTERM`
- Real client IP on Minecraft server with `PROXY` header ([usage](./docs/proxy-ip.md))
- Restart server on crash
- Lockout mode
@@ -76,8 +77,8 @@ chmod a+x ./lazymc
./lazymc --help
```
When `lazymc` is set-up, change into your server directory if you haven't
already. Then set up the [configuration](./res/lazymc.toml) and start it up:
When lazymc is set-up, change into your server directory if you haven't already.
Then set up the [configuration](./res/lazymc.toml) and start it up:
```bash
# Change into your server directory (if you haven't already)
@@ -94,20 +95,14 @@ nano lazymc.toml
lazymc start
```
Before you use this in production, please ensure starting and stopping the
server works as expected by connecting to it once. Watch `lazymc`s output while
it starts and stops. If stopping results in errors, fix this first to prevent
corrupting world/user data.
Please see [extras](./docs/extras.md) for recommendations and additional things
to set up (e.g. how to fix incorrect client IPs and IP banning on your server).
Follow this repository with the _Watch_ button on the top right to be notified of new releases.
Everything should now be ready to go! Connect with your Minecraft client to wake
your server up!
After you've read through the [extras](./docs/extras.md), everything should now
be ready to go! Connect with your Minecraft client to wake your server up!
_Note: If a binary for your system isn't provided, please [compile from
source](#compile-from-source)._
_Note: Installation options are limited at this moment. More will be added
source](#compile-from-source). Installation options are limited at this moment. More will be added
later._
[latest-release]: https://github.com/timvisee/lazymc/releases/latest
@@ -145,7 +140,7 @@ cargo build --release
## Third-party usage & implementations
A list of third-party implementations, projects using `lazymc`, that you might
A list of third-party implementations, projects using lazymc, that you might
find useful:
- Docker: [crbanman/papermc-lazymc](https://hub.docker.com/r/crbanman/papermc-lazymc) _(PaperMC with lazymc in Docker)_

View File

@@ -4,15 +4,13 @@
- Resolve TODOs in code
- Don't drop errors, handle everywhere where needed (some were dropped while
prototyping to speed up development)
- Spigot/paper plugin to get real player IP from lazymc, reimplement ban-ip
## Nice to have
- Use server whitelist/blacklist
- Console error if server already started on port, not through `lazymc`
- Kick with message if proxy-to-server connection fails for new client.
- Kick with message if proxy-to-server connection fails for new client.
- Test configuration on start (server dir exists, command not empty)
- Also quit `lazymc` after CTRL+C signal, after server has stopped
- Dynamically increase/decrease server polling interval based on server state
- Server polling through query (`enable-query` in `server.properties`, uses GameSpy4 protocol)

View File

@@ -1,7 +0,0 @@
fn main() {
// Must enable rcon on Windows
#[cfg(all(windows, not(feature = "rcon")))]
{
println!("cargo:warning=lazymc: you must enable rcon feature on Windows");
}
}

41
docs/command-bash.md Normal file
View File

@@ -0,0 +1,41 @@
# Use bash script to start server
You may use a `bash` script to start your server rather than invoking `java`
directly. This requires some changes though to ensure your server properly shuts
down.
When lazymc stops your server it sends a [`SIGTERM`][sigterm] signal to the
invoked server process to gracefully shut it down. `bash` ignores this signal by
default and keeps the Minecraft server running.
You must configure `bash` to [forward][forward-signal] the signal to properly
shutdown the Minecraft server as well.
[sigterm]: https://en.wikipedia.org/wiki/Signal_(IPC)#SIGTERM
[forward-signal]: https://unix.stackexchange.com/a/434269/61092
## Example
Here's a minimal example, trapping the signal and forwarding it to the server.
Be sure to set the correct server JAR file and appropriate memory limits.
[`start-server`](../res/start-server):
```bash
#!/bin/bash
# Server JAR file, set this to your own
FILE=server.jar
# Trap SIGTERM, forward it to server process ID
trap 'kill -TERM $PID' TERM INT
# Start server
java -Xms1G -Xmx1G -jar $FILE --nogui &
# Remember server process ID, wait for it to quit, then reset the trap
PID=$!
wait $PID
trap - TERM INT
wait $PID
```

View File

@@ -1,41 +0,0 @@
# Use bash script to start server
You may use a `bash` script to start your server rather than invoking `java`
directly. This requires some changes though to ensure your server properly shuts
down.
When `lazymc` stops your server it sends a [`SIGTERM`][sigterm] signal to the
invoked server process to gracefully shut it down. `bash` ignores this signal by
default and keeps the Minecraft server running.
You must configure `bash` to [forward][forward-signal] the signal to properly
shutdown the Minecraft server as well.
[sigterm]: https://en.wikipedia.org/wiki/Signal_(IPC)#SIGTERM
[forward-signal]: https://unix.stackexchange.com/a/434269/61092
## Example
Here's a minimal example, trapping the signal and forwarding it to the server.
Be sure to set the correct server JAR file and appropriate memory limits.
[`start-server`](../res/start-server):
```bash
#!/bin/bash
# Server JAR file, set this to your own
FILE=server.jar
# Trap SIGTERM, forward it to server process ID
trap 'kill -TERM $PID' TERM INT
# Start server
java -Xms1G -Xmx1G -jar $FILE --nogui &
# Remember server process ID, wait for it to quit, then reset the trap
PID=$!
wait $PID
trap - TERM INT
wait $PID
```

1
docs/command_bash.md Symbolic link
View File

@@ -0,0 +1 @@
command-bash.md

28
docs/extras.md Normal file
View File

@@ -0,0 +1,28 @@
# Extras
Some extra steps and recommendations when using lazymc:
Before you use this in production, always ensure starting and stopping the
server works as expected by connecting to it once. Watch lazymc's output while
it starts and stops. If stopping results in errors, fix this first to prevent
corrupting world/user data.
Follow this repository with the _Watch_ button on the top right to be notified
of new releases.
## Recommended
- [Protocol version](./protocol-version.md):
_set correct Minecraft protocol version for the best client compatability_
- [Proxy IP](./proxy-ip.md):
_fix incorrect client IPs on Minecraft server, notify server of correct IP with `PROXY` header_
## Tips
- [bash with start command](./command_bash.md):
_how to properly use a bash script as server start command_
## Experimental features
- [Join method: lobby](./join-method-lobby.md):
_keep clients in fake lobby world while server starts, teleport to real server when ready_

View File

@@ -7,7 +7,7 @@ The lobby join method allows you to keep clients in a lobby world while the
server is starting. When the server is ready, the player is _teleported_ to the
real server.
`lazymc` emulates a fake server with an empty lobby world. The player is put in
lazymc emulates a fake server with an empty lobby world. The player is put in
this world, floating in space. A custom message is shown on the client to notify
we're waiting on the server to start.
@@ -21,9 +21,11 @@ enable this in a production environment.
Current limitations:
- Only works with offline mode
- Only works with vanilla Minecraft clients, does not work with modded (e.g. Forge, FTB)
- Probably only works with Minecraft 1.16-1.17.1 (tested with 1.17.1)
- Server must be in offline mode (`online-mode=false`)
- Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1)
- Server must use vanilla Minecraft
- May work with Forge (set `server.forge = true`), depends on used mods, test before use
- Does not work with other mods, such as FTB
- This method will consume the client, following configured join methods won't be used.
At this time it is unknown if some of the above limitations will ever be lifted,
@@ -85,3 +87,25 @@ ready_sound = "block.note_block.chime"
_Note: this might have changed, see the latest configuration
[here](../res/lazymc.toml)._
## Probe issue with whitelist
lazymc may report a _probe_ error on first start when a whitelist is enabled
on your server.
lazymc uses a probe to fetch some required details from your Minecraft
server, such as a mod list. When probing, the server is started once when lazymc
starts. It then connects to the Minecraft server with the _probe_ user
(username: `_lazymc_probe`) and disconnects when everything needed is fetched.
If you use a whitelist on your server it will cause issues if the probe user
isn't whitelisted. Simply whitelist the probe user with the following command
and restart lazymc to fix the issue:
```
/whitelist add _lazymc_probe
```
Probing isn't enabled by default. You may enable this by setting
`server.probe_on_start = true`. Other configuration settings might
automatically enable proving if required for your setup.

39
docs/protocol-version.md Normal file
View File

@@ -0,0 +1,39 @@
# Protocol version
The Minecraft protocol uses a version number to distinguish between different
protocol versions. Each new Minecraft version having a change in its protocol
gets a new protocol version.
## List of versions
- https://wiki.vg/Protocol_version_numbers#Versions_after_the_Netty_rewrite
## Configuration
In lazymc you may configure what protocol version to use:
[`lazymc.toml`](../res/lazymc.toml):
```bash
# -- snip --
[public]
# Server version & protocol hint.
# Sent to clients until actual server version is known.
# See: https://git.io/J1Fvx
version = "1.17.1"
protocol = 756
# -- snip --
```
It is highly recommended to set these to match that of your server version to
allow the best compatibility with clients.
- Set `public.protocol` to the number matching that of your server version
(see [this](#list-of-versions) list)
- Set `public.version` to any string you like. Shows up in read in clients that
have an incompatibel protocol version number
These are used as hint. lazymc will automatically use the protocol version of
your Minecraft server once it has started at least once.

79
docs/proxy-ip.md Normal file
View File

@@ -0,0 +1,79 @@
# Proxy IP
lazymc acts as a proxy most of the time. Because of this the Minecraft server
will think all clients connect from the same IP, being the IP lazymc proxies
from.
This breaks IP banning (`/ban-ip`, amongst other IP related things). This may be
a problematic issue for your server.
Luckily, this can be fixed with the [proxy header](#proxy-header). lazymc has
support for this, and can be used with a companion plugin on your server.
## Proxy header
The `PROXY` header may be used to notify the Minecraft server of the real client
IP.
When a new connection is opened to the Minecraft server, the Minecraft server
will read the `PROXY` header with client-IP information. Once read, it will set
the correct client IP internally and will resume communicating with the client
normally.
To enable this with lazymc you must do two things:
- [Modify the lazymc configuration](#configuration)
- [Install a companion plugin](#server-plugin)
## Configuration
To use the `PROXY` header with your Minecraft server, set `server.send_proxy_v2`
to `true`.
[`lazymc.toml`](../res/lazymc.toml):
```toml
# -- snip --
[server]
send_proxy_v2 = true
# -- snip --
```
Other related properties, you probably won't need to touch, include:
- `server.send_proxy_v2`: set to `true` to enable `PROXY` header for Minecraft server
- `join.forward.send_proxy_v2`: set to `true` to enable `PROXY` header forwarded server, if `forward` join method is used
- `rcon.send_proxy_v2`: set to `true` to enable `PROXY` header for RCON connections for Minecraft server
## Server plugin
Install one of these plugins as companion on your server to enable support for
the `PROXY` header. This requires Minecraft server software supporting plugins,
the vanilla Minecraft server does not support this.
If lazymc connects to a Spigot compatible server, use any of:
- https://github.com/riku6460/SpigotProxy ([JAR](https://github.com/riku6460/SpigotProxy/releases/latest))
- https://github.com/timvisee/spigot-proxy
If lazymc connects to a BungeeCord server, use any of:
- https://github.com/MinelinkNetwork/BungeeProxy
## Warning: connection failures
Use of the `PROXY` header must be enabled or disabled on both lazymc and your
Minecraft server using a companion plugin.
If either of the two is missing or misconfigured, it will result in connection
failures due to a missing or unrecognized header.
## Warning: fake IP
When enabling the `PROXY` header on your Minecraft server, malicious parties may
send this header to fake their real IP.
To solve this, make sure the Minecraft server is only publicly reachable through
lazymc. This can be done by setting the Minecraft server IP to a local address
only, or by setting up firewall rules.

View File

@@ -14,8 +14,8 @@ Open a terminal, go to the server directory, and make sure you can execute it:
.\lazymc --help
```
When `lazymc` is ready, set up the [configuration](./res/lazymc.toml) and start
it up:
When lazymc is ready, set up the [configuration](./res/lazymc.toml) and start it
up:
```bash
# In your Minecraft server directory:
@@ -31,18 +31,14 @@ notepad lazymc.toml
.\lazymc start
```
Before you use this in production, please ensure starting and stopping the
server works as expected by connecting to it once. Watch `lazymc`s output while
it starts and stops. If stopping results in errors, fix this first to prevent
corrupting world/user data.
Please see [extras](./extras.md) for recommendations and additional things
to set up (e.g. how to fix incorrect client IPs and IP banning on your server).
Follow this repository with the _Watch_ button on the top right to be notified of new releases.
Everything should now be ready to go! Connect with your Minecraft client to wake
your server up!
After you've read through the [extras](./extras.md), everything should now
be ready to go! Connect with your Minecraft client to wake your server up!
_Note: if you put `lazymc` in `PATH`, or if you
[install](../README.md#compile-from-source) it through Cargo, you can invoke
`lazymc` everywhere directly without the `.\` prefix.
`lazymc` everywhere directly without the `.\` prefix._
[latest-release]: https://github.com/timvisee/lazymc/releases/latest

View File

@@ -8,7 +8,7 @@
# You can probably leave the rest as-is.
#
# You may generate a new configuration with: lazymc config generate
# Or find the latest at: https://is.gd/WWBIQu
# Or find the latest at: https://git.io/J1Fvq
[public]
# Public address. IP and port users connect to.
@@ -17,7 +17,7 @@
# Server version & protocol hint.
# Sent to clients until actual server version is known.
# See: https://is.gd/FTQKTP
# See: https://git.io/J1Fvx
#version = "1.17.1"
#protocol = 756
@@ -30,7 +30,7 @@
directory = "."
# Command to start the server.
# Warning: if using a bash script read: https://is.gd/k8SQYv
# Warning: if using a bash script read: https://git.io/JMIKH
command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# Immediately wake server when starting lazymc.
@@ -39,6 +39,13 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# Immediately wake server after crash.
#wake_on_crash = false
# Probe required server details when starting lazymc, wakes server on start.
# Improves client compatibility. Automatically enabled if required by other config properties.
#probe_on_start = false
# Set to true if this server runs Forge.
#forge = false
# Server start/stop timeout in seconds. Force kill server process if it takes too long.
#start_timeout = 300
#stop_timeout = 150
@@ -51,6 +58,10 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# On connect, clients show a 'Disconnected' message rather than the ban reason.
#drop_banned_ips = false
# Add HAProxy v2 header to proxied connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[time]
# Sleep after number of seconds.
#sleep_after = 60
@@ -105,6 +116,10 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# The target server will receive original client handshake and login request as received by lazymc.
#address = "127.0.0.1:25565"
# Add HAProxy v2 header to forwarded connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[join.lobby]
# Lobby occupation method.
# The client joins a fake lobby server with an empty world, floating in space.
@@ -112,15 +127,18 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# The client will be teleported to the real server once it is ready.
# This may keep the client occupied forever if no timeout is set.
# Consumes client, not allowing other join methods afterwards.
# See: https://git.io/JMIi4
# !!! WARNING !!!
# This is highly experimental, incomplete and unstable.
# This may break the game and crash clients.
# Don't enable this unless you know what you're doing.
#
# - Only works with offline mode
# - Only works with vanilla Minecraft clients, does not work with modded
# - Only tested with Minecraft 1.17.1
# - Server must be in offline mode
# - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1)
# - Server must use vanilla Minecraft
# - May work with Forge, enable in config, depends on used mods, test before use
# - Does not work with other mods, such as FTB
# Maximum time in seconds in the lobby while the server starts.
#timeout = 600
@@ -141,7 +159,7 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
[rcon]
# Enable sleeping server through RCON.
# Must be enabled on Windows.
#enabled = true
#enabled = false # default: false, true on Windows
# Server RCON port. Must differ from public and server port.
#port = 25575
@@ -151,6 +169,10 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
#password = ""
#randomize_password = true
# Add HAProxy v2 header to RCON connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[advanced]
# Automatically update values in Minecraft server.properties file as required.
#rewrite_server_properties = true
@@ -158,4 +180,4 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
[config]
# lazymc version this configuration is for.
# Don't change unless you know what you're doing.
version = "0.2.2"
version = "0.2.5"

View File

@@ -1,5 +1,7 @@
#!/bin/bash
# See: https://git.io/JMIKH
# Server JAR file, set this to your own
FILE=server.jar

BIN
res/unknown_server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use clap::ArgMatches;
use crate::config::{self, Config};
use crate::config::{self, Config, Server as ConfigServer};
use crate::mc::server_properties;
use crate::proto;
use crate::service;
@@ -120,7 +120,7 @@ fn rewrite_server_properties(config: &Config) {
}
// Ensure server directory is set, it must exist
let dir = match &config.server.directory {
let dir = match ConfigServer::server_directory(config) {
Some(dir) => dir,
None => {
warn!(target: "lazymc", "Not rewriting {} file, server directory not configured (server.directory)", server_properties::FILE);

View File

@@ -1,7 +1,7 @@
use std::fs;
use std::io;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use clap::ArgMatches;
use serde::Deserialize;
@@ -63,6 +63,12 @@ pub fn load(matches: &ArgMatches) -> Config {
/// Configuration.
#[derive(Debug, Deserialize)]
pub struct Config {
/// Configuration path if known.
///
/// Should be used as base directory for filesystem operations.
#[serde(skip)]
pub path: Option<PathBuf>,
/// Public configuration.
#[serde(default)]
pub public: Public,
@@ -101,9 +107,9 @@ pub struct Config {
impl Config {
/// Load configuration from file.
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
let data = fs::read(path)?;
let config: Config = toml::from_slice(&data)?;
pub fn load(path: PathBuf) -> Result<Self, io::Error> {
let data = fs::read(&path)?;
let mut config: Config = toml::from_slice(&data)?;
// Show warning if config version is problematic
match &config.config.version {
@@ -118,6 +124,7 @@ impl Config {
Ok(true) => {}
},
}
config.path.replace(path);
Ok(config)
}
@@ -152,8 +159,10 @@ impl Default for Public {
#[derive(Debug, Deserialize)]
pub struct Server {
/// Server directory.
///
/// Private because you should use `Server::server_directory()` instead.
#[serde(default = "option_pathbuf_dot")]
pub directory: Option<PathBuf>,
directory: Option<PathBuf>,
/// Start command.
pub command: String,
@@ -173,6 +182,14 @@ pub struct Server {
#[serde(default)]
pub wake_on_crash: bool,
/// Probe required server details when starting lazymc, wakes server on start.
#[serde(default)]
pub probe_on_start: bool,
/// Whether this server runs forge.
#[serde(default)]
pub forge: bool,
/// Server starting timeout. Force kill server process if it takes longer.
#[serde(default = "u32_300")]
pub start_timeout: u32,
@@ -188,6 +205,23 @@ pub struct Server {
/// Drop connections from banned IPs.
#[serde(default)]
pub drop_banned_ips: bool,
/// Add HAProxy v2 header to proxied connections.
#[serde(default)]
pub send_proxy_v2: bool,
}
impl Server {
/// Get the server directory.
///
/// This does not check whether it exists.
pub fn server_directory(config: &Config) -> Option<PathBuf> {
// Get directory, relative to config directory if known
match config.path.as_ref().and_then(|p| p.parent()) {
Some(config_dir) => Some(config_dir.join(config.server.directory.as_ref()?)),
None => config.server.directory.clone(),
}
}
}
/// Time configuration.
@@ -333,12 +367,17 @@ pub struct JoinForward {
/// IP and port to forward to.
#[serde(deserialize_with = "to_socket_addrs")]
pub address: SocketAddr,
/// Add HAProxy v2 header to proxied connections.
#[serde(default)]
pub send_proxy_v2: bool,
}
impl Default for JoinForward {
fn default() -> Self {
Self {
address: "127.0.0.1:25565".parse().unwrap(),
send_proxy_v2: false,
}
}
}
@@ -401,15 +440,19 @@ pub struct Rcon {
/// Randomize server RCON password on each start.
pub randomize_password: bool,
/// Add HAProxy v2 header to RCON connections.
pub send_proxy_v2: bool,
}
impl Default for Rcon {
fn default() -> Self {
Self {
enabled: true,
enabled: cfg!(windows),
port: 25575,
password: "".into(),
randomize_password: true,
send_proxy_v2: false,
}
}
}

255
src/forge.rs Normal file
View File

@@ -0,0 +1,255 @@
#[cfg(feature = "lobby")]
use std::sync::Arc;
#[cfg(feature = "lobby")]
use std::time::Duration;
#[cfg(feature = "lobby")]
use bytes::BytesMut;
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::encoder::Encoder;
use minecraft_protocol::version::forge_v1_13::login::{Acknowledgement, LoginWrapper, ModList};
use minecraft_protocol::version::v1_14_4::login::{LoginPluginRequest, LoginPluginResponse};
use minecraft_protocol::version::PacketId;
#[cfg(feature = "lobby")]
use tokio::io::AsyncWriteExt;
use tokio::net::tcp::WriteHalf;
#[cfg(feature = "lobby")]
use tokio::net::TcpStream;
#[cfg(feature = "lobby")]
use tokio::time;
use crate::forge;
use crate::proto::client::Client;
#[cfg(feature = "lobby")]
use crate::proto::client::ClientState;
use crate::proto::packet;
use crate::proto::packet::RawPacket;
#[cfg(feature = "lobby")]
use crate::proto::packets;
#[cfg(feature = "lobby")]
use crate::server::Server;
/// Forge status magic.
pub const STATUS_MAGIC: &str = "\0FML2\0";
/// Forge plugin wrapper login plugin request channel.
pub const CHANNEL_LOGIN_WRAPPER: &str = "fml:loginwrapper";
/// Forge handshake channel.
pub const CHANNEL_HANDSHAKE: &str = "fml:handshake";
/// Timeout for draining Forge plugin responses from client.
#[cfg(feature = "lobby")]
const CLIENT_DRAIN_FORGE_TIMEOUT: Duration = Duration::from_secs(5);
/// Respond with Forge login wrapper packet.
pub async fn respond_forge_login_packet(
client: &Client,
writer: &mut WriteHalf<'_>,
message_id: i32,
forge_channel: String,
forge_packet: impl PacketId + Encoder,
) -> Result<(), ()> {
// Encode Forge packet to data
let mut forge_data = Vec::new();
forge_packet.encode(&mut forge_data).map_err(|_| ())?;
// Encode Forge payload
let forge_payload =
RawPacket::new(forge_packet.packet_id(), forge_data).encode_without_len(client)?;
// Wrap Forge payload in login wrapper
let mut payload = Vec::new();
let packet = LoginWrapper {
channel: forge_channel,
packet: forge_payload,
};
packet.encode(&mut payload).map_err(|_| ())?;
// Write login plugin request with forge payload
packet::write_packet(
LoginPluginResponse {
message_id,
successful: true,
data: payload,
},
client,
writer,
)
.await
}
/// Respond to a Forge login plugin request.
pub async fn respond_login_plugin_request(
client: &Client,
packet: LoginPluginRequest,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
// Decode Forge login wrapper packet
let (message_id, login_wrapper, packet) =
forge::decode_forge_login_packet(client, packet).await?;
// Determine whether we received the mod list
let is_unknown_header = login_wrapper.channel != forge::CHANNEL_HANDSHAKE;
let is_mod_list = !is_unknown_header && packet.id == ModList::PACKET_ID;
// If not the mod list, just acknowledge
if !is_mod_list {
trace!(target: "lazymc::forge", "Acknowledging login plugin request");
forge::respond_forge_login_packet(
client,
writer,
message_id,
login_wrapper.channel,
Acknowledgement {},
)
.await
.map_err(|_| {
error!(target: "lazymc::forge", "Failed to send Forge login plugin request acknowledgement");
})?;
return Ok(());
}
trace!(target: "lazymc::forge", "Sending mod list reply to server with same contents");
// Parse mod list, transform into reply
let mod_list = ModList::decode(&mut packet.data.as_slice()).map_err(|err| {
error!(target: "lazymc::forge", "Failed to decode Forge mod list: {:?}", err);
})?;
let mod_list_reply = mod_list.into_reply();
// We got mod list, respond with reply
forge::respond_forge_login_packet(
client,
writer,
message_id,
login_wrapper.channel,
mod_list_reply,
)
.await
.map_err(|_| {
error!(target: "lazymc::forge", "Failed to send Forge login plugin mod list reply");
})?;
Ok(())
}
/// Decode a Forge login wrapper packet from login plugin request.
///
/// Returns (`message_id`, `login_wrapper`, `packet`).
pub async fn decode_forge_login_packet(
client: &Client,
plugin_request: LoginPluginRequest,
) -> Result<(i32, LoginWrapper, RawPacket), ()> {
// Validate channel
assert_eq!(plugin_request.channel, CHANNEL_LOGIN_WRAPPER);
// Decode login wrapped packet
let login_wrapper =
LoginWrapper::decode(&mut plugin_request.data.as_slice()).map_err(|err| {
error!(target: "lazymc::forge", "Failed to decode Forge LoginWrapper packet: {:?}", err);
})?;
// Parse packet
let packet = RawPacket::decode_without_len(client, &login_wrapper.packet).map_err(|err| {
error!(target: "lazymc::forge", "Failed to decode Forge LoginWrapper packet contents: {:?}", err);
})?;
Ok((plugin_request.message_id, login_wrapper, packet))
}
/// Replay the Forge login payload for a client.
#[cfg(feature = "lobby")]
pub async fn replay_login_payload(
client: &Client,
inbound: &mut TcpStream,
server: Arc<Server>,
inbound_buf: &mut BytesMut,
) -> Result<(), ()> {
debug!(target: "lazymc::lobby", "Replaying Forge login procedure for lobby client...");
// Replay each Forge packet
for packet in server.forge_payload.read().await.as_slice() {
inbound.write_all(packet).await.map_err(|err| {
error!(target: "lazymc::lobby", "Failed to send Forge join payload to lobby client, will likely cause issues: {}", err);
})?;
}
// Drain all responses
let count = server.forge_payload.read().await.len();
drain_forge_responses(client, inbound, inbound_buf, count).await?;
trace!(target: "lazymc::lobby", "Forge join payload replayed");
Ok(())
}
/// Drain Forge login plugin response packets from stream.
#[cfg(feature = "lobby")]
async fn drain_forge_responses(
client: &Client,
inbound: &mut TcpStream,
buf: &mut BytesMut,
mut count: usize,
) -> Result<(), ()> {
let (mut reader, mut _writer) = inbound.split();
loop {
// We're done if count is zero
if count == 0 {
trace!(target: "lazymc::forge", "Drained all plugin responses from client");
return Ok(());
}
// Read packet from stream with timeout
let read_packet_task = packet::read_packet(client, buf, &mut reader);
let timeout = time::timeout(CLIENT_DRAIN_FORGE_TIMEOUT, read_packet_task).await;
let read_packet_task = match timeout {
Ok(result) => result,
Err(_) => {
error!(target: "lazymc::forge", "Expected more plugin responses from client, but didn't receive anything in a while, may be problematic");
return Ok(());
}
};
// Read packet from stream
let (packet, _raw) = match read_packet_task {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc::forge", "Closing connection, error occurred");
break;
}
};
// Grab client state
let client_state = client.state();
// Catch login plugin resposne
if client_state == ClientState::Login
&& packet.id == packets::login::SERVER_LOGIN_PLUGIN_RESPONSE
{
trace!(target: "lazymc::forge", "Voiding plugin response from client");
count -= 1;
continue;
}
// TODO: instantly return on this packet?
// // Hijack login success
// if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS {
// trace!(target: "lazymc::forge", "Got login success from server connection, change to play mode");
// // Switch to play state
// tmp_client.set_state(ClientState::Play);
// return Ok(forge_payload);
// }
// Show unhandled packet warning
debug!(target: "lazymc::forge", "Got unhandled packet from server in record_forge_response:");
debug!(target: "lazymc::forge", "- State: {:?}", client_state);
debug!(target: "lazymc::forge", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
Err(())
}

View File

@@ -4,6 +4,7 @@ use bytes::BytesMut;
use tokio::net::TcpStream;
use crate::config::*;
use crate::proxy::ProxyHeader;
use crate::service;
use super::MethodResult;
@@ -20,6 +21,7 @@ pub async fn occupy(
service::server::route_proxy_address_queue(
inbound,
ProxyHeader::Proxy.not_none(config.join.forward.send_proxy_v2),
config.join.forward.address,
inbound_history.clone(),
);

View File

@@ -21,6 +21,12 @@ pub async fn occupy(
) -> Result<MethodResult, ()> {
trace!(target: "lazymc", "Using lobby method to occupy joining client");
// Must be ready to lobby
if must_still_probe(&config, &server).await {
warn!(target: "lazymc", "Client connected but lobby is not ready, using next join method, probing not completed");
return Ok(MethodResult::Continue(inbound));
}
// Start lobby
lobby::serve(client, client_info, inbound, config, server, inbound_queue).await?;
@@ -28,3 +34,13 @@ pub async fn occupy(
Ok(MethodResult::Consumed)
}
/// Check whether we still have to probe before we can use the lobby.
async fn must_still_probe(config: &Config, server: &Server) -> bool {
must_probe(config) && server.probed_join_game.read().await.is_none()
}
/// Check whether we must have probed data.
fn must_probe(config: &Config) -> bool {
config.server.forge
}

View File

@@ -1,40 +1,33 @@
use std::io::ErrorKind;
use std::ops::Deref;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use bytes::BytesMut;
use futures::FutureExt;
use minecraft_protocol::data::chat::{Message, Payload};
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
use minecraft_protocol::version::v1_14_4::login::{LoginStart, LoginSuccess, SetCompression};
use minecraft_protocol::version::v1_17_1::game::{
ClientBoundKeepAlive, ClientBoundPluginMessage, JoinGame, NamedSoundEffect,
PlayerPositionAndLook, Respawn, SetTitleSubtitle, SetTitleText, SetTitleTimes, TimeUpdate,
use minecraft_protocol::version::v1_14_4::login::{
LoginPluginRequest, LoginPluginResponse, LoginStart, LoginSuccess, SetCompression,
};
use nbt::CompoundTag;
use tokio::io::AsyncWriteExt;
use tokio::net::tcp::{ReadHalf, WriteHalf};
use tokio::net::TcpStream;
use tokio::select;
use tokio::time;
use uuid::Uuid;
use crate::config::*;
use crate::mc;
use crate::forge;
use crate::mc::uuid;
use crate::net;
use crate::proto;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::proto::packets::play::join_game::JoinGameData;
use crate::proto::{packet, packets};
use crate::proxy;
use crate::server::{Server, State};
/// Interval to send keep-alive packets at.
const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
/// Auto incrementing ID source for keep alive packets.
static KEEP_ALIVE_ID: AtomicU64 = AtomicU64::new(0);
pub const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
/// Timeout for creating new server connection for lobby client.
const SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2 * 60);
@@ -52,11 +45,6 @@ const SERVER_JOIN_GAME_TIMEOUT: Duration = Duration::from_secs(20);
/// See warning at: https://wiki.vg/Protocol#Login_Success
const SERVER_WARMUP: Duration = Duration::from_secs(1);
/// Server brand to send to client in lobby world.
///
/// Shown in F3 menu. Updated once client is relayed to real server.
const SERVER_BRAND: &[u8] = b"lazymc";
/// Serve lobby service for given client connection.
///
/// The client must be in the login state, or this will error.
@@ -109,6 +97,14 @@ pub async fn serve(
debug!(target: "lazymc::lobby", "Login on lobby server (user: {})", login_start.name);
// Replay Forge payload
if config.server.forge {
forge::replay_login_payload(client, &mut inbound, server.clone(), &mut inbound_buf)
.await?;
let (_returned_reader, returned_writer) = inbound.split();
writer = returned_writer;
}
// Respond with set compression if compression is enabled based on threshold
if proto::COMPRESSION_THRESHOLD >= 0 {
trace!(target: "lazymc::lobby", "Enabling compression for lobby client because server has it enabled (threshold: {})", proto::COMPRESSION_THRESHOLD);
@@ -123,22 +119,33 @@ pub async fn serve(
trace!(target: "lazymc::lobby", "Client login success, sending required play packets for lobby world");
// Send packets to client required to get into workable play state for lobby world
send_lobby_play_packets(client, &mut writer, &server).await?;
send_lobby_play_packets(client, &client_info, &mut writer, &server).await?;
// Wait for server to come online, then set up new connection to it
stage_wait(client, &server, &config, &mut writer).await?;
// Wait for server to come online
stage_wait(client, &client_info, &server, &config, &mut writer).await?;
// Start new connection to server
let server_client_info = client_info.clone();
let (server_client, mut outbound, mut server_buf) =
connect_to_server(client_info, &config).await?;
connect_to_server(&server_client_info, &inbound, &config).await?;
let (returned_reader, returned_writer) = inbound.split();
reader = returned_reader;
writer = returned_writer;
// Grab join game packet from server
let join_game =
wait_for_server_join_game(&server_client, &mut outbound, &mut server_buf).await?;
let join_game_data = wait_for_server_join_game(
&server_client,
&server_client_info,
&mut outbound,
&mut server_buf,
)
.await?;
// Reset lobby title
send_lobby_title(client, &mut writer, "").await?;
packets::play::title::send(client, &client_info, &mut writer, "").await?;
// Play ready sound if configured
play_lobby_ready_sound(client, &mut writer, &config).await?;
play_lobby_ready_sound(client, &client_info, &mut writer, &config).await?;
// Wait a second because Notchian servers are slow
// See: https://wiki.vg/Protocol#Login_Success
@@ -146,7 +153,8 @@ pub async fn serve(
time::sleep(SERVER_WARMUP).await;
// Send respawn packet, initiates teleport to real server world
send_respawn_from_join(client, &mut writer, join_game).await?;
packets::play::respawn::lobby_send(client, &client_info, &mut writer, join_game_data)
.await?;
// Drain inbound connection so we don't confuse the server
// TODO: can we void everything? we might need to forward everything to server except
@@ -161,11 +169,8 @@ pub async fn serve(
return Ok(());
}
// TODO: when receiving Login Plugin Request, respond with empty payload
// See: https://wiki.vg/Protocol#Login_Plugin_Request
// Show unhandled packet warning
debug!(target: "lazymc", "Received unhandled packet:");
debug!(target: "lazymc", "Got unhandled packet:");
debug!(target: "lazymc", "- State: {:?}", client_state);
debug!(target: "lazymc", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
@@ -194,10 +199,7 @@ async fn respond_login_success(
) -> Result<(), ()> {
packet::write_packet(
LoginSuccess {
uuid: Uuid::new_v3(
&Uuid::new_v3(&Uuid::nil(), b"OfflinePlayer"),
login_start.name.as_bytes(),
),
uuid: uuid::offline_player_uuid(&login_start.name),
username: login_start.name.clone(),
},
client,
@@ -209,6 +211,7 @@ async fn respond_login_success(
/// Play lobby ready sound effect if configured.
async fn play_lobby_ready_sound(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
config: &Config,
) -> Result<(), ()> {
@@ -220,8 +223,8 @@ async fn play_lobby_ready_sound(
}
// Play sound effect
send_lobby_player_pos(client, writer).await?;
send_lobby_sound_effect(client, writer, sound_name).await?;
packets::play::player_pos::send(client, client_info, writer).await?;
packets::play::sound::send(client, client_info, writer, sound_name).await?;
}
Ok(())
@@ -230,245 +233,33 @@ async fn play_lobby_ready_sound(
/// Send packets to client to get workable play state for lobby world.
async fn send_lobby_play_packets(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
server: &Server,
) -> Result<(), ()> {
// See: https://wiki.vg/Protocol_FAQ#What.27s_the_normal_login_sequence_for_a_client.3F
// Send initial game join
send_lobby_join_game(client, writer, server).await?;
packets::play::join_game::lobby_send(client, client_info, writer, server).await?;
// Send server brand
send_lobby_brand(client, writer).await?;
packets::play::server_brand::send(client, client_info, writer).await?;
// Send spawn and player position, disables 'download terrain' screen
send_lobby_player_pos(client, writer).await?;
packets::play::player_pos::send(client, client_info, writer).await?;
// Notify client of world time, required once before keep-alive packets
send_lobby_time_update(client, writer).await?;
packets::play::time_update::send(client, client_info, writer).await?;
Ok(())
}
/// Send initial join game packet to client for lobby.
async fn send_lobby_join_game(
client: &Client,
writer: &mut WriteHalf<'_>,
server: &Server,
) -> Result<(), ()> {
// Send Minecrafts default states, slightly customised for lobby world
packet::write_packet(
{
let status = server.status().await;
JoinGame {
// Player ID must be unique, if it collides with another server entity ID the player gets
// in a weird state and cannot move
entity_id: 0,
// TODO: use real server value
hardcore: false,
game_mode: 3,
previous_game_mode: -1i8 as u8,
world_names: vec![
"minecraft:overworld".into(),
"minecraft:the_nether".into(),
"minecraft:the_end".into(),
],
dimension_codec: snbt_to_compound_tag(include_str!("../res/dimension_codec.snbt")),
dimension: snbt_to_compound_tag(include_str!("../res/dimension.snbt")),
world_name: "lazymc:lobby".into(),
hashed_seed: 0,
max_players: status.as_ref().map(|s| s.players.max as i32).unwrap_or(20),
// TODO: use real server value
view_distance: 10,
// TODO: use real server value
reduced_debug_info: false,
// TODO: use real server value
enable_respawn_screen: true,
is_debug: true,
is_flat: false,
}
},
client,
writer,
)
.await
}
/// Send lobby brand to client.
async fn send_lobby_brand(client: &Client, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
packet::write_packet(
ClientBoundPluginMessage {
channel: "minecraft:brand".into(),
data: SERVER_BRAND.into(),
},
client,
writer,
)
.await
}
/// Send lobby player position to client.
async fn send_lobby_player_pos(client: &Client, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
// Send player location, disables download terrain screen
packet::write_packet(
PlayerPositionAndLook {
x: 0.0,
y: 0.0,
z: 0.0,
yaw: 0.0,
pitch: 90.0,
flags: 0b00000000,
teleport_id: 0,
dismount_vehicle: true,
},
client,
writer,
)
.await
}
/// Send lobby time update to client.
async fn send_lobby_time_update(client: &Client, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
const MC_TIME_NOON: i64 = 6000;
// Send time update, required once for keep-alive packets
packet::write_packet(
TimeUpdate {
world_age: MC_TIME_NOON,
time_of_day: MC_TIME_NOON,
},
client,
writer,
)
.await
}
/// Send keep alive packet to client.
///
/// Required periodically in play mode to prevent client timeout.
async fn send_keep_alive(client: &Client, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
packet::write_packet(
ClientBoundKeepAlive {
// Keep sending new IDs
id: KEEP_ALIVE_ID.fetch_add(1, Ordering::Relaxed),
},
client,
writer,
)
.await
// TODO: verify we receive keep alive response with same ID from client
}
/// Send lobby title packets to client.
///
/// This will show the given text for two keep-alive periods. Use a newline for the subtitle.
///
/// If an empty string is given, the title times will be reset to default.
async fn send_lobby_title(
client: &Client,
writer: &mut WriteHalf<'_>,
text: &str,
) -> Result<(), ()> {
// Grab title and subtitle bits
let title = text.lines().next().unwrap_or("");
let subtitle = text.lines().skip(1).collect::<Vec<_>>().join("\n");
// Set title
packet::write_packet(
SetTitleText {
text: Message::new(Payload::text(title)),
},
client,
writer,
)
.await?;
// Set subtitle
packet::write_packet(
SetTitleSubtitle {
text: Message::new(Payload::text(&subtitle)),
},
client,
writer,
)
.await?;
// Set title times
packet::write_packet(
if title.is_empty() && subtitle.is_empty() {
// Defaults: https://minecraft.fandom.com/wiki/Commands/title#Detail
SetTitleTimes {
fade_in: 10,
stay: 70,
fade_out: 20,
}
} else {
SetTitleTimes {
fade_in: 0,
stay: KEEP_ALIVE_INTERVAL.as_secs() as i32 * mc::TICKS_PER_SECOND as i32 * 2,
fade_out: 0,
}
},
client,
writer,
)
.await
}
/// Send lobby ready sound effect to client.
async fn send_lobby_sound_effect(
client: &Client,
writer: &mut WriteHalf<'_>,
sound_name: &str,
) -> Result<(), ()> {
packet::write_packet(
NamedSoundEffect {
sound_name: sound_name.into(),
sound_category: 0,
effect_pos_x: 0,
effect_pos_y: 0,
effect_pos_z: 0,
volume: 1.0,
pitch: 1.0,
},
client,
writer,
)
.await
}
/// Send respawn packet to client to jump from lobby into now loaded server.
///
/// The required details will be fetched from the `join_game` packet as provided by the server.
async fn send_respawn_from_join(
client: &Client,
writer: &mut WriteHalf<'_>,
join_game: JoinGame,
) -> Result<(), ()> {
packet::write_packet(
Respawn {
dimension: join_game.dimension,
world_name: join_game.world_name,
hashed_seed: join_game.hashed_seed,
game_mode: join_game.game_mode,
previous_game_mode: join_game.previous_game_mode,
is_debug: join_game.is_debug,
is_flat: join_game.is_flat,
copy_metadata: false,
},
client,
writer,
)
.await
}
/// An infinite keep-alive loop.
///
/// This will keep sending keep-alive and title packets to the client until it is dropped.
async fn keep_alive_loop(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
config: &Config,
) -> Result<(), ()> {
@@ -480,8 +271,10 @@ async fn keep_alive_loop(
trace!(target: "lazymc::lobby", "Sending keep-alive sequence to lobby client");
// Send keep alive and title packets
send_keep_alive(client, writer).await?;
send_lobby_title(client, writer, &config.join.lobby.message).await?;
packets::play::keep_alive::send(client, client_info, writer).await?;
packets::play::title::send(client, client_info, writer, &config.join.lobby.message).await?;
// TODO: verify we receive correct keep alive response
}
}
@@ -492,12 +285,13 @@ async fn keep_alive_loop(
/// During this stage we keep sending keep-alive and title packets to the client to keep it active.
async fn stage_wait(
client: &Client,
client_info: &ClientInfo,
server: &Server,
config: &Config,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
select! {
a = keep_alive_loop(client, writer, config) => a,
a = keep_alive_loop(client, client_info, writer, config) => a,
b = wait_for_server(server, config) => b,
}
}
@@ -561,12 +355,13 @@ async fn wait_for_server(server: &Server, config: &Config) -> Result<(), ()> {
///
/// This will initialize the connection to the play state. Client details are used.
async fn connect_to_server(
client_info: ClientInfo,
client_info: &ClientInfo,
inbound: &TcpStream,
config: &Config,
) -> Result<(Client, TcpStream, BytesMut), ()> {
time::timeout(
SERVER_CONNECT_TIMEOUT,
connect_to_server_no_timeout(client_info, config),
connect_to_server_no_timeout(client_info, inbound, config),
)
.await
.map_err(|_| {
@@ -579,7 +374,8 @@ async fn connect_to_server(
/// This will initialize the connection to the play state. Client details are used.
// TODO: clean this up
async fn connect_to_server_no_timeout(
client_info: ClientInfo,
client_info: &ClientInfo,
inbound: &TcpStream,
config: &Config,
) -> Result<(Client, TcpStream, BytesMut), ()> {
// Open connection
@@ -588,6 +384,15 @@ async fn connect_to_server_no_timeout(
.await
.map_err(|_| ())?;
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::lobby", "Sending client proxy header for server connection");
outbound
.write_all(&proxy::stream_proxy_header(inbound).map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Construct temporary server client
let tmp_client = match outbound.local_addr() {
Ok(addr) => Client::new(addr),
@@ -597,14 +402,14 @@ async fn connect_to_server_no_timeout(
let (mut reader, mut writer) = outbound.split();
// Handshake packet
// Replay client handshake packet
assert_eq!(
client_info.handshake.as_ref().unwrap().next_state,
ClientState::Login.to_id(),
"Client handshake should have login as next state"
);
packet::write_packet(
Handshake {
protocol_version: client_info.protocol_version.unwrap(),
server_addr: config.server.address.ip().to_string(),
server_port: config.server.address.port(),
next_state: ClientState::Login.to_id(),
},
client_info.handshake.clone().unwrap(),
&tmp_client,
&mut writer,
)
@@ -613,7 +418,7 @@ async fn connect_to_server_no_timeout(
// Request login start
packet::write_packet(
LoginStart {
name: client_info.username.ok_or(())?,
name: client_info.username.clone().ok_or(())?,
},
&tmp_client,
&mut writer,
@@ -642,9 +447,7 @@ async fn connect_to_server_no_timeout(
{
// Decode compression packet
let set_compression =
SetCompression::decode(&mut packet.data.as_slice()).map_err(|err| {
dbg!(err);
})?;
SetCompression::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
// Client and server compression threshold should match, show warning if not
if set_compression.threshold != proto::COMPRESSION_THRESHOLD {
@@ -661,9 +464,59 @@ async fn connect_to_server_no_timeout(
continue;
}
// Catch encryption requests
if client_state == ClientState::Login
&& packet.id == packets::login::CLIENT_ENCRYPTION_REQUEST
{
error!(
target: "lazymc::lobby",
"Got encryption request from server, this is unsupported. Server must be in offline mode to use lobby.",
);
break;
}
// Hijack login plugin request
if client_state == ClientState::Login
&& packet.id == packets::login::CLIENT_LOGIN_PLUGIN_REQUEST
{
// Decode login plugin request
let plugin_request =
LoginPluginRequest::decode(&mut packet.data.as_slice()).map_err(|err| {
dbg!(err);
})?;
// Respond with Forge messages
if config.server.forge {
trace!(target: "lazymc::lobby", "Got login plugin request from server, responding with Forge reply");
// Respond to Forge login plugin request
forge::respond_login_plugin_request(&tmp_client, plugin_request, &mut writer)
.await?;
continue;
}
warn!(target: "lazymc::lobby", "Got unexpected login plugin request from server, you may need to enable Forge support");
// Write unsuccesful login plugin response
packet::write_packet(
LoginPluginResponse {
message_id: plugin_request.message_id,
successful: false,
data: vec![],
},
&tmp_client,
&mut writer,
)
.await?;
continue;
}
// Hijack login success
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS {
trace!(target: "lazymc::lobby", "Received login success from server connection, change to play mode");
trace!(target: "lazymc::lobby", "Got login success from server connection, change to play mode");
// TODO: parse this packet to ensure it's fine
// let login_success =
@@ -683,8 +536,23 @@ async fn connect_to_server_no_timeout(
return Ok((tmp_client, outbound, buf));
}
// Hijack disconnect
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_DISCONNECT {
error!(target: "lazymc::lobby", "Got disconnect from server connection");
// // Decode disconnect packet
// let login_disconnect =
// LoginDisconnect::decode(&mut packet.data.as_slice()).map_err(|err| {
// dbg!(err);
// })?;
// TODO: report/forward error to client
break;
}
// Show unhandled packet warning
debug!(target: "lazymc::lobby", "Received unhandled packet from server in connect_to_server:");
debug!(target: "lazymc::lobby", "Got unhandled packet from server in connect_to_server:");
debug!(target: "lazymc::lobby", "- State: {:?}", client_state);
debug!(target: "lazymc::lobby", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
@@ -700,12 +568,13 @@ async fn connect_to_server_no_timeout(
/// This parses, consumes and returns the packet.
async fn wait_for_server_join_game(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGame, ()> {
) -> Result<JoinGameData, ()> {
time::timeout(
SERVER_JOIN_GAME_TIMEOUT,
wait_for_server_join_game_no_timeout(client, outbound, buf),
wait_for_server_join_game_no_timeout(client, client_info, outbound, buf),
)
.await
.map_err(|_| {
@@ -720,9 +589,10 @@ async fn wait_for_server_join_game(
// TODO: do not drop error here, return Box<dyn Error>
async fn wait_for_server_join_game_no_timeout(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGame, ()> {
) -> Result<JoinGameData, ()> {
let (mut reader, mut _writer) = outbound.split();
loop {
@@ -737,16 +607,17 @@ async fn wait_for_server_join_game_no_timeout(
};
// Catch join game
if packet.id == packets::play::CLIENT_JOIN_GAME {
let join_game = JoinGame::decode(&mut packet.data.as_slice()).map_err(|err| {
dbg!(err);
if packets::play::join_game::is_packet(client_info, packet.id) {
// Parse join game data
let join_game_data = JoinGameData::from_packet(client_info, packet).map_err(|err| {
warn!(target: "lazymc::lobby", "Failed to parse join game packet: {:?}", err);
})?;
return Ok(join_game);
return Ok(join_game_data);
}
// Show unhandled packet warning
debug!(target: "lazymc::lobby", "Received unhandled packet from server in wait_for_server_join_game:");
debug!(target: "lazymc::lobby", "Got unhandled packet from server in wait_for_server_join_game:");
debug!(target: "lazymc::lobby", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
@@ -791,21 +662,3 @@ async fn drain_stream(reader: &mut ReadHalf<'_>) -> Result<(), ()> {
}
}
}
/// Read NBT CompoundTag from SNBT.
fn snbt_to_compound_tag(data: &str) -> CompoundTag {
use nbt::decode::read_compound_tag;
use quartz_nbt::io::{write_nbt, Flavor};
use quartz_nbt::snbt;
// Parse SNBT data
let compound = snbt::parse(data).expect("failed to parse SNBT");
// Encode to binary
let mut binary = Vec::new();
write_nbt(&mut binary, None, &compound, Flavor::Uncompressed)
.expect("failed to encode NBT CompoundTag as binary");
// Parse binary with usable NBT create
read_compound_tag(&mut &*binary).unwrap()
}

View File

@@ -10,6 +10,7 @@ extern crate log;
pub(crate) mod action;
pub(crate) mod cli;
pub(crate) mod config;
pub(crate) mod forge;
pub(crate) mod join;
#[cfg(feature = "lobby")]
pub(crate) mod lobby;
@@ -17,6 +18,7 @@ pub(crate) mod mc;
pub(crate) mod monitor;
pub(crate) mod net;
pub(crate) mod os;
pub(crate) mod probe;
pub(crate) mod proto;
pub(crate) mod proxy;
pub(crate) mod server;
@@ -29,6 +31,10 @@ use std::env;
use clap::App;
// Compile time feature compatability check.
#[cfg(all(windows, not(feature = "rcon")))]
compile_error!("Must enable \"rcon\" feature on Windows.");
/// Default log level if none is set.
const LOG_DEFAULT: &str = "info";

112
src/mc/dimension.rs Normal file
View File

@@ -0,0 +1,112 @@
use nbt::CompoundTag;
/// Create lobby dimension from the given codec.
///
/// This creates a dimension suitable for the lobby that should be suitable for the current server
/// version.
pub fn lobby_dimension(codec: &CompoundTag) -> CompoundTag {
// Retrieve dimension types from codec
let dimension_types = match codec.get_compound_tag("minecraft:dimension_type") {
Ok(types) => types,
Err(_) => return lobby_default_dimension(),
};
// Get base dimension
let mut base = lobby_base_dimension(dimension_types);
// Change known properties on base to get more desirable dimension
base.insert_i8("piglin_safe", 1);
base.insert_f32("ambient_light", 0.0);
// base.insert_str("infiniburn", "minecraft:infiniburn_end");
base.insert_i8("respawn_anchor_works", 0);
base.insert_i8("has_skylight", 0);
base.insert_i8("bed_works", 0);
base.insert_str("effects", "minecraft:the_end");
base.insert_i64("fixed_time", 0);
base.insert_i8("has_raids", 0);
base.insert_i32("min_y", 0);
base.insert_i32("height", 16);
base.insert_i32("logical_height", 16);
base.insert_f64("coordinate_scale", 1.0);
base.insert_i8("ultrawarm", 0);
base.insert_i8("has_ceiling", 0);
base
}
/// Get lobby base dimension.
///
/// This retrieves the most desirable dimension to use as base for the lobby from the given list of
/// `dimension_types`.
///
/// If no dimension is found in the given tag, a default one will be returned.
fn lobby_base_dimension(dimension_types: &CompoundTag) -> CompoundTag {
// The dimension types we prefer the most, in order
let preferred = vec![
"minecraft:the_end",
"minecraft:the_nether",
"minecraft:the_overworld",
];
let dimensions = dimension_types.get_compound_tag_vec("value").unwrap();
for name in preferred {
if let Some(dimension) = dimensions
.iter()
.find(|d| d.get_str("name").map(|n| n == name).unwrap_or(false))
{
if let Ok(dimension) = dimension.get_compound_tag("element") {
return dimension.clone();
}
}
}
// Return first dimension
if let Some(dimension) = dimensions.first() {
if let Ok(dimension) = dimension.get_compound_tag("element") {
return dimension.clone();
}
}
// Fall back to default dimension
lobby_default_dimension()
}
/// Default lobby dimension codec from resource file.
///
/// This likely breaks if the Minecraft version doesn't match exactly.
/// Please use an up-to-date coded from the server instead.
pub fn default_dimension_codec() -> CompoundTag {
snbt_to_compound_tag(include_str!("../../res/dimension_codec.snbt"))
}
/// Default lobby dimension from resource file.
///
/// This likely breaks if the Minecraft version doesn't match exactly.
/// Please use `lobby_dimension` with an up-to-date coded from the server instead.
fn lobby_default_dimension() -> CompoundTag {
snbt_to_compound_tag(include_str!("../../res/dimension.snbt"))
}
/// Read NBT CompoundTag from SNBT.
fn snbt_to_compound_tag(data: &str) -> CompoundTag {
use quartz_nbt::io::{write_nbt, Flavor};
use quartz_nbt::snbt;
// Parse SNBT data
let compound = snbt::parse(data).expect("failed to parse SNBT");
// Encode to binary
let mut binary = Vec::new();
write_nbt(&mut binary, None, &compound, Flavor::Uncompressed)
.expect("failed to encode NBT CompoundTag as binary");
// Parse binary with usable NBT create
bin_to_compound_tag(&binary)
}
/// Read NBT CompoundTag from SNBT.
fn bin_to_compound_tag(data: &[u8]) -> CompoundTag {
use nbt::decode::read_compound_tag;
read_compound_tag(&mut &*data).unwrap()
}

26
src/mc/favicon.rs Normal file
View File

@@ -0,0 +1,26 @@
use crate::proto::client::ClientInfo;
/// Protocol version since when favicons are supported.
const FAVICON_PROTOCOL_VERSION: u32 = 4;
/// Get default server status favicon.
pub fn default_favicon() -> String {
encode_favicon(include_bytes!("../../res/unknown_server_optimized.png"))
}
/// Encode favicon bytes to a string Minecraft can read.
///
/// This assumes the favicon data to be a valid PNG image.
pub fn encode_favicon(data: &[u8]) -> String {
format!("{}{}", "data:image/png;base64,", base64::encode(data))
}
/// Check whether the status response favicon is supported based on the given client info.
///
/// Defaults to `true` if unsure.
pub fn supports_favicon(client_info: &ClientInfo) -> bool {
client_info
.protocol
.map(|p| p >= FAVICON_PROTOCOL_VERSION)
.unwrap_or(true)
}

View File

@@ -1,7 +1,12 @@
pub mod ban;
#[cfg(feature = "lobby")]
pub mod dimension;
pub mod favicon;
#[cfg(feature = "rcon")]
pub mod rcon;
pub mod server_properties;
#[cfg(feature = "lobby")]
pub mod uuid;
/// Minecraft ticks per second.
#[allow(unused)]

View File

@@ -1,8 +1,13 @@
use std::time::Duration;
use async_std::net::TcpStream;
use async_std::prelude::*;
use rust_rcon::{Connection, Error as RconError};
use tokio::time;
use crate::config::Config;
use crate::proxy;
/// Minecraft RCON quirk.
///
/// Wait this time between RCON operations.
@@ -17,16 +22,39 @@ pub struct Rcon {
impl Rcon {
/// Connect to a host.
pub async fn connect(addr: &str, pass: &str) -> Result<Self, Box<dyn std::error::Error>> {
pub async fn connect(
config: &Config,
addr: &str,
pass: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
// Connect to our TCP stream
let mut stream = TcpStream::connect(addr).await?;
// Add proxy header
if config.rcon.send_proxy_v2 {
trace!(target: "lazymc::rcon", "Sending local proxy header for RCON connection");
stream.write_all(&proxy::local_proxy_header()?).await?;
}
// Start connection
let con = Connection::builder()
.enable_minecraft_quirks(true)
.connect(addr, pass)
.connect_stream(stream, pass)
.await?;
Ok(Self { con })
}
/// Connect to a host from the given configuration.
pub async fn connect_config(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
// RCON address
let mut addr = config.server.address;
addr.set_port(config.rcon.port);
let addr = addr.to_string();
Self::connect(config, &addr, &config.rcon.password).await
}
/// Send command over RCON.
pub async fn cmd(&mut self, cmd: &str) -> Result<String, RconError> {
// Minecraft quirk

View File

@@ -11,39 +11,39 @@ const EOL: &str = "\r\n";
/// Try to rewrite changes in server.properties file in dir.
///
/// Prints an error and stops on failure.
pub fn rewrite_dir(dir: &Path, changes: HashMap<&str, String>) {
pub fn rewrite_dir<P: AsRef<Path>>(dir: P, changes: HashMap<&str, String>) {
if changes.is_empty() {
return;
}
// Ensure directory exists
if !dir.is_dir() {
if !dir.as_ref().is_dir() {
warn!(target: "lazymc",
"Not rewriting {} file, configured server directory doesn't exist: {}",
FILE,
dir.to_str().unwrap_or("?")
dir.as_ref().to_str().unwrap_or("?")
);
return;
}
// Rewrite file
rewrite_file(&dir.join(FILE), changes)
rewrite_file(dir.as_ref().join(FILE), changes)
}
/// Try to rewrite changes in server.properties file.
///
/// Prints an error and stops on failure.
pub fn rewrite_file(file: &Path, changes: HashMap<&str, String>) {
pub fn rewrite_file<P: AsRef<Path>>(file: P, changes: HashMap<&str, String>) {
if changes.is_empty() {
return;
}
// File must exist
if !file.is_file() {
if !file.as_ref().is_file() {
warn!(target: "lazymc",
"Not writing {} file, not found at: {}",
FILE,
file.to_str().unwrap_or("?"),
file.as_ref().to_str().unwrap_or("?"),
);
return;
}

33
src/mc/uuid.rs Normal file
View File

@@ -0,0 +1,33 @@
use md5::{Digest, Md5};
use uuid::Uuid;
/// Offline player namespace.
const OFFLINE_PLAYER_NAMESPACE: &str = "OfflinePlayer:";
/// Get UUID for given player username.
fn player_uuid(username: &str) -> Uuid {
java_name_uuid_from_bytes(username.as_bytes())
}
/// Get UUID for given offline player username.
pub fn offline_player_uuid(username: &str) -> Uuid {
player_uuid(&format!("{}{}", OFFLINE_PLAYER_NAMESPACE, username))
}
/// Java's `UUID.nameUUIDFromBytes`
///
/// Static factory to retrieve a type 3 (name based) `Uuid` based on the specified byte array.
///
/// Ported from: https://git.io/J1b6A
fn java_name_uuid_from_bytes(data: &[u8]) -> Uuid {
let mut hasher = Md5::new();
hasher.update(data);
let mut md5: [u8; 16] = hasher.finalize().into();
md5[6] &= 0x0f; // clear version
md5[6] |= 0x30; // set to version 3
md5[8] &= 0x3f; // clear variant
md5[8] |= 0x80; // set to IETF variant
Uuid::from_bytes(md5)
}

View File

@@ -1,5 +1,3 @@
// TODO: remove all unwraps/expects here!
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
@@ -12,12 +10,14 @@ use minecraft_protocol::version::v1_14_4::status::{
PingRequest, PingResponse, StatusRequest, StatusResponse,
};
use rand::Rng;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use tokio::time;
use crate::config::Config;
use crate::proto::client::{Client, ClientState};
use crate::proto::{packet, packets};
use crate::proxy;
use crate::server::{Server, State};
/// Monitor ping inverval in seconds.
@@ -27,7 +27,7 @@ const MONITOR_POLL_INTERVAL: Duration = Duration::from_secs(2);
const STATUS_TIMEOUT: u64 = 20;
/// Ping request timeout in seconds.
const PING_TIMEOUT: u64 = 20;
const PING_TIMEOUT: u64 = 10;
/// Monitor server.
pub async fn monitor_server(config: Arc<Config>, server: Arc<Server>) {
@@ -87,6 +87,7 @@ pub async fn poll_server(
// Try ping fallback if server is currently started
if server.state() == State::Started {
debug!(target: "lazymc::monitor", "Failed to get status from started server, trying ping...");
do_ping(config, addr).await?;
}
@@ -97,6 +98,15 @@ pub async fn poll_server(
async fn fetch_status(config: &Config, addr: SocketAddr) -> Result<ServerStatus, ()> {
let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?;
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::monitor", "Sending local proxy header for server connection");
stream
.write_all(&proxy::local_proxy_header().map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Dummy client
let client = Client::dummy();
@@ -109,6 +119,15 @@ async fn fetch_status(config: &Config, addr: SocketAddr) -> Result<ServerStatus,
async fn do_ping(config: &Config, addr: SocketAddr) -> Result<(), ()> {
let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?;
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::monitor", "Sending local proxy header for server connection");
stream
.write_all(&proxy::local_proxy_header().map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Dummy client
let client = Client::dummy();

366
src/probe.rs Normal file
View File

@@ -0,0 +1,366 @@
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use bytes::BytesMut;
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
use minecraft_protocol::version::v1_14_4::login::{
LoginPluginRequest, LoginPluginResponse, LoginStart, SetCompression,
};
use tokio::net::TcpStream;
use tokio::time;
use crate::config::Config;
use crate::forge;
use crate::net;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::proto::packets::play::join_game::JoinGameData;
use crate::proto::{self, packet, packets};
use crate::server::{Server, State};
/// Minecraft username to use for probing the server.
const PROBE_USER: &str = "_lazymc_probe";
/// Timeout for probe user connecting to the server.
const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Maximum time the probe may wait for the server to come online.
const PROBE_ONLINE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// Timeout for receiving join game packet.
///
/// When the play state is reached, the server should immeditely respond with a join game packet.
/// This defines the maximum timeout for waiting on it.
const PROBE_JOIN_GAME_TIMEOUT: Duration = Duration::from_secs(20);
/// Connect to the Minecraft server and probe useful details from it.
pub async fn probe(config: Arc<Config>, server: Arc<Server>) -> Result<(), ()> {
debug!(target: "lazymc::probe", "Starting server probe...");
// Start server if not starting already
if Server::start(config.clone(), server.clone(), None).await {
info!(target: "lazymc::probe", "Starting server to probe...");
}
// Wait for server to come online
if !wait_until_online(&server).await? {
warn!(target: "lazymc::probe", "Couldn't probe server, failed to wait for server to come online");
return Err(());
}
debug!(target: "lazymc::probe", "Connecting to server to probe details...");
// Connect to server, record Forge payload
let forge_payload = connect_to_server(&config, &server).await?;
*server.forge_payload.write().await = forge_payload;
Ok(())
}
/// Wait for the server to come online.
///
/// Returns `true` when it is online.
async fn wait_until_online<'a>(server: &Server) -> Result<bool, ()> {
trace!(target: "lazymc::probe", "Waiting for server to come online...");
// A task to wait for suitable server state
// Waits for started state, errors if stopping/stopped state is reached
let task_wait = async {
let mut state = server.state_receiver();
loop {
// Wait for state change
state.changed().await.unwrap();
match state.borrow().deref() {
// Still waiting on server start
State::Starting => {
continue;
}
// Server started, start relaying and proxy
State::Started => {
break true;
}
// Server stopping, this shouldn't happen, skip
State::Stopping => {
warn!(target: "lazymc::probe", "Server stopping while trying to probe, skipping");
break false;
}
// Server stopped, this shouldn't happen, skip
State::Stopped => {
error!(target: "lazymc::probe", "Server stopped while trying to probe, skipping");
break false;
}
}
}
};
// Wait for server state with timeout
match time::timeout(PROBE_ONLINE_TIMEOUT, task_wait).await {
Ok(online) => Ok(online),
// Timeout reached, kick with starting message
Err(_) => {
warn!(target: "lazymc::probe", "Probe waited for server to come online but timed out after {}s", PROBE_ONLINE_TIMEOUT.as_secs());
Ok(false)
}
}
}
/// Create connection to the server, with timeout.
///
/// This will initialize the connection to the play state. Client details are used.
///
/// Returns recorded Forge login payload if any.
async fn connect_to_server(config: &Config, server: &Server) -> Result<Vec<Vec<u8>>, ()> {
time::timeout(
PROBE_CONNECT_TIMEOUT,
connect_to_server_no_timeout(config, server),
)
.await
.map_err(|_| {
error!(target: "lazymc::probe", "Probe tried to connect to server but timed out after {}s", PROBE_CONNECT_TIMEOUT.as_secs());
})?
}
/// Create connection to the server, with no timeout.
///
/// This will initialize the connection to the play state. Client details are used.
///
/// Returns recorded Forge login payload if any.
// TODO: clean this up
async fn connect_to_server_no_timeout(
config: &Config,
server: &Server,
) -> Result<Vec<Vec<u8>>, ()> {
// Open connection
// TODO: on connect fail, ping server and redirect to serve_status if offline
let mut outbound = TcpStream::connect(config.server.address)
.await
.map_err(|_| ())?;
// Construct temporary server client
let tmp_client = match outbound.local_addr() {
Ok(addr) => Client::new(addr),
Err(_) => Client::dummy(),
};
tmp_client.set_state(ClientState::Login);
// Construct client info
let mut tmp_client_info = ClientInfo::empty();
tmp_client_info.protocol.replace(config.public.protocol);
let (mut reader, mut writer) = outbound.split();
// Select server address to use, add magic if Forge
let server_addr = if config.server.forge {
format!(
"{}{}",
config.server.address.ip().to_string(),
forge::STATUS_MAGIC,
)
} else {
config.server.address.ip().to_string()
};
// Send handshake packet
packet::write_packet(
Handshake {
protocol_version: config.public.protocol as i32,
server_addr,
server_port: config.server.address.port(),
next_state: ClientState::Login.to_id(),
},
&tmp_client,
&mut writer,
)
.await?;
// Request login start
packet::write_packet(
LoginStart {
name: PROBE_USER.into(),
},
&tmp_client,
&mut writer,
)
.await?;
// Incoming buffer, record Forge plugin request payload
let mut buf = BytesMut::new();
let mut forge_payload = Vec::new();
loop {
// Read packet from stream
let (packet, raw) = match packet::read_packet(&tmp_client, &mut buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc::forge", "Closing connection, error occurred");
break;
}
};
// Grab client state
let client_state = tmp_client.state();
// Catch set compression
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_SET_COMPRESSION
{
// Decode compression packet
let set_compression =
SetCompression::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
// Client and server compression threshold should match, show warning if not
if set_compression.threshold != proto::COMPRESSION_THRESHOLD {
error!(
target: "lazymc::forge",
"Compression threshold sent to lobby client does not match threshold from server, this may cause errors (client: {}, server: {})",
proto::COMPRESSION_THRESHOLD,
set_compression.threshold
);
}
// Set client compression
tmp_client.set_compression(set_compression.threshold);
continue;
}
// Catch login plugin request
if client_state == ClientState::Login
&& packet.id == packets::login::CLIENT_LOGIN_PLUGIN_REQUEST
{
// Decode login plugin request packet
let plugin_request = LoginPluginRequest::decode(&mut packet.data.as_slice()).map_err(|err| {
error!(target: "lazymc::probe", "Failed to decode login plugin request from server, cannot respond properly: {:?}", err);
})?;
// Handle plugin requests for Forge
if config.server.forge {
// Record Forge login payload
forge_payload.push(raw);
// Respond to Forge login plugin request
forge::respond_login_plugin_request(&tmp_client, plugin_request, &mut writer)
.await?;
continue;
}
warn!(target: "lazymc::probe", "Got unexpected login plugin request, responding with error");
// Respond with plugin response failure
packet::write_packet(
LoginPluginResponse {
message_id: plugin_request.message_id,
successful: false,
data: vec![],
},
&tmp_client,
&mut writer,
)
.await?;
continue;
}
// Hijack login success
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS {
trace!(target: "lazymc::probe", "Got login success from server connection, change to play mode");
// Switch to play state
tmp_client.set_state(ClientState::Play);
// Wait to catch join game packet
let join_game_data =
wait_for_server_join_game(&tmp_client, &tmp_client_info, &mut outbound, &mut buf)
.await?;
server
.probed_join_game
.write()
.await
.replace(join_game_data);
// Gracefully close connection
let _ = net::close_tcp_stream(outbound).await;
return Ok(forge_payload);
}
// Show unhandled packet warning
debug!(target: "lazymc::forge", "Got unhandled packet from server in connect_to_server:");
debug!(target: "lazymc::forge", "- State: {:?}", client_state);
debug!(target: "lazymc::forge", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
// Gracefully close connection
net::close_tcp_stream(outbound).await.map_err(|_| ())?;
Err(())
}
/// Wait for join game packet on server connection, with timeout.
///
/// This parses, consumes and returns the packet.
async fn wait_for_server_join_game(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGameData, ()> {
time::timeout(
PROBE_JOIN_GAME_TIMEOUT,
wait_for_server_join_game_no_timeout(client, client_info, outbound, buf),
)
.await
.map_err(|_| {
error!(target: "lazymc::probe", "Waiting for for game data from server for probe client timed out after {}s", PROBE_JOIN_GAME_TIMEOUT.as_secs());
})?
}
/// Wait for join game packet on server connection, with no timeout.
///
/// This parses, consumes and returns the packet.
// TODO: clean this up
// TODO: do not drop error here, return Box<dyn Error>
async fn wait_for_server_join_game_no_timeout(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGameData, ()> {
let (mut reader, mut _writer) = outbound.split();
loop {
// Read packet from stream
let (packet, _raw) = match packet::read_packet(client, buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc::probe", "Closing connection, error occurred");
break;
}
};
// Catch join game
if packets::play::join_game::is_packet(client_info, packet.id) {
// Parse join game data
let join_game_data = JoinGameData::from_packet(client_info, packet).map_err(|err| {
warn!(target: "lazymc::probe", "Failed to parse join game packet: {:?}", err);
})?;
return Ok(join_game_data);
}
// Show unhandled packet warning
debug!(target: "lazymc::probe", "Got unhandled packet from server in wait_for_server_join_game:");
debug!(target: "lazymc::probe", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
// Gracefully close connection
net::close_tcp_stream_ref(outbound).await.map_err(|_| ())?;
Err(())
}

View File

@@ -2,6 +2,8 @@ use std::net::SocketAddr;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Mutex;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
/// Client state.
///
/// Note: this does not keep track of encryption states.
@@ -113,8 +115,11 @@ impl Default for ClientState {
/// Client info, useful during connection handling.
#[derive(Debug, Clone, Default)]
pub struct ClientInfo {
/// Client protocol version.
pub protocol_version: Option<i32>,
/// Used protocol version.
pub protocol: Option<u32>,
/// Handshake as received from client.
pub handshake: Option<Handshake>,
/// Client username.
pub username: Option<String>,
@@ -124,4 +129,10 @@ impl ClientInfo {
pub fn empty() -> Self {
Self::default()
}
/// Get protocol version.
pub fn protocol(&self) -> Option<u32> {
self.protocol
.or_else(|| self.handshake.as_ref().map(|h| h.protocol_version as u32))
}
}

View File

@@ -1,3 +1,4 @@
use std::fmt::Debug;
use std::io::prelude::*;
use bytes::BytesMut;
@@ -44,11 +45,22 @@ impl RawPacket {
///
/// This decodes both compressed and uncompressed packets based on the client threshold
/// preference.
pub fn decode(client: &Client, mut buf: &[u8]) -> Result<Self, ()> {
pub fn decode_with_len(client: &Client, mut buf: &[u8]) -> Result<Self, ()> {
// Read length
let (read, len) = types::read_var_int(buf)?;
buf = &buf[read..][..len as usize];
// TODO: assert buffer length!
Self::decode_without_len(client, buf)
}
/// Decode packet from raw buffer without packet length.
///
/// This decodes both compressed and uncompressed packets based on the client threshold
/// preference.
/// The length is given, and not included in the buffer itself.
pub fn decode_without_len(client: &Client, mut buf: &[u8]) -> Result<Self, ()> {
// If no compression is used, read remaining packet ID and data
if !client.is_compressed() {
// Read packet ID and data
@@ -85,7 +97,20 @@ impl RawPacket {
/// Encode packet to raw buffer.
///
/// This compresses packets based on the client threshold preference.
pub fn encode(&self, client: &Client) -> Result<Vec<u8>, ()> {
pub fn encode_with_len(&self, client: &Client) -> Result<Vec<u8>, ()> {
// Encode packet without length
let mut payload = self.encode_without_len(client)?;
// Add length header
let mut packet = types::encode_var_int(payload.len() as i32)?;
packet.append(&mut payload);
Ok(packet)
}
/// Encode packet to raw buffer without length header.
///
/// This compresses packets based on the client threshold preference.
pub fn encode_without_len(&self, client: &Client) -> Result<Vec<u8>, ()> {
let threshold = client.compressed();
if threshold >= 0 {
self.encode_compressed(threshold)
@@ -103,8 +128,7 @@ impl RawPacket {
// Determine whether to compress, encode data length bytes
let data_len = payload.len() as i32;
let compress = data_len > threshold;
let mut data_len_bytes =
types::encode_var_int(if compress { data_len } else { 0 }).unwrap();
let data_len_header = if compress { data_len } else { 0 };
// Compress payload
if compress {
@@ -117,10 +141,8 @@ impl RawPacket {
})?;
}
// Encapsulate payload with packet and data length
let len = data_len_bytes.len() as i32 + payload.len() as i32;
let mut packet = types::encode_var_int(len)?;
packet.append(&mut data_len_bytes);
// Add data length header
let mut packet = types::encode_var_int(data_len_header).unwrap();
packet.append(&mut payload);
Ok(packet)
@@ -128,12 +150,8 @@ impl RawPacket {
/// Encode uncompressed packet to raw buffer.
fn encode_uncompressed(&self) -> Result<Vec<u8>, ()> {
let mut data = types::encode_var_int(self.id as i32)?;
data.extend_from_slice(&self.data);
let len = data.len() as i32;
let mut packet = types::encode_var_int(len)?;
packet.append(&mut data);
let mut packet = types::encode_var_int(self.id as i32)?;
packet.extend_from_slice(&self.data);
Ok(packet)
}
@@ -194,22 +212,23 @@ pub async fn read_packet(
}
// Parse packet, use full buffer since we'll read the packet length again
// TODO: use decode_without_len, strip len from buffer
let raw = buf.split_to(consumed + len as usize);
let packet = RawPacket::decode(client, &raw)?;
let packet = RawPacket::decode_with_len(client, &raw)?;
Ok(Some((packet, raw.to_vec())))
}
/// Write packet to stream writer.
pub async fn write_packet(
packet: impl PacketId + Encoder,
packet: impl PacketId + Encoder + Debug,
client: &Client,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
let mut data = Vec::new();
packet.encode(&mut data).map_err(|_| ())?;
let response = RawPacket::new(packet.packet_id(), data).encode(client)?;
let response = RawPacket::new(packet.packet_id(), data).encode_with_len(client)?;
writer.write_all(&response).await.map_err(|_| ())?;
Ok(())

View File

@@ -1,41 +0,0 @@
//! Minecraft protocol packet IDs.
#![allow(unused)]
pub mod handshake {
pub const SERVER_HANDSHAKE: u8 = 0x00;
}
pub mod status {
pub const CLIENT_STATUS: u8 = 0x0;
pub const CLIENT_PING: u8 = 0x01;
pub const SERVER_STATUS: u8 = 0x00;
pub const SERVER_PING: u8 = 0x01;
}
pub mod login {
pub const CLIENT_DISCONNECT: u8 = 0x00;
pub const CLIENT_LOGIN_SUCCESS: u8 = 0x02;
pub const CLIENT_SET_COMPRESSION: u8 = 0x03;
pub const SERVER_LOGIN_START: u8 = 0x00;
}
pub mod play {
pub const CLIENT_CHAT_MSG: u8 = 0x0F;
pub const CLIENT_PLUGIN_MESSAGE: u8 = 0x18;
pub const CLIENT_NAMED_SOUND_EFFECT: u8 = 0x19;
pub const CLIENT_DISCONNECT: u8 = 0x1A;
pub const CLIENT_KEEP_ALIVE: u8 = 0x21;
pub const CLIENT_JOIN_GAME: u8 = 0x26;
pub const CLIENT_PLAYER_POS_LOOK: u8 = 0x38;
pub const CLIENT_RESPAWN: u8 = 0x3D;
pub const CLIENT_SPAWN_POS: u8 = 0x4B;
pub const CLIENT_SET_TITLE_SUBTITLE: u8 = 0x57;
pub const CLIENT_TIME_UPDATE: u8 = 0x58;
pub const CLIENT_SET_TITLE_TEXT: u8 = 0x59;
pub const CLIENT_SET_TITLE_TIMES: u8 = 0x5A;
pub const SERVER_CLIENT_SETTINGS: u8 = 0x05;
pub const SERVER_PLUGIN_MESSAGE: u8 = 0x0A;
pub const SERVER_PLAYER_POS: u8 = 0x11;
pub const SERVER_PLAYER_POS_ROT: u8 = 0x12;
}

33
src/proto/packets/mod.rs Normal file
View File

@@ -0,0 +1,33 @@
//! Minecraft protocol packet IDs.
pub mod play;
pub mod handshake {
use minecraft_protocol::version::v1_14_4::handshake::*;
pub const SERVER_HANDSHAKE: u8 = Handshake::PACKET_ID;
}
pub mod status {
use minecraft_protocol::version::v1_14_4::status::*;
pub const CLIENT_STATUS: u8 = StatusResponse::PACKET_ID;
pub const CLIENT_PING: u8 = PingResponse::PACKET_ID;
pub const SERVER_STATUS: u8 = StatusRequest::PACKET_ID;
pub const SERVER_PING: u8 = PingRequest::PACKET_ID;
}
pub mod login {
use minecraft_protocol::version::v1_14_4::login::*;
#[cfg(feature = "lobby")]
pub const CLIENT_DISCONNECT: u8 = LoginDisconnect::PACKET_ID;
pub const CLIENT_LOGIN_SUCCESS: u8 = LoginSuccess::PACKET_ID;
pub const CLIENT_SET_COMPRESSION: u8 = SetCompression::PACKET_ID;
#[cfg(feature = "lobby")]
pub const CLIENT_ENCRYPTION_REQUEST: u8 = EncryptionRequest::PACKET_ID;
pub const CLIENT_LOGIN_PLUGIN_REQUEST: u8 = LoginPluginRequest::PACKET_ID;
pub const SERVER_LOGIN_START: u8 = LoginStart::PACKET_ID;
#[cfg(feature = "lobby")]
pub const SERVER_LOGIN_PLUGIN_RESPONSE: u8 = LoginPluginResponse::PACKET_ID;
}

View File

@@ -0,0 +1,208 @@
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::error::DecodeError;
use minecraft_protocol::version::{v1_16_3, v1_17};
use nbt::CompoundTag;
#[cfg(feature = "lobby")]
use tokio::net::tcp::WriteHalf;
#[cfg(feature = "lobby")]
use crate::mc::dimension;
#[cfg(feature = "lobby")]
use crate::proto::client::Client;
use crate::proto::client::ClientInfo;
#[cfg(feature = "lobby")]
use crate::proto::packet;
use crate::proto::packet::RawPacket;
#[cfg(feature = "lobby")]
use crate::server::Server;
/// Data extracted from `JoinGame` packet.
#[derive(Debug, Clone)]
pub struct JoinGameData {
pub hardcore: Option<bool>,
pub game_mode: Option<u8>,
pub previous_game_mode: Option<u8>,
pub world_names: Option<Vec<String>>,
pub dimension: Option<CompoundTag>,
pub dimension_codec: Option<CompoundTag>,
pub world_name: Option<String>,
pub hashed_seed: Option<i64>,
pub max_players: Option<i32>,
pub view_distance: Option<i32>,
pub reduced_debug_info: Option<bool>,
pub enable_respawn_screen: Option<bool>,
pub is_debug: Option<bool>,
pub is_flat: Option<bool>,
}
impl JoinGameData {
/// Extract join game data from given packet.
pub fn from_packet(client_info: &ClientInfo, packet: RawPacket) -> Result<Self, DecodeError> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
Ok(v1_16_3::game::JoinGame::decode(&mut packet.data.as_slice())?.into())
}
_ => Ok(v1_17::game::JoinGame::decode(&mut packet.data.as_slice())?.into()),
}
}
}
impl From<v1_16_3::game::JoinGame> for JoinGameData {
fn from(join_game: v1_16_3::game::JoinGame) -> Self {
Self {
hardcore: Some(join_game.hardcore),
game_mode: Some(join_game.game_mode),
previous_game_mode: Some(join_game.previous_game_mode),
world_names: Some(join_game.world_names.clone()),
dimension: Some(join_game.dimension),
dimension_codec: Some(join_game.dimension_codec),
world_name: Some(join_game.world_name),
hashed_seed: Some(join_game.hashed_seed),
max_players: Some(join_game.max_players),
view_distance: Some(join_game.view_distance),
reduced_debug_info: Some(join_game.reduced_debug_info),
enable_respawn_screen: Some(join_game.enable_respawn_screen),
is_debug: Some(join_game.is_debug),
is_flat: Some(join_game.is_flat),
}
}
}
impl From<v1_17::game::JoinGame> for JoinGameData {
fn from(join_game: v1_17::game::JoinGame) -> Self {
Self {
hardcore: Some(join_game.hardcore),
game_mode: Some(join_game.game_mode),
previous_game_mode: Some(join_game.previous_game_mode),
world_names: Some(join_game.world_names.clone()),
dimension: Some(join_game.dimension),
dimension_codec: Some(join_game.dimension_codec),
world_name: Some(join_game.world_name),
hashed_seed: Some(join_game.hashed_seed),
max_players: Some(join_game.max_players),
view_distance: Some(join_game.view_distance),
reduced_debug_info: Some(join_game.reduced_debug_info),
enable_respawn_screen: Some(join_game.enable_respawn_screen),
is_debug: Some(join_game.is_debug),
is_flat: Some(join_game.is_flat),
}
}
}
/// Check whether the packet ID matches.
pub fn is_packet(client_info: &ClientInfo, packet_id: u8) -> bool {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => packet_id == v1_16_3::game::JoinGame::PACKET_ID,
_ => packet_id == v1_17::game::JoinGame::PACKET_ID,
}
}
/// Send initial join game packet to client for lobby.
#[cfg(feature = "lobby")]
pub async fn lobby_send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
server: &Server,
) -> Result<(), ()> {
let status = server.status().await;
let join_game = server.probed_join_game.read().await;
// Get dimension codec and build lobby dimension
let dimension_codec: CompoundTag = if let Some(join_game) = join_game.as_ref() {
join_game
.dimension_codec
.clone()
.unwrap_or_else(dimension::default_dimension_codec)
} else {
dimension::default_dimension_codec()
};
// Get other values from status and probed join game data
let dimension: CompoundTag = dimension::lobby_dimension(&dimension_codec);
let hardcore = join_game.as_ref().and_then(|p| p.hardcore).unwrap_or(false);
let world_names = join_game
.as_ref()
.and_then(|p| p.world_names.clone())
.unwrap_or_else(|| {
vec![
"minecraft:overworld".into(),
"minecraft:the_nether".into(),
"minecraft:the_end".into(),
]
});
let max_players = status
.as_ref()
.map(|s| s.players.max as i32)
.or_else(|| join_game.as_ref().and_then(|p| p.max_players))
.unwrap_or(20);
let view_distance = join_game
.as_ref()
.and_then(|p| p.view_distance)
.unwrap_or(10);
let reduced_debug_info = join_game
.as_ref()
.and_then(|p| p.reduced_debug_info)
.unwrap_or(false);
let enable_respawn_screen = join_game
.as_ref()
.and_then(|p| p.enable_respawn_screen)
.unwrap_or(true);
let is_debug = join_game.as_ref().and_then(|p| p.is_debug).unwrap_or(false);
let is_flat = join_game.as_ref().and_then(|p| p.is_flat).unwrap_or(false);
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::JoinGame {
// Player ID must be unique, if it collides with another server entity ID the player gets
// in a weird state and cannot move
entity_id: 0,
hardcore,
game_mode: 3,
previous_game_mode: -1i8 as u8,
world_names,
dimension_codec,
dimension,
world_name: "lazymc:lobby".into(),
hashed_seed: 0,
max_players,
view_distance,
reduced_debug_info,
enable_respawn_screen,
is_debug,
is_flat,
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::JoinGame {
// Player ID must be unique, if it collides with another server entity ID the player gets
// in a weird state and cannot move
entity_id: 0,
hardcore,
game_mode: 3,
previous_game_mode: -1i8 as u8,
world_names,
dimension_codec,
dimension,
world_name: "lazymc:lobby".into(),
hashed_seed: 0,
max_players,
view_distance,
reduced_debug_info,
enable_respawn_screen,
is_debug,
is_flat,
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,29 @@
use std::sync::atomic::{AtomicU64, Ordering};
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Auto incrementing ID source for keep alive packets.
static KEEP_ALIVE_ID: AtomicU64 = AtomicU64::new(0);
/// Send keep alive packet to client.
///
/// Required periodically in play mode to prevent client timeout.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
// Keep sending new IDs
let id = KEEP_ALIVE_ID.fetch_add(1, Ordering::Relaxed);
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(v1_16_3::game::ClientBoundKeepAlive { id }, client, writer).await
}
_ => packet::write_packet(v1_17::game::ClientBoundKeepAlive { id }, client, writer).await,
}
}

View File

@@ -0,0 +1,15 @@
pub mod join_game;
#[cfg(feature = "lobby")]
pub mod keep_alive;
#[cfg(feature = "lobby")]
pub mod player_pos;
#[cfg(feature = "lobby")]
pub mod respawn;
#[cfg(feature = "lobby")]
pub mod server_brand;
#[cfg(feature = "lobby")]
pub mod sound;
#[cfg(feature = "lobby")]
pub mod time_update;
#[cfg(feature = "lobby")]
pub mod title;

View File

@@ -0,0 +1,48 @@
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Move player to world origin.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::PlayerPositionAndLook {
x: 0.0,
y: 0.0,
z: 0.0,
yaw: 0.0,
pitch: 90.0,
flags: 0b00000000,
teleport_id: 0,
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::PlayerPositionAndLook {
x: 0.0,
y: 0.0,
z: 0.0,
yaw: 0.0,
pitch: 90.0,
flags: 0b00000000,
teleport_id: 0,
dismount_vehicle: true,
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,70 @@
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use super::join_game::JoinGameData;
use crate::mc::dimension;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Send respawn packet to client to jump from lobby into now loaded server.
///
/// The required details will be fetched from the `join_game` packet as provided by the server.
pub async fn lobby_send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
data: JoinGameData,
) -> Result<(), ()> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::Respawn {
dimension: data.dimension.unwrap_or_else(|| {
dimension::lobby_dimension(
&data
.dimension_codec
.unwrap_or_else(dimension::default_dimension_codec),
)
}),
world_name: data
.world_name
.unwrap_or_else(|| "minecraft:overworld".into()),
hashed_seed: data.hashed_seed.unwrap_or(0),
game_mode: data.game_mode.unwrap_or(0),
previous_game_mode: data.previous_game_mode.unwrap_or(-1i8 as u8),
is_debug: data.is_debug.unwrap_or(false),
is_flat: data.is_flat.unwrap_or(false),
copy_metadata: false,
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::Respawn {
dimension: data.dimension.unwrap_or_else(|| {
dimension::lobby_dimension(
&data
.dimension_codec
.unwrap_or_else(dimension::default_dimension_codec),
)
}),
world_name: data
.world_name
.unwrap_or_else(|| "minecraft:overworld".into()),
hashed_seed: data.hashed_seed.unwrap_or(0),
game_mode: data.game_mode.unwrap_or(0),
previous_game_mode: data.previous_game_mode.unwrap_or(-1i8 as u8),
is_debug: data.is_debug.unwrap_or(false),
is_flat: data.is_flat.unwrap_or(false),
copy_metadata: false,
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,45 @@
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Minecraft channel to set brand.
const CHANNEL: &str = "minecraft:brand";
/// Server brand to send to client in lobby world.
///
/// Shown in F3 menu. Updated once client is relayed to real server.
const SERVER_BRAND: &[u8] = b"lazymc";
/// Send lobby brand to client.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::ClientBoundPluginMessage {
channel: CHANNEL.into(),
data: SERVER_BRAND.into(),
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::ClientBoundPluginMessage {
channel: CHANNEL.into(),
data: SERVER_BRAND.into(),
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,48 @@
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Play a sound effect at world origin.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
sound_name: &str,
) -> Result<(), ()> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::NamedSoundEffect {
sound_name: sound_name.into(),
sound_category: 0,
effect_pos_x: 0,
effect_pos_y: 0,
effect_pos_z: 0,
volume: 1.0,
pitch: 1.0,
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::NamedSoundEffect {
sound_name: sound_name.into(),
sound_category: 0,
effect_pos_x: 0,
effect_pos_y: 0,
effect_pos_z: 0,
volume: 1.0,
pitch: 1.0,
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,41 @@
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
/// Send lobby time update to client.
///
/// Sets world time to 0.
///
/// Required once for keep-alive packets.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => {
packet::write_packet(
v1_16_3::game::TimeUpdate {
world_age: 0,
time_of_day: 0,
},
client,
writer,
)
.await
}
_ => {
packet::write_packet(
v1_17::game::TimeUpdate {
world_age: 0,
time_of_day: 0,
},
client,
writer,
)
.await
}
}
}

View File

@@ -0,0 +1,141 @@
use minecraft_protocol::data::chat::{Message, Payload};
use minecraft_protocol::version::{v1_16_3, v1_17};
use tokio::net::tcp::WriteHalf;
#[cfg(feature = "lobby")]
use crate::lobby::KEEP_ALIVE_INTERVAL;
use crate::mc;
use crate::proto::client::{Client, ClientInfo};
use crate::proto::packet;
#[cfg(feature = "lobby")]
const DISPLAY_TIME: i32 = KEEP_ALIVE_INTERVAL.as_secs() as i32 * mc::TICKS_PER_SECOND as i32 * 2;
#[cfg(not(feature = "lobby"))]
const DISPLAY_TIME: i32 = 10 * mc::TICKS_PER_SECOND as i32 * 2;
/// Send lobby title packets to client.
///
/// This will show the given text for two keep-alive periods. Use a newline for the subtitle.
///
/// If an empty string is given, the title times will be reset to default.
pub async fn send(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
text: &str,
) -> Result<(), ()> {
// Grab title and subtitle bits
let title = text.lines().next().unwrap_or("");
let subtitle = text.lines().skip(1).collect::<Vec<_>>().join("\n");
match client_info.protocol() {
Some(p) if p < v1_17::PROTOCOL => send_v1_16_3(client, writer, title, &subtitle).await,
_ => send_v1_17(client, writer, title, &subtitle).await,
}
}
async fn send_v1_16_3(
client: &Client,
writer: &mut WriteHalf<'_>,
title: &str,
subtitle: &str,
) -> Result<(), ()> {
use v1_16_3::game::{Title, TitleAction};
// Set title
packet::write_packet(
Title {
action: TitleAction::SetTitle {
text: Message::new(Payload::text(title)),
},
},
client,
writer,
)
.await?;
// Set subtitle
packet::write_packet(
Title {
action: TitleAction::SetSubtitle {
text: Message::new(Payload::text(subtitle)),
},
},
client,
writer,
)
.await?;
// Set title times
packet::write_packet(
Title {
action: if title.is_empty() && subtitle.is_empty() {
// Defaults: https://minecraft.fandom.com/wiki/Commands/title#Detail
TitleAction::SetTimesAndDisplay {
fade_in: 10,
stay: 70,
fade_out: 20,
}
} else {
TitleAction::SetTimesAndDisplay {
fade_in: 0,
stay: DISPLAY_TIME,
fade_out: 0,
}
},
},
client,
writer,
)
.await
}
async fn send_v1_17(
client: &Client,
writer: &mut WriteHalf<'_>,
title: &str,
subtitle: &str,
) -> Result<(), ()> {
use v1_17::game::{SetTitleSubtitle, SetTitleText, SetTitleTimes};
// Set title
packet::write_packet(
SetTitleText {
text: Message::new(Payload::text(title)),
},
client,
writer,
)
.await?;
// Set subtitle
packet::write_packet(
SetTitleSubtitle {
text: Message::new(Payload::text(subtitle)),
},
client,
writer,
)
.await?;
// Set title times
packet::write_packet(
if title.is_empty() && subtitle.is_empty() {
// Defaults: https://minecraft.fandom.com/wiki/Commands/title#Detail
SetTitleTimes {
fade_in: 10,
stay: 70,
fade_out: 20,
}
} else {
SetTitleTimes {
fade_in: 0,
stay: DISPLAY_TIME,
fade_out: 0,
}
},
client,
writer,
)
.await
}

View File

@@ -1,6 +1,9 @@
use std::error::Error;
use std::net::SocketAddr;
use bytes::BytesMut;
use proxy_protocol::version2::{ProxyAddresses, ProxyCommand, ProxyTransportProtocol};
use proxy_protocol::EncodeError;
use tokio::io;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
@@ -8,8 +11,12 @@ use tokio::net::TcpStream;
use crate::net;
/// Proxy the inbound stream to a target address.
pub async fn proxy(inbound: TcpStream, addr_target: SocketAddr) -> Result<(), Box<dyn Error>> {
proxy_with_queue(inbound, addr_target, &[]).await
pub async fn proxy(
inbound: TcpStream,
proxy_header: ProxyHeader,
addr_target: SocketAddr,
) -> Result<(), Box<dyn Error>> {
proxy_with_queue(inbound, proxy_header, addr_target, &[]).await
}
/// Proxy the inbound stream to a target address.
@@ -17,12 +24,26 @@ pub async fn proxy(inbound: TcpStream, addr_target: SocketAddr) -> Result<(), Bo
/// Send the queue to the target server before proxying.
pub async fn proxy_with_queue(
inbound: TcpStream,
proxy_header: ProxyHeader,
addr_target: SocketAddr,
queue: &[u8],
) -> Result<(), Box<dyn Error>> {
// Set up connection to server
// TODO: on connect fail, ping server and redirect to serve_status if offline
let outbound = TcpStream::connect(addr_target).await?;
let mut outbound = TcpStream::connect(addr_target).await?;
// Add proxy header
match proxy_header {
ProxyHeader::None => {}
ProxyHeader::Local => {
let header = local_proxy_header()?;
outbound.write_all(&header).await?;
}
ProxyHeader::Proxy => {
let header = stream_proxy_header(&inbound)?;
outbound.write_all(&header).await?;
}
}
// Start proxy on both streams
proxy_inbound_outbound_with_queue(inbound, outbound, &[], queue).await
@@ -71,3 +92,76 @@ pub async fn proxy_inbound_outbound_with_queue(
Ok(())
}
/// Proxy header.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ProxyHeader {
/// Do not add proxy header.
None,
/// Header for locally initiated connection.
#[allow(unused)]
Local,
/// Header for proxied connection.
Proxy,
}
impl ProxyHeader {
/// Changes to `None` if `false` if given.
///
/// `None` stays `None`.
pub fn not_none(self, not_none: bool) -> Self {
if not_none {
self
} else {
Self::None
}
}
}
/// Get the proxy header for a locally initiated connection.
///
/// This header may be sent over the outbound stream to signal client information.
pub fn local_proxy_header() -> Result<BytesMut, EncodeError> {
// Build proxy header
let header = proxy_protocol::ProxyHeader::Version2 {
command: ProxyCommand::Local,
transport_protocol: ProxyTransportProtocol::Stream,
addresses: ProxyAddresses::Unspec,
};
proxy_protocol::encode(header)
}
/// Get the proxy header for the given inbound stream.
///
/// This header may be sent over the outbound stream to signal client information.
pub fn stream_proxy_header(inbound: &TcpStream) -> Result<BytesMut, EncodeError> {
// Get peer and local address
let peer = inbound
.peer_addr()
.expect("Peer address not known for TCP stream");
let local = inbound
.local_addr()
.expect("Local address not known for TCP stream");
// Build proxy header
let header = proxy_protocol::ProxyHeader::Version2 {
command: ProxyCommand::Proxy,
transport_protocol: ProxyTransportProtocol::Stream,
addresses: match (peer, local) {
(SocketAddr::V4(source), SocketAddr::V4(destination)) => ProxyAddresses::Ipv4 {
source,
destination,
},
(SocketAddr::V6(source), SocketAddr::V6(destination)) => ProxyAddresses::Ipv6 {
source,
destination,
},
(_, _) => unreachable!(),
},
};
proxy_protocol::encode(header)
}

View File

@@ -12,9 +12,10 @@ use tokio::sync::Semaphore;
use tokio::sync::{Mutex, RwLock, RwLockReadGuard};
use tokio::time;
use crate::config::Config;
use crate::config::{Config, Server as ConfigServer};
use crate::mc::ban::{BannedIp, BannedIps};
use crate::os;
use crate::proto::packets::play::join_game::JoinGameData;
/// Server cooldown after the process quit.
/// Used to give it some more time to quit forgotten threads, such as for RCON.
@@ -27,6 +28,10 @@ const SERVER_QUIT_COOLDOWN: Duration = Duration::from_millis(2500);
#[cfg(feature = "rcon")]
const RCON_COOLDOWN: Duration = Duration::from_secs(15);
/// Exit code when SIGTERM is received on Unix.
#[cfg(unix)]
const UNIX_EXIT_SIGTERM: i32 = 130;
/// Shared server state.
#[derive(Debug)]
pub struct Server {
@@ -75,6 +80,14 @@ pub struct Server {
/// Last time server was stopped over RCON.
#[cfg(feature = "rcon")]
rcon_last_stop: Mutex<Option<Instant>>,
/// Probed join game data.
pub probed_join_game: RwLock<Option<JoinGameData>>,
/// Forge payload.
///
/// Sent to clients when they connect to lobby. Recorded from server by probe.
pub forge_payload: RwLock<Vec<Vec<u8>>>,
}
impl Server {
@@ -362,6 +375,8 @@ impl Default for Server {
rcon_lock: Semaphore::new(1),
#[cfg(feature = "rcon")]
rcon_last_stop: Default::default(),
probed_join_game: Default::default(),
forge_payload: Default::default(),
}
}
}
@@ -417,7 +432,7 @@ pub async fn invoke_server_cmd(
cmd.kill_on_drop(true);
// Set working directory
if let Some(ref dir) = config.server.directory {
if let Some(ref dir) = ConfigServer::server_directory(&config) {
cmd.current_dir(dir);
}
@@ -443,6 +458,11 @@ pub async fn invoke_server_cmd(
debug!(target: "lazymc", "Server process stopped successfully ({})", status);
false
}
#[cfg(unix)]
Ok(status) if status.code() == Some(UNIX_EXIT_SIGTERM) => {
debug!(target: "lazymc", "Server process stopped successfully by SIGTERM ({})", status);
false
}
Ok(status) => {
warn!(target: "lazymc", "Server process stopped with error code ({})", status);
state.state() == State::Started
@@ -498,13 +518,8 @@ async fn stop_server_rcon(config: &Config, server: &Server) -> bool {
return false;
}
// RCON address
let mut addr = config.server.address;
addr.set_port(config.rcon.port);
let addr = addr.to_string();
// Create RCON client
let mut rcon = match Rcon::connect(&addr, &config.rcon.password).await {
let mut rcon = match Rcon::connect_config(config).await {
Ok(rcon) => rcon,
Err(err) => {
error!(target: "lazymc", "Failed to RCON server to sleep: {}", err);
@@ -522,11 +537,11 @@ async fn stop_server_rcon(config: &Config, server: &Server) -> bool {
server.rcon_last_stop.lock().await.replace(Instant::now());
server.update_state(State::Stopping, config).await;
drop(rcon_lock);
// Gracefully close connection
rcon.close().await;
drop(rcon_lock);
true
}

View File

@@ -6,7 +6,7 @@ use std::time::Duration;
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use crate::config::Config;
use crate::config::{Config, Server as ConfigServer};
use crate::mc::ban;
use crate::server::Server;
@@ -23,7 +23,7 @@ pub fn service(config: Arc<Config>, server: Arc<Server>) {
}
// Ensure server directory is set, it must exist
let dir = match &config.server.directory {
let dir = match ConfigServer::server_directory(&config) {
Some(dir) => dir,
None => {
warn!(target: "lazymc", "Not blocking banned IPs, server directory not configured, unable to find {} file", ban::FILE);
@@ -102,7 +102,7 @@ fn watch(server: &Server, path: &Path) -> bool {
// Reload banned IPs
if reload {
info!(target: "lazymc", "Reloading list of banned IPs...");
debug!(target: "lazymc", "Reloading list of banned IPs...");
match ban::load(path) {
Ok(ips) => server.set_banned_ips_blocking(ips),
Err(err) => {

View File

@@ -1,4 +1,5 @@
pub mod ban_reload;
pub mod monitor;
pub mod probe;
pub mod server;
pub mod signal;

33
src/service/probe.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::sync::Arc;
use crate::config::{Config, Method};
use crate::probe;
use crate::server::Server;
/// Probe server.
pub async fn service(config: Arc<Config>, state: Arc<Server>) {
// Only probe if enabled or if we must
if !config.server.probe_on_start && !must_probe(&config) {
return;
}
// Probe
match probe::probe(config, state).await {
Ok(_) => info!(target: "lazymc::probe", "Succesfully probed server"),
Err(_) => {
error!(target: "lazymc::probe", "Failed to probe server, this may limit lazymc features")
}
}
}
/// Check whether we must probe.
fn must_probe(config: &Config) -> bool {
// Must probe with lobby and Forge
if config.server.forge && config.join.methods.contains(&Method::Lobby) {
warn!(target: "lazymc::probe", "Starting server to probe for Forge lobby...");
warn!(target: "lazymc::probe", "Set 'server.probe_on_start = true' to remove this warning");
return true;
}
false
}

View File

@@ -7,7 +7,7 @@ use tokio::net::{TcpListener, TcpStream};
use crate::config::Config;
use crate::proto::client::Client;
use crate::proxy;
use crate::proxy::{self, ProxyHeader};
use crate::server::{self, Server};
use crate::service;
use crate::status;
@@ -46,19 +46,22 @@ pub async fn service(config: Arc<Config>) -> Result<(), ()> {
);
}
// Spawn server monitor and signal handler services
// Spawn services: monitor, signal handler
tokio::spawn(service::monitor::service(config.clone(), server.clone()));
tokio::spawn(service::signal::service(config.clone(), server.clone()));
tokio::task::spawn_blocking({
let (config, server) = (config.clone(), server.clone());
|| service::ban_reload::service(config, server)
});
// Initiate server start
if config.server.wake_on_start {
Server::start(config.clone(), server.clone(), None).await;
}
// Spawn additional services: probe and ban manager
tokio::spawn(service::probe::service(config.clone(), server.clone()));
tokio::task::spawn_blocking({
let (config, server) = (config.clone(), server.clone());
|| service::ban_reload::service(config, server)
});
// Route all incomming connections
while let Ok((inbound, _)) = listener.accept().await {
route(inbound, config.clone(), server.clone());
@@ -114,7 +117,12 @@ fn route_status(inbound: TcpStream, config: Arc<Config>, server: Arc<Server>, pe
#[inline]
fn route_proxy(inbound: TcpStream, config: Arc<Config>) {
// When server is online, proxy all
let service = proxy::proxy(inbound, config.server.address).map(|r| {
let service = proxy::proxy(
inbound,
ProxyHeader::Proxy.not_none(config.server.send_proxy_v2),
config.server.address,
)
.map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to proxy: {}", err);
}
@@ -126,15 +134,25 @@ fn route_proxy(inbound: TcpStream, config: Arc<Config>) {
/// Route inbound TCP stream to proxy with queued data, spawning a new task.
#[inline]
pub fn route_proxy_queue(inbound: TcpStream, config: Arc<Config>, queue: BytesMut) {
route_proxy_address_queue(inbound, config.server.address, queue);
route_proxy_address_queue(
inbound,
ProxyHeader::Proxy.not_none(config.server.send_proxy_v2),
config.server.address,
queue,
);
}
/// Route inbound TCP stream to proxy with given address and queued data, spawning a new task.
#[inline]
pub fn route_proxy_address_queue(inbound: TcpStream, addr: SocketAddr, queue: BytesMut) {
pub fn route_proxy_address_queue(
inbound: TcpStream,
proxy_header: ProxyHeader,
addr: SocketAddr,
queue: BytesMut,
) {
// When server is online, proxy all
let service = async move {
proxy::proxy_with_queue(inbound, addr, &queue)
proxy::proxy_with_queue(inbound, proxy_header, addr, &queue)
.map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to proxy: {}", err);

View File

@@ -12,8 +12,9 @@ use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use crate::config::*;
use crate::config::{Config, Server as ConfigServer};
use crate::join;
use crate::mc::favicon;
use crate::proto::action;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::proto::packet::{self, RawPacket};
@@ -84,8 +85,9 @@ pub async fn serve(
// Update client info and client state
client_info
.protocol_version
.replace(handshake.protocol_version);
.protocol
.replace(handshake.protocol_version as u32);
client_info.handshake.replace(handshake);
client.set_state(new_state);
// If loggin in with handshake, remember inbound
@@ -98,13 +100,13 @@ pub async fn serve(
// Hijack server status packet
if client_state == ClientState::Status && packet.id == packets::status::SERVER_STATUS {
let server_status = server_status(&config, &server).await;
let server_status = server_status(&client_info, &config, &server).await;
let packet = StatusResponse { server_status };
let mut data = Vec::new();
packet.encode(&mut data).map_err(|_| ())?;
let response = RawPacket::new(0, data).encode(&client)?;
let response = RawPacket::new(0, data).encode_with_len(&client)?;
writer.write_all(&response).await.map_err(|_| ())?;
continue;
@@ -187,7 +189,7 @@ pub async fn serve(
}
// Show unhandled packet warning
debug!(target: "lazymc", "Received unhandled packet:");
debug!(target: "lazymc", "Got unhandled packet:");
debug!(target: "lazymc", "- State: {:?}", client_state);
debug!(target: "lazymc", "- Packet ID: {}", packet.id);
}
@@ -196,7 +198,7 @@ pub async fn serve(
}
/// Build server status object to respond to client with.
async fn server_status(config: &Config, server: &Server) -> ServerStatus {
async fn server_status(client_info: &ClientInfo, config: &Config, server: &Server) -> ServerStatus {
let status = server.status().await;
let server_state = server.state();
@@ -230,12 +232,16 @@ async fn server_status(config: &Config, server: &Server) -> ServerStatus {
}
};
// Get server favicon
let favicon = if config.motd.from_server && status.is_some() {
status.as_ref().unwrap().favicon.clone()
} else {
favicon(&config).await
};
// Extract favicon from real server status, load from disk, or use default
let mut favicon = None;
if favicon::supports_favicon(client_info) {
if config.motd.from_server && status.is_some() {
favicon = status.as_ref().unwrap().favicon.clone()
}
if favicon.is_none() {
favicon = Some(server_favicon(config).await);
}
}
// Build status resposne
ServerStatus {
@@ -251,31 +257,31 @@ async fn server_status(config: &Config, server: &Server) -> ServerStatus {
}
/// Get server status favicon.
async fn favicon(config: &Config) -> Option<String> {
///
/// This always returns a favicon, returning the default one if none is set.
async fn server_favicon(config: &Config) -> String {
// Get server dir
let dir = match config.server.directory.as_ref() {
let dir = match ConfigServer::server_directory(config) {
Some(dir) => dir,
None => return None,
None => return favicon::default_favicon(),
};
// Server icon file, ensure it exists
let path = dir.join(SERVER_ICON_FILE);
if !path.is_file() {
return None;
return favicon::default_favicon();
}
// Read icon data
let data = fs::read(path)
.await
.map_err(|err| {
error!(target: "lazymc", "Failed to read favicon from {}: {}", SERVER_ICON_FILE, err);
})
.ok()?;
let data = match fs::read(path).await.map_err(|err| {
error!(target: "lazymc", "Failed to read favicon from {}: {}", SERVER_ICON_FILE, err);
}) {
Ok(data) => data,
Err(err) => {
error!(target: "lazymc::status", "Failed to load server icon from disk, using default: {:?}", err);
return favicon::default_favicon();
}
};
// Format and return favicon
Some(format!(
"{}{}",
"data:image/png;base64,",
base64::encode(data)
))
favicon::encode_favicon(&data)
}