├── .env.example ├── .github └── FUNDING.yml ├── .gitignore ├── .gitlab-ci.yml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── TODO.md ├── build.rs ├── clippy.toml ├── docs ├── command-bash.md ├── command_bash.md ├── extras.md ├── join-method-lobby.md ├── protocol-version.md ├── proxy-ip.md └── usage-windows.md ├── res ├── dimension.snbt ├── dimension_codec.snbt ├── lazymc.toml ├── screenshot │ ├── demo.mp4 │ ├── join.png │ ├── lobby.png │ ├── sleeping.png │ ├── started.png │ └── starting.png ├── start-server ├── unknown_server.png └── unknown_server_optimized.png └── src ├── action ├── config_generate.rs ├── config_test.rs ├── mod.rs └── start.rs ├── cli.rs ├── config.rs ├── forge.rs ├── join ├── forward.rs ├── hold.rs ├── kick.rs ├── lobby.rs └── mod.rs ├── lobby.rs ├── main.rs ├── mc ├── ban.rs ├── dimension.rs ├── favicon.rs ├── mod.rs ├── rcon.rs ├── server_properties.rs ├── uuid.rs └── whitelist.rs ├── monitor.rs ├── net.rs ├── os ├── mod.rs └── windows.rs ├── probe.rs ├── proto ├── action.rs ├── client.rs ├── mod.rs ├── packet.rs └── packets │ ├── mod.rs │ └── play │ ├── join_game.rs │ ├── keep_alive.rs │ ├── mod.rs │ ├── player_pos.rs │ ├── respawn.rs │ ├── server_brand.rs │ ├── sound.rs │ ├── time_update.rs │ └── title.rs ├── proxy.rs ├── server.rs ├── service ├── file_watcher.rs ├── mod.rs ├── monitor.rs ├── probe.rs ├── server.rs └── signal.rs ├── status.rs ├── types.rs └── util ├── cli.rs ├── error.rs ├── mod.rs ├── serde.rs └── style.rs /.env.example: -------------------------------------------------------------------------------- 1 | RUST_LOG=info 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Funding links 2 | github: 3 | - timvisee 4 | custom: 5 | - "https://timvisee.com/donate" 6 | patreon: timvisee 7 | ko_fi: timvisee 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /lazymc.toml 3 | /target 4 | 5 | # Test server 6 | /mcserver 7 | /bettermc 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: "rust:slim" 2 | 3 | stages: 4 | - check 5 | - build 6 | - test 7 | - pre-release 8 | - release 9 | 10 | # Variable defaults 11 | variables: 12 | RUST_VERSION: stable 13 | TARGET: x86_64-unknown-linux-gnu 14 | 15 | # Install build dependencies 16 | before_script: 17 | - apt-get update 18 | - apt-get install -y --no-install-recommends build-essential 19 | - | 20 | rustup install $RUST_VERSION 21 | rustup default $RUST_VERSION 22 | - | 23 | rustc --version 24 | cargo --version 25 | 26 | # Windows before script 27 | .before_script-windows: &before_script-windows 28 | before_script: 29 | # Install scoop 30 | - iex "& {$(irm get.scoop.sh)} -RunAsAdmin" 31 | 32 | # Install Rust 33 | - scoop install rustup gcc 34 | - rustup install $RUST_VERSION 35 | - rustup default $RUST_VERSION 36 | - rustc --version 37 | - cargo --version 38 | 39 | # Install proper Rust target 40 | - rustup target install x86_64-pc-windows-msvc 41 | 42 | # Check on stable, beta and nightly 43 | .check-base: &check-base 44 | stage: check 45 | script: 46 | - cargo check --verbose 47 | - cargo check --no-default-features --verbose 48 | - cargo check --no-default-features --features rcon --verbose 49 | - cargo check --no-default-features --features lobby --verbose 50 | check-stable: 51 | <<: *check-base 52 | check-msrv: 53 | <<: *check-base 54 | variables: 55 | RUST_VERSION: 1.74.0 56 | only: 57 | - master 58 | 59 | # Build using Rust stable on Linux 60 | build-x86_64-linux-gnu: 61 | stage: build 62 | needs: [] 63 | script: 64 | - cargo build --target=$TARGET --release --locked --verbose 65 | - mv target/$TARGET/release/lazymc ./lazymc-$TARGET 66 | - strip -g ./lazymc-$TARGET 67 | artifacts: 68 | name: lazymc-x86_64-linux-gnu 69 | paths: 70 | - lazymc-$TARGET 71 | expire_in: 1 month 72 | 73 | # Build a static version 74 | build-x86_64-linux-musl: 75 | stage: build 76 | only: 77 | - master 78 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 79 | needs: [] 80 | variables: 81 | TARGET: x86_64-unknown-linux-musl 82 | script: 83 | - rustup target add $TARGET 84 | - cargo build --target=$TARGET --release --locked --verbose 85 | 86 | # Prepare the release artifact, strip it 87 | - find . -name lazymc -exec ls -lah {} \; 88 | - mv target/$TARGET/release/lazymc ./lazymc-$TARGET 89 | - strip -g ./lazymc-$TARGET 90 | artifacts: 91 | name: lazymc-x86_64-linux-musl 92 | paths: 93 | - lazymc-$TARGET 94 | expire_in: 1 month 95 | 96 | # Build using Rust stable on Linux for ARMv7 97 | build-armv7-linux-gnu: 98 | stage: build 99 | image: ubuntu 100 | only: 101 | - master 102 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 103 | needs: [] 104 | variables: 105 | TARGET: armv7-unknown-linux-gnueabihf 106 | before_script: 107 | - apt-get update 108 | - apt-get install -y --no-install-recommends build-essential 109 | - | 110 | apt-get install -y curl 111 | curl https://sh.rustup.rs -sSf | sh -s -- -y 112 | source $HOME/.cargo/env 113 | - | 114 | rustc --version 115 | cargo --version 116 | script: 117 | - apt-get install -y gcc-arm-linux-gnueabihf 118 | - rustup target add $TARGET 119 | 120 | - mkdir -p ~/.cargo 121 | - 'echo "[target.$TARGET]" >> ~/.cargo/config' 122 | - 'echo "linker = \"arm-linux-gnueabihf-gcc\"" >> ~/.cargo/config' 123 | 124 | - cargo build --target=$TARGET --release --locked --verbose 125 | - mv target/$TARGET/release/lazymc ./lazymc-$TARGET 126 | artifacts: 127 | name: lazymc-armv7-linux-gnu 128 | paths: 129 | - lazymc-$TARGET 130 | expire_in: 1 month 131 | 132 | # Build using Rust stable on Linux for aarch64 133 | build-aarch64-linux-gnu: 134 | stage: build 135 | image: ubuntu 136 | only: 137 | - master 138 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 139 | needs: [] 140 | variables: 141 | TARGET: aarch64-unknown-linux-gnu 142 | before_script: 143 | - apt-get update 144 | - apt-get install -y --no-install-recommends build-essential 145 | - | 146 | apt-get install -y curl 147 | curl https://sh.rustup.rs -sSf | sh -s -- -y 148 | source $HOME/.cargo/env 149 | - | 150 | rustc --version 151 | cargo --version 152 | script: 153 | - apt-get install -y gcc-aarch64-linux-gnu 154 | - rustup target add $TARGET 155 | 156 | - mkdir -p ~/.cargo 157 | - 'echo "[target.$TARGET]" >> ~/.cargo/config' 158 | - 'echo "linker = \"aarch64-linux-gnu-gcc\"" >> ~/.cargo/config' 159 | 160 | - cargo build --target=$TARGET --release --locked --verbose 161 | - mv target/$TARGET/release/lazymc ./lazymc-$TARGET 162 | artifacts: 163 | name: lazymc-aarch64-linux-gnu 164 | paths: 165 | - lazymc-$TARGET 166 | expire_in: 1 month 167 | 168 | # Build using Rust stable on Windows 169 | build-x86_64-windows: 170 | stage: build 171 | tags: 172 | - windows 173 | only: 174 | - master 175 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 176 | needs: [] 177 | variables: 178 | TARGET: x86_64-pc-windows-msvc 179 | <<: *before_script-windows 180 | script: 181 | - cargo build --target=$TARGET --release --locked --verbose 182 | - mv target\$env:TARGET\release\lazymc.exe .\lazymc-$env:TARGET.exe 183 | artifacts: 184 | name: lazymc-x86_64-windows 185 | paths: 186 | - lazymc-$TARGET.exe 187 | expire_in: 1 month 188 | 189 | # Run the unit tests through Cargo on Linux 190 | test-cargo-x86_64-linux-gnu: 191 | stage: test 192 | only: 193 | - master 194 | needs: [] 195 | dependencies: [] 196 | script: 197 | - cargo test --locked --verbose 198 | - cargo test --locked --no-default-features --verbose 199 | - cargo test --locked --no-default-features --features rcon --verbose 200 | - cargo test --locked --no-default-features --features lobby --verbose 201 | 202 | # # Run the unit tests through Cargo on Windows 203 | # test-cargo-x86_64-windows: 204 | # stage: test 205 | # tags: 206 | # - windows 207 | # needs: [] 208 | # dependencies: [] 209 | # <<: *before_script-windows 210 | # script: 211 | # - cargo test --locked --verbose 212 | # - cargo test --locked --no-default-features --features rcon --verbose 213 | # - cargo test --locked --no-default-features --features rcon,lobby --verbose 214 | 215 | # Release binaries on GitLab as generic package 216 | release-gitlab-generic-package: 217 | image: curlimages/curl 218 | stage: pre-release 219 | dependencies: 220 | - build-x86_64-linux-gnu 221 | - build-x86_64-linux-musl 222 | - build-armv7-linux-gnu 223 | - build-aarch64-linux-gnu 224 | - build-x86_64-windows 225 | only: 226 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 227 | variables: 228 | LINUX_GNU_BIN: "lazymc-x86_64-unknown-linux-gnu" 229 | LINUX_MUSL_BIN: "lazymc-x86_64-unknown-linux-musl" 230 | LINUX_ARMV7_GNU_BIN: "lazymc-armv7-unknown-linux-gnueabihf" 231 | LINUX_AARCH64_GNU_BIN: "lazymc-aarch64-unknown-linux-gnu" 232 | WINDOWS_BIN: "lazymc-x86_64-pc-windows-msvc.exe" 233 | before_script: [] 234 | script: 235 | # Get version based on tag, determine registry URL 236 | - VERSION=$(echo $CI_COMMIT_REF_NAME | cut -c 2-) 237 | - PACKAGE_REGISTRY_URL="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/lazymc/${VERSION}" 238 | 239 | # Publish packages 240 | - | 241 | curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_GNU_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_GNU_BIN} 242 | - | 243 | curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_MUSL_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_MUSL_BIN} 244 | - | 245 | curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_ARMV7_GNU_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_ARMV7_GNU_BIN} 246 | - | 247 | curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_AARCH64_GNU_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_AARCH64_GNU_BIN} 248 | - | 249 | curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${WINDOWS_BIN} ${PACKAGE_REGISTRY_URL}/${WINDOWS_BIN} 250 | 251 | # Publish GitLab release 252 | release-gitlab-release: 253 | image: registry.gitlab.com/gitlab-org/release-cli 254 | stage: release 255 | only: 256 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 257 | variables: 258 | LINUX_GNU_BIN: "lazymc-x86_64-unknown-linux-gnu" 259 | LINUX_MUSL_BIN: "lazymc-x86_64-unknown-linux-musl" 260 | LINUX_ARMV7_GNU_BIN: "lazymc-armv7-unknown-linux-gnueabihf" 261 | LINUX_AARCH64_GNU_BIN: "lazymc-aarch64-unknown-linux-gnu" 262 | WINDOWS_BIN: "lazymc-x86_64-pc-windows-msvc.exe" 263 | before_script: [] 264 | script: 265 | # Get version based on tag, determine registry URL 266 | - VERSION=$(echo $CI_COMMIT_REF_NAME | cut -c 2-) 267 | - PACKAGE_REGISTRY_URL="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/lazymc/${VERSION}" 268 | 269 | # Publish release 270 | - | 271 | release-cli create --name "lazymc $CI_COMMIT_TAG" --tag-name $CI_COMMIT_TAG \ 272 | --assets-link "{\"name\":\"${LINUX_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_GNU_BIN}\"}" \ 273 | --assets-link "{\"name\":\"${LINUX_MUSL_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_MUSL_BIN}\"}" \ 274 | --assets-link "{\"name\":\"${LINUX_ARMV7_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_ARMV7_GNU_BIN}\"}" \ 275 | --assets-link "{\"name\":\"${LINUX_AARCH64_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_AARCH64_GNU_BIN}\"}" \ 276 | --assets-link "{\"name\":\"${WINDOWS_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${WINDOWS_BIN}\"}" 277 | 278 | # Publish GitHub release 279 | release-github: 280 | stage: release 281 | only: 282 | - /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 283 | dependencies: 284 | - build-x86_64-linux-gnu 285 | - build-x86_64-linux-musl 286 | - build-armv7-linux-gnu 287 | - build-aarch64-linux-gnu 288 | - build-x86_64-windows 289 | before_script: [] 290 | script: 291 | # Install dependencies 292 | - apt-get update 293 | - apt-get install -y curl wget gzip netbase 294 | 295 | # Download github-release binary 296 | - wget https://github.com/tfausak/github-release/releases/download/1.2.5/github-release-linux.gz -O github-release.gz 297 | - gunzip github-release.gz 298 | - chmod a+x ./github-release 299 | 300 | # Create the release, upload binaries 301 | - ./github-release release --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --title "lazymc $CI_COMMIT_REF_NAME" 302 | - ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-x86_64-unknown-linux-gnu --name lazymc-$CI_COMMIT_REF_NAME-linux-x64 303 | - ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-x86_64-unknown-linux-musl --name lazymc-$CI_COMMIT_REF_NAME-linux-x64-static 304 | - ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-armv7-unknown-linux-gnueabihf --name lazymc-$CI_COMMIT_REF_NAME-linux-armv7 305 | - ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-aarch64-unknown-linux-gnu --name lazymc-$CI_COMMIT_REF_NAME-linux-aarch64 306 | - ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-x86_64-pc-windows-msvc.exe --name lazymc-$CI_COMMIT_REF_NAME-windows.exe 307 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.11 (2024-03-16) 4 | 5 | - Add support for Minecraft 1.20.3 and 1.20.4 6 | - Improve error handling of parsing server favicon 7 | - Fix typo in log message 8 | - Update dependencies 9 | 10 | ## 0.2.10 (2023-02-20) 11 | 12 | - Do not report an error when server exits with status code 143 13 | 14 | ## 0.2.9 (2023-02-14) 15 | 16 | - Fix dropping all connections when `server.drop_banned_ips` was enabled 17 | - Update dependencies 18 | 19 | ## 0.2.8 (2023-01-30) 20 | 21 | - Add `freeze_process` feature on Unix platforms to freeze a sleeping server 22 | rather than shutting it down. 23 | - Update default Minecraft version to 1.19.3 24 | - Remove macOS builds from releases, users can compile from source 25 | - Update dependencies 26 | 27 | ## 0.2.7 (2021-12-13) 28 | 29 | - Update default Minecraft version to 1.18.1 30 | - Update dependencies 31 | 32 | ## 0.2.6 (2021-11-28) 33 | 34 | - Add whitelist support, use server whitelist to prevent unknown users from waking server 35 | - Update dependencies 36 | 37 | ## 0.2.5 (2021-11-25) 38 | 39 | - Add support Minecraft 1.16.3 to 1.17.1 with lobby join method 40 | - Add support for Forge client/server to lobby join method (partial) 41 | - Probe server on start with fake user to fetch server settings improving compatibility 42 | - Improve lobby compatibility, send probed server data to client when possible 43 | - Skip lobby join method if server probe is not yet finished 44 | - Generate lobby dimension configuration on the fly based on server dimensions 45 | - Fix unsupported lobby dimension configuration values for some Minecraft versions 46 | - Demote IP ban list reload message from info to debug 47 | - Update dependencies 48 | 49 | ## 0.2.4 (2021-11-24) 50 | 51 | - Fix status response issues with missing server icon, fall back to default icon 52 | - Fix incorrect UUID for players in lobby logic 53 | - Make server directory relative to configuration file path 54 | - Assume SIGTERM exit code for server process to be successful on Unix 55 | - Update features in README 56 | - Update dependencies 57 | 58 | ## 0.2.3 (2021-11-22) 59 | 60 | - Add support for `PROXY` header to notify Minecraft server of real client IP 61 | - Only enable RCON by default on Windows 62 | - Update dependencies 63 | 64 | ## 0.2.2 (2021-11-18) 65 | 66 | - Add server favicon to status response 67 | 68 | ## 0.2.1 (2021-11-17) 69 | 70 | - Add support for using host names in config address fields 71 | - Handle banned players within `lazymc` based on server `banned-ips.json` 72 | - Update dependencies 73 | 74 | ## 0.2.0 (2021-11-15) 75 | 76 | - Add lockout feature, enable to kick all connecting clients with a message 77 | - Add option to configure list of join methods to occupy client with while server is starting (kick, hold, forward, lobby) 78 | - Add lobby join method, keeps client in lobby world on emulated server, teleports to real server when it is ready (highly experimental) 79 | - Add forward join method to forward (proxy) client to other host while server is starting 80 | - Restructure `lazymc.toml` configuration 81 | - Increase packet reading buffer size to speed things up 82 | - Add support for Minecraft packet compression 83 | - Show warning if config version is outdated or invalid 84 | - Various fixes and improvements 85 | 86 | ## 0.1.3 (2021-11-15) 87 | 88 | - Fix binary release 89 | 90 | ## 0.1.2 (2021-11-15) 91 | 92 | - Add Linux ARMv7 and aarch64 releases 93 | - RCON now works if server is running while server command already quit 94 | - Various RCON tweaks in an attempt to make it more robust and reliable (cooldown, exclusive lock, invocation spacing) 95 | - Increase server monitoring timeout to 20 seconds 96 | - Improve waiting for server logic when holding client 97 | - Various fixes and improvements 98 | 99 | ## 0.1.1 (2021-11-14) 100 | 101 | - Make server sleeping errors more descriptive 102 | - Add server quit cooldown period, intended to prevent RCON errors due to RCON 103 | server thread something quitting after main server 104 | - Rewrite `enable-status = true` in `server.properties` 105 | - Rewrite `prevent-proxy-connections = false` in `server.properties` if 106 | Minecraft server has non-loopback address (other public IP) 107 | - Add compile from source instructions to README 108 | - Add Windows instructions to README 109 | - Update dependencies 110 | - Various fixes and improvements 111 | 112 | ## 0.1.0 (2021-11-11) 113 | 114 | - Initial release 115 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lazymc" 3 | version = "0.2.11" 4 | authors = ["Tim Visee <3a4fb3964f@sinenomine.email>"] 5 | license = "GPL-3.0" 6 | readme = "README.md" 7 | homepage = "https://timvisee.com/projects/lazymc" 8 | repository = "https://gitlab.com/timvisee/lazymc" 9 | description = "Put your Minecraft server to rest when idle." 10 | keywords = ["minecraft", "server", "idle", "cli"] 11 | categories = ["command-line-interface", "games"] 12 | exclude = ["/.github", "/contrib"] 13 | edition = "2021" 14 | rust-version = "1.74.0" 15 | 16 | [profile.release] 17 | codegen-units = 1 18 | lto = true 19 | strip = true 20 | 21 | [features] 22 | default = ["rcon", "lobby"] 23 | 24 | # RCON support 25 | # Allow use of RCON to manage (stop) server. 26 | # Required on Windows. 27 | rcon = ["rust_rcon"] 28 | 29 | # Lobby support 30 | # Add lobby join method, keeps client in fake lobby world until server is ready. 31 | lobby = ["md-5", "uuid"] 32 | 33 | [dependencies] 34 | anyhow = "1.0" 35 | base64 = "0.22" 36 | bytes = "1.1" 37 | chrono = "0.4" 38 | clap = { version = "4.0.32", default-features = false, features = [ 39 | "std", 40 | "help", 41 | "suggestions", 42 | "color", 43 | "usage", 44 | "cargo", 45 | "env", 46 | "unicode", 47 | ] } 48 | colored = "2.0" 49 | derive_builder = "0.20" 50 | dotenv = "0.15" 51 | flate2 = { version = "1.0", default-features = false, features = ["default"] } 52 | futures = { version = "0.3", default-features = false, features = ["executor"] } 53 | log = "0.4" 54 | minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "4f93bb3" } 55 | named-binary-tag = "0.6" 56 | nix = { version = "0.28", features = ["process", "signal"] } 57 | notify = "4.0" 58 | pretty_env_logger = "0.5" 59 | proxy-protocol = "0.5" 60 | quartz_nbt = "0.2" 61 | rand = "0.8" 62 | serde = "1.0" 63 | serde_json = "1.0" 64 | shlex = "1.1" 65 | thiserror = "1.0" 66 | tokio = { version = "1", default-features = false, features = [ 67 | "rt-multi-thread", 68 | "io-util", 69 | "net", 70 | "macros", 71 | "time", 72 | "process", 73 | "signal", 74 | "sync", 75 | "fs", 76 | ] } 77 | toml = "0.8" 78 | version-compare = "0.2" 79 | 80 | # Feature: rcon 81 | rust_rcon = { package = "rcon", version = "0.6", default-features = false, features = ["rt-tokio"], optional = true } 82 | 83 | # Feature: lobby 84 | md-5 = { version = "0.10", optional = true } 85 | uuid = { version = "1.7", optional = true, features = ["v3"] } 86 | 87 | [target.'cfg(unix)'.dependencies] 88 | libc = "0.2" 89 | 90 | [target.'cfg(windows)'.dependencies] 91 | winapi = { version = "0.3", features = [ 92 | "winuser", 93 | "processthreadsapi", 94 | "handleapi", 95 | "ntdef", 96 | "minwindef", 97 | ] } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status on GitLab CI][gitlab-ci-master-badge]][gitlab-ci-link] 2 | [![Project license][license-badge]](LICENSE) 3 | 4 | [gitlab-ci-link]: https://gitlab.com/timvisee/lazymc/pipelines 5 | [gitlab-ci-master-badge]: https://gitlab.com/timvisee/lazymc/badges/master/pipeline.svg 6 | [license-badge]: https://img.shields.io/github/license/timvisee/lazymc 7 | 8 | # lazymc 9 | 10 | `lazymc` puts your Minecraft server to rest when idle, and wakes it up when 11 | players connect. 12 | 13 | Some Minecraft servers (especially modded) use an insane amount of resources 14 | when nobody is playing. lazymc helps by stopping your server when idle, until a 15 | player connects again. 16 | 17 | lazymc functions as proxy between clients and the server. It handles all 18 | incoming status connections until the server is started and then transparently 19 | relays/proxies the rest. All without them noticing. 20 | 21 | https://user-images.githubusercontent.com/856222/141378688-882082be-9efa-4cfe-81cc-5a7ab8b8e86b.mp4 22 | 23 | 24 |
Click to see screenshots 25 |

26 | 27 | ![Sleeping server](./res/screenshot/sleeping.png) 28 | ![Join sleeping server](./res/screenshot/join.png) 29 | ![Starting server](./res/screenshot/starting.png) 30 | ![Started server](./res/screenshot/started.png) 31 | 32 |

33 |
34 | 35 | ## Features 36 | 37 | - Very efficient, lightweight & low-profile (~3KB RAM) 38 | - Supports Minecraft Java Edition 1.20.3+ 39 | - Configure joining client occupation methods: 40 | - Hold: hold clients when server starts, relay when ready, without them noticing 41 | - Kick: kick clients when server starts, with a starting message 42 | - Forward: forward client to another IP when server starts 43 | - _Lobby: keep client in emulated server with lobby world, teleport to real server when ready ([experimental*](./docs/join-method-lobby.md))_ 44 | - Customizable MOTD and login messages 45 | - Automatically manages `server.properties` (host, port and RCON settings) 46 | - Automatically block banned IPs from server within lazymc 47 | - Graceful server sleep/shutdown through RCON or `SIGTERM` 48 | - Real client IP on Minecraft server with `PROXY` header ([usage](./docs/proxy-ip.md)) 49 | - Restart server on crash 50 | - Lockout mode 51 | 52 | ## Requirements 53 | 54 | - Linux, macOS or Windows 55 | - Minecraft Java Edition 1.6+ 56 | - On Windows: RCON (automatically managed) 57 | 58 | Build requirements: 59 | 60 | - Rust 1.74 (MSRV) 61 | 62 | _Note: You must have access to the system to run the `lazymc` binary. If you're 63 | using a Minecraft shared hosting provider with a custom dashboard, you likely 64 | won't be able to set this up._ 65 | 66 | ## Usage 67 | 68 | _Note: these instructions are for Linux & macOS, for Windows look 69 | [here](./docs/usage-windows.md)._ 70 | 71 | Make sure you meet all [requirements](#requirements). 72 | 73 | Download the appropriate binary for your system from the [latest 74 | release][latest-release] page. On macOS you must [compile from 75 | source](#compile-from-source). 76 | 77 | Place the binary in your Minecraft server directory, rename it if you like. 78 | Open a terminal, go to the directory, and make sure you can invoke it: 79 | 80 | ```bash 81 | chmod a+x ./lazymc 82 | ./lazymc --help 83 | ``` 84 | 85 | When lazymc is set-up, change into your server directory if you haven't already. 86 | Then set up the [configuration](./res/lazymc.toml) and start it up: 87 | 88 | ```bash 89 | # Change into your server directory (if you haven't already) 90 | cd server 91 | 92 | # Generate lazymc configuration 93 | lazymc config generate 94 | 95 | # Edit configuration 96 | # Set the correct server address, directory and start command 97 | nano lazymc.toml 98 | 99 | # Start lazymc 100 | lazymc start 101 | ``` 102 | 103 | Please see [extras](./docs/extras.md) for recommendations and additional things 104 | to set up (e.g. how to fix incorrect client IPs and IP banning on your server). 105 | 106 | After you've read through the [extras](./docs/extras.md), everything should now 107 | be ready to go! Connect with your Minecraft client to wake your server up! 108 | 109 | _Note: If a binary for your system isn't provided, please [compile from 110 | source](#compile-from-source). Installation options are limited at this moment. More will be added 111 | later._ 112 | 113 | [latest-release]: https://github.com/timvisee/lazymc/releases/latest 114 | 115 | ## Compile from source 116 | 117 | Make sure you meet all [requirements](#requirements). 118 | 119 | To compile from source you need Rust, install it through `rustup`: https://rustup.rs/ 120 | 121 | When Rust is installed, compile and install `lazymc` from this git repository 122 | directly: 123 | 124 | ```bash 125 | # Compile and install lazymc from source 126 | cargo install -f --git https://github.com/timvisee/lazymc 127 | 128 | # Ensure lazymc works 129 | lazymc --help 130 | ``` 131 | 132 | Or clone the repository and build it yourself: 133 | 134 | ```bash 135 | # Clone repository 136 | git clone https://github.com/timvisee/lazymc 137 | cd lazymc 138 | 139 | # Compile 140 | cargo build --release 141 | 142 | # Run lazymc 143 | ./target/release/lazymc --help 144 | ``` 145 | 146 | ## Third-party usage & implementations 147 | 148 | A list of third-party implementations, projects using lazymc, that you might 149 | find useful: 150 | 151 | - Docker: [crbanman/papermc-lazymc](https://hub.docker.com/r/crbanman/papermc-lazymc) _(PaperMC with lazymc in Docker)_ 152 | 153 | ## License 154 | 155 | This project is released under the GNU GPL-3.0 license. 156 | Check out the [LICENSE](LICENSE) file for more information. 157 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Better organize code 4 | - Resolve TODOs in code 5 | - Don't drop errors, handle everywhere where needed (some were dropped while 6 | prototyping to speed up development) 7 | 8 | ## Nice to have 9 | 10 | - Use server whitelist/blacklist 11 | - Console error if server already started on port, not through `lazymc` 12 | - Kick with message if proxy-to-server connection fails for new client. 13 | - Test configuration on start (server dir exists, command not empty) 14 | - Dynamically increase/decrease server polling interval based on server state 15 | - Server polling through query (`enable-query` in `server.properties`, uses GameSpy4 protocol) 16 | 17 | ## Experiment 18 | 19 | - `io_uring` on Linux for efficient proxying (see `tokio-uring`) 20 | 21 | ## Lobby join method 22 | 23 | - add support for more Minecraft versions (with changed protocols) 24 | - support online mode (encryption) 25 | - hold back packets (whitelist), forward to server at connect before joining 26 | - add support for forge (emulate mod list communication) 27 | - on login plugin request during login state, respond with empty payload, not supported 28 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // rcon is required on Windows 3 | #[cfg(all(windows, not(feature = "rcon")))] 4 | { 5 | compile_error!("required feature missing on Windows: rcon"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | msrv = "1.64.0" 2 | -------------------------------------------------------------------------------- /docs/command-bash.md: -------------------------------------------------------------------------------- 1 | # Use bash script to start server 2 | 3 | You may use a `bash` script to start your server rather than invoking `java` 4 | directly. This requires some changes though to ensure your server properly shuts 5 | down. 6 | 7 | When lazymc stops your server it sends a [`SIGTERM`][sigterm] signal to the 8 | invoked server process to gracefully shut it down. `bash` ignores this signal by 9 | default and keeps the Minecraft server running. 10 | 11 | You must configure `bash` to [forward][forward-signal] the signal to properly 12 | shutdown the Minecraft server as well. 13 | 14 | [sigterm]: https://en.wikipedia.org/wiki/Signal_(IPC)#SIGTERM 15 | [forward-signal]: https://unix.stackexchange.com/a/434269/61092 16 | 17 | ## Example 18 | 19 | Here's a minimal example, trapping the signal and forwarding it to the server. 20 | Be sure to set the correct server JAR file and appropriate memory limits. 21 | 22 | [`start-server`](../res/start-server): 23 | 24 | ```bash 25 | #!/bin/bash 26 | 27 | # Server JAR file, set this to your own 28 | FILE=server.jar 29 | 30 | # Trap SIGTERM, forward it to server process ID 31 | trap 'kill -TERM $PID' TERM INT 32 | 33 | # Start server 34 | java -Xms1G -Xmx1G -jar $FILE --nogui & 35 | 36 | # Remember server process ID, wait for it to quit, then reset the trap 37 | PID=$! 38 | wait $PID 39 | trap - TERM INT 40 | wait $PID 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/command_bash.md: -------------------------------------------------------------------------------- 1 | command-bash.md -------------------------------------------------------------------------------- /docs/extras.md: -------------------------------------------------------------------------------- 1 | # Extras 2 | 3 | Some extra steps and recommendations when using lazymc: 4 | 5 | Before you use this in production, always ensure starting and stopping the 6 | server works as expected by connecting to it once. Watch lazymc's output while 7 | it starts and stops. If stopping results in errors, fix this first to prevent 8 | corrupting world/user data. 9 | 10 | Follow this repository with the _Watch_ button on the top right to be notified 11 | of new releases. 12 | 13 | ## Recommended 14 | 15 | - [Protocol version](./protocol-version.md): 16 | _set correct Minecraft protocol version for the best client compatability_ 17 | - [Proxy IP](./proxy-ip.md): 18 | _fix incorrect client IPs on Minecraft server, notify server of correct IP with `PROXY` header_ 19 | 20 | ## Tips 21 | 22 | - [bash with start command](./command_bash.md): 23 | _how to properly use a bash script as server start command_ 24 | 25 | ## Experimental features 26 | 27 | - [Join method: lobby](./join-method-lobby.md): 28 | _keep clients in fake lobby world while server starts, teleport to real server when ready_ 29 | -------------------------------------------------------------------------------- /docs/join-method-lobby.md: -------------------------------------------------------------------------------- 1 | # Join method: lobby 2 | 3 | **Note: this is highly experimental, incomplete, and may break your game. See 4 | [warning](#warning).** 5 | 6 | The lobby join method allows you to keep clients in a lobby world while the 7 | server is starting. When the server is ready, the player is _teleported_ to the 8 | real server. 9 | 10 | lazymc emulates a fake server with an empty lobby world. The player is put in 11 | this world, floating in space. A custom message is shown on the client to notify 12 | we're waiting on the server to start. 13 | 14 | ![Lobby screenshot](../res/screenshot/lobby.png) 15 | 16 | ## Warning 17 | 18 | This feature is highly experimental, incomplete and unstable. This may break the 19 | game and crash clients. Don't use this unless you know what you're doing. Never 20 | enable this in a production environment. 21 | 22 | Current limitations: 23 | 24 | - Server must be in offline mode (`online-mode=false`) 25 | - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1) 26 | - Server must use vanilla Minecraft 27 | - May work with Forge (set `server.forge = true`), depends on used mods, test before use 28 | - Does not work with other mods, such as FTB 29 | - This method will consume the client, following configured join methods won't be used. 30 | 31 | At this time it is unknown if some of the above limitations will ever be lifted, 32 | or if this will ever be implemented in a robust manner. 33 | 34 | ## Usage 35 | 36 | _Note: you must use `lazymc v0.2.0` or above with the `lobby` feature enabled._ 37 | 38 | To try this out, simply add the `"lobby"` method to the `join.methods` list in 39 | your `lazymc.toml` configuration file: 40 | 41 | ```toml 42 | # -- snip -- 43 | 44 | [join] 45 | methods = [ 46 | "lobby", 47 | "kick", 48 | ] 49 | 50 | # -- snip -- 51 | ``` 52 | 53 | Then configure the lobby to your likings: 54 | 55 | ```toml 56 | # -- snip -- 57 | 58 | [join.lobby] 59 | # Lobby occupation method. 60 | # The client joins a fake lobby server with an empty world, floating in space. 61 | # A message is overlayed on screen to notify the server is starting. 62 | # The client will be teleported to the real server once it is ready. 63 | # This may keep the client occupied forever if no timeout is set. 64 | # Consumes client, not allowing other join methods afterwards. 65 | # See: https://git.io/JMIi4 66 | 67 | # !!! WARNING !!! 68 | # This is highly experimental, incomplete and unstable. 69 | # This may break the game and crash clients. 70 | # Don't enable this unless you know what you're doing. 71 | # 72 | # - Server must be in offline mode 73 | # - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1) 74 | # - Server must use vanilla Minecraft 75 | # - May work with Forge, enable in config, depends on used mods, test before use 76 | # - Does not work with other mods, such as FTB 77 | 78 | # Maximum time in seconds in the lobby while the server starts. 79 | timeout = 600 80 | 81 | # Message banner in lobby shown to client. 82 | message = "§2Server is starting\n§7⌛ Please wait..." 83 | 84 | # Sound effect to play when server is ready. 85 | ready_sound = "block.note_block.chime" 86 | 87 | # -- snip -- 88 | 89 | ``` 90 | 91 | _Note: this might have changed, see the latest configuration 92 | [here](../res/lazymc.toml)._ 93 | 94 | ## Probe issue with whitelist 95 | 96 | lazymc may report a _probe_ error on first start when a whitelist is enabled 97 | on your server. 98 | 99 | lazymc uses a probe to fetch some required details from your Minecraft 100 | server, such as a mod list. When probing, the server is started once when lazymc 101 | starts. It then connects to the Minecraft server with the _probe_ user 102 | (username: `_lazymc_probe`) and disconnects when everything needed is fetched. 103 | 104 | If you use a whitelist on your server it will cause issues if the probe user 105 | isn't whitelisted. Simply whitelist the probe user with the following command 106 | and restart lazymc to fix the issue: 107 | 108 | ``` 109 | /whitelist add _lazymc_probe 110 | ``` 111 | 112 | Probing isn't enabled by default. You may enable this by setting 113 | `server.probe_on_start = true`. Other configuration settings might 114 | automatically enable proving if required for your setup. 115 | -------------------------------------------------------------------------------- /docs/protocol-version.md: -------------------------------------------------------------------------------- 1 | # Protocol version 2 | 3 | The Minecraft protocol uses a version number to distinguish between different 4 | protocol versions. Each new Minecraft version having a change in its protocol 5 | gets a new protocol version. 6 | 7 | ## List of versions 8 | 9 | - https://wiki.vg/Protocol_version_numbers#Versions_after_the_Netty_rewrite 10 | 11 | ## Configuration 12 | 13 | In lazymc you may configure what protocol version to use: 14 | 15 | [`lazymc.toml`](../res/lazymc.toml): 16 | 17 | ```bash 18 | # -- snip -- 19 | 20 | [public] 21 | # Server version & protocol hint. 22 | # Sent to clients until actual server version is known. 23 | # See: https://git.io/J1Fvx 24 | version = "1.19.3" 25 | protocol = 761 26 | 27 | # -- snip -- 28 | ``` 29 | 30 | It is highly recommended to set these to match that of your server version to 31 | allow the best compatibility with clients. 32 | 33 | - Set `public.protocol` to the number matching that of your server version 34 | (see [this](#list-of-versions) list) 35 | - Set `public.version` to any string you like. Shows up in read in clients that 36 | have an incompatibel protocol version number 37 | 38 | These are used as hint. lazymc will automatically use the protocol version of 39 | your Minecraft server once it has started at least once. 40 | -------------------------------------------------------------------------------- /docs/proxy-ip.md: -------------------------------------------------------------------------------- 1 | # Proxy IP 2 | 3 | lazymc acts as a proxy most of the time. Because of this the Minecraft server 4 | will think all clients connect from the same IP, being the IP lazymc proxies 5 | from. 6 | 7 | This breaks IP banning (`/ban-ip`, amongst other IP related things). This may be 8 | a problematic issue for your server. 9 | 10 | Luckily, this can be fixed with the [proxy header](#proxy-header). lazymc has 11 | support for this, and can be used with a companion plugin on your server. 12 | 13 | ## Proxy header 14 | 15 | The `PROXY` header may be used to notify the Minecraft server of the real client 16 | IP. 17 | 18 | When a new connection is opened to the Minecraft server, the Minecraft server 19 | will read the `PROXY` header with client-IP information. Once read, it will set 20 | the correct client IP internally and will resume communicating with the client 21 | normally. 22 | 23 | To enable this with lazymc you must do two things: 24 | - [Modify the lazymc configuration](#configuration) 25 | - [Install a companion plugin](#server-plugin) 26 | 27 | ## Configuration 28 | 29 | To use the `PROXY` header with your Minecraft server, set `server.send_proxy_v2` 30 | to `true`. 31 | 32 | [`lazymc.toml`](../res/lazymc.toml): 33 | 34 | ```toml 35 | # -- snip -- 36 | 37 | [server] 38 | send_proxy_v2 = true 39 | 40 | # -- snip -- 41 | ``` 42 | 43 | Other related properties, you probably won't need to touch, include: 44 | 45 | - `server.send_proxy_v2`: set to `true` to enable `PROXY` header for Minecraft server 46 | - `join.forward.send_proxy_v2`: set to `true` to enable `PROXY` header forwarded server, if `forward` join method is used 47 | - `rcon.send_proxy_v2`: set to `true` to enable `PROXY` header for RCON connections for Minecraft server 48 | 49 | ## Server plugin 50 | 51 | Install one of these plugins as companion on your server to enable support for 52 | the `PROXY` header. This requires Minecraft server software supporting plugins, 53 | the vanilla Minecraft server does not support this. 54 | 55 | If lazymc connects to a Spigot compatible server, use any of: 56 | 57 | - https://github.com/riku6460/SpigotProxy ([JAR](https://github.com/riku6460/SpigotProxy/releases/latest)) 58 | - https://github.com/timvisee/spigot-proxy 59 | 60 | If lazymc connects to a BungeeCord server, use any of: 61 | 62 | - https://github.com/MinelinkNetwork/BungeeProxy 63 | 64 | ## Warning: connection failures 65 | 66 | Use of the `PROXY` header must be enabled or disabled on both lazymc and your 67 | Minecraft server using a companion plugin. 68 | 69 | If either of the two is missing or misconfigured, it will result in connection 70 | failures due to a missing or unrecognized header. 71 | 72 | ## Warning: fake IP 73 | 74 | When enabling the `PROXY` header on your Minecraft server, malicious parties may 75 | send this header to fake their real IP. 76 | 77 | To solve this, make sure the Minecraft server is only publicly reachable through 78 | lazymc. This can be done by setting the Minecraft server IP to a local address 79 | only, or by setting up firewall rules. 80 | -------------------------------------------------------------------------------- /docs/usage-windows.md: -------------------------------------------------------------------------------- 1 | ## Usage on Windows 2 | 3 | Make sure you meet all [requirements](../README.md#requirements). 4 | 5 | Download the `lazymc-*-windows.exe` Windows executable for your system from the 6 | [latest release][latest-release] page. 7 | 8 | Place the binary in your Minecraft server directory, and rename it to 9 | `lazymc.exe`. 10 | 11 | Open a terminal, go to the server directory, and make sure you can execute it: 12 | 13 | ```bash 14 | .\lazymc --help 15 | ``` 16 | 17 | When lazymc is ready, set up the [configuration](../res/lazymc.toml) and start it 18 | up: 19 | 20 | ```bash 21 | # In your Minecraft server directory: 22 | 23 | # Generate lazymc configuration 24 | .\lazymc config generate 25 | 26 | # Edit configuration 27 | # Set the correct server address, directory and start command 28 | notepad lazymc.toml 29 | 30 | # Start lazymc 31 | .\lazymc start 32 | ``` 33 | 34 | Please see [extras](./extras.md) for recommendations and additional things 35 | to set up (e.g. how to fix incorrect client IPs and IP banning on your server). 36 | 37 | After you've read through the [extras](./extras.md), everything should now 38 | be ready to go! Connect with your Minecraft client to wake your server up! 39 | 40 | _Note: if you put `lazymc` in `PATH`, or if you 41 | [install](../README.md#compile-from-source) it through Cargo, you can invoke 42 | `lazymc` everywhere directly without the `.\` prefix._ 43 | 44 | [latest-release]: https://github.com/timvisee/lazymc/releases/latest 45 | -------------------------------------------------------------------------------- /res/dimension.snbt: -------------------------------------------------------------------------------- 1 | { 2 | piglin_safe: 1b, 3 | natural: 0b, 4 | ambient_light: 0.0f, 5 | fixed_time: 0, 6 | infiniburn: "minecraft:infiniburn_overworld", 7 | respawn_anchor_works: 0b, 8 | has_skylight: 1b, 9 | bed_works: 0b, 10 | effects: "minecraft:the_end", 11 | has_raids: 0b, 12 | min_y: 0, 13 | height: 256, 14 | logical_height: 256, 15 | coordinate_scale: 1.0d, 16 | ultrawarm: 0b, 17 | has_ceiling: 0b 18 | } 19 | -------------------------------------------------------------------------------- /res/lazymc.toml: -------------------------------------------------------------------------------- 1 | # lazymc configuration 2 | # 3 | # You must configure your server directory and start command, see: 4 | # - server.directory 5 | # - server.command 6 | # 7 | # All defaults are commented out, change it if you desire. 8 | # You can probably leave the rest as-is. 9 | # 10 | # You may generate a new configuration with: lazymc config generate 11 | # Or find the latest at: https://git.io/J1Fvq 12 | 13 | [public] 14 | # Public address. IP and port users connect to. 15 | # Shows sleeping status, starts server on connect, and proxies to server. 16 | #address = "0.0.0.0:25565" 17 | 18 | # Server version & protocol hint. 19 | # Sent to clients until actual server version is known. 20 | # See: https://git.io/J1Fvx 21 | #version = "1.20.3" 22 | #protocol = 765 23 | 24 | [server] 25 | # Server address. Internal IP and port of server started by lazymc to proxy to. 26 | # Port must be different from public port. 27 | #address = "127.0.0.1:25566" 28 | 29 | # Server directory, defaults to current directory. 30 | directory = "." 31 | 32 | # Command to start the server. 33 | # Warning: if using a bash script read: https://git.io/JMIKH 34 | command = "java -Xmx1G -Xms1G -jar server.jar --nogui" 35 | 36 | # Freeze the server process instead of restarting it when no players online, making it resume faster. 37 | # Only works on Unix (Linux or MacOS), ignored on Windows 38 | #freeze_process = true 39 | 40 | # Immediately wake server when starting lazymc. 41 | #wake_on_start = false 42 | 43 | # Immediately wake server after crash. 44 | #wake_on_crash = false 45 | 46 | # Probe required server details when starting lazymc, wakes server on start. 47 | # Improves client compatibility. Automatically enabled if required by other config properties. 48 | #probe_on_start = false 49 | 50 | # Set to true if this server runs Forge. 51 | #forge = false 52 | 53 | # Server start/stop timeout in seconds. Force kill server process if it takes too long. 54 | #start_timeout = 300 55 | #stop_timeout = 150 56 | 57 | # To wake server, user must be in server whitelist if enabled on server. 58 | #wake_whitelist = true 59 | 60 | # Block banned IPs as listed in banned-ips.json in server directory. 61 | #block_banned_ips = true 62 | 63 | # Drop connections from banned IPs. 64 | # Banned IPs won't be able to ping or request server status. 65 | # On connect, clients show a 'Disconnected' message rather than the ban reason. 66 | #drop_banned_ips = false 67 | 68 | # Add HAProxy v2 header to proxied connections. 69 | # See: https://git.io/J1bYb 70 | #send_proxy_v2 = false 71 | 72 | [time] 73 | # Sleep after number of seconds. 74 | #sleep_after = 60 75 | 76 | # Minimum time in seconds to stay online when server is started. 77 | #minimum_online_time = 60 78 | 79 | [motd] 80 | # MOTD, shown in server browser. 81 | #sleeping = "☠ Server is sleeping\n§2☻ Join to start it up" 82 | #starting = "§2☻ Server is starting...\n§7⌛ Please wait..." 83 | #stopping = "☠ Server going to sleep...\n⌛ Please wait..." 84 | 85 | # Use MOTD from Minecraft server once known. 86 | #from_server = false 87 | 88 | [join] 89 | # Methods to use to occupy a client on join while the server is starting. 90 | # Read about all methods and configure them below. 91 | # Methods are used in order, if none is set, the client disconnects without a message. 92 | #methods = [ 93 | # "hold", 94 | # "kick", 95 | #] 96 | 97 | [join.kick] 98 | # Kick occupation method. 99 | # Instantly kicks a client with a message. 100 | 101 | # Message shown when client is kicked while server is starting/stopping. 102 | #starting = "Server is starting... §c♥§r\n\nThis may take some time.\n\nPlease try to reconnect in a minute." 103 | #stopping = "Server is going to sleep... §7☠§r\n\nPlease try to reconnect in a minute to wake it again." 104 | 105 | [join.hold] 106 | # Hold occupation method. 107 | # Holds back a joining client while the server is started until it is ready. 108 | # 'Connecting the server...' is shown on the client while it's held back. 109 | # If the server starts fast enough, the client won't notice it was sleeping at all. 110 | # This works for a limited time of 30 seconds, after which the Minecraft client times out. 111 | 112 | # Hold client for number of seconds on connect while server starts. 113 | # Keep below Minecraft timeout of 30 seconds. 114 | #timeout = 25 115 | 116 | [join.forward] 117 | # Forward occupation method. 118 | # Instantly forwards (proxies) the client to a different address. 119 | # You may need to configure target server for it, such as allowing proxies. 120 | # Consumes client, not allowing other join methods afterwards. 121 | 122 | # IP and port to forward to. 123 | # The target server will receive original client handshake and login request as received by lazymc. 124 | #address = "127.0.0.1:25565" 125 | 126 | # Add HAProxy v2 header to forwarded connections. 127 | # See: https://git.io/J1bYb 128 | #send_proxy_v2 = false 129 | 130 | [join.lobby] 131 | # Lobby occupation method. 132 | # The client joins a fake lobby server with an empty world, floating in space. 133 | # A message is overlayed on screen to notify the server is starting. 134 | # The client will be teleported to the real server once it is ready. 135 | # This may keep the client occupied forever if no timeout is set. 136 | # Consumes client, not allowing other join methods afterwards. 137 | # See: https://git.io/JMIi4 138 | 139 | # !!! WARNING !!! 140 | # This is highly experimental, incomplete and unstable. 141 | # This may break the game and crash clients. 142 | # Don't enable this unless you know what you're doing. 143 | # 144 | # - Server must be in offline mode 145 | # - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1) 146 | # - Server must use vanilla Minecraft 147 | # - May work with Forge, enable in config, depends on used mods, test before use 148 | # - Does not work with other mods, such as FTB 149 | 150 | # Maximum time in seconds in the lobby while the server starts. 151 | #timeout = 600 152 | 153 | # Message banner in lobby shown to client. 154 | #message = "§2Server is starting\n§7⌛ Please wait..." 155 | 156 | # Sound effect to play when server is ready. 157 | #ready_sound = "block.note_block.chime" 158 | 159 | [lockout] 160 | # Enable to prevent everybody from connecting through lazymc. Instantly kicks player. 161 | #enabled = false 162 | 163 | # Kick players with following message. 164 | #message = "Server is closed §7☠§r\n\nPlease try to reconnect in a minute." 165 | 166 | [rcon] 167 | # Enable sleeping server through RCON. 168 | # Must be enabled on Windows. 169 | #enabled = false # default: false, true on Windows 170 | 171 | # Server RCON port. Must differ from public and server port. 172 | #port = 25575 173 | 174 | # Server RCON password. 175 | # Or whether to randomize password each start (recommended). 176 | #password = "" 177 | #randomize_password = true 178 | 179 | # Add HAProxy v2 header to RCON connections. 180 | # See: https://git.io/J1bYb 181 | #send_proxy_v2 = false 182 | 183 | [advanced] 184 | # Automatically update values in Minecraft server.properties file as required. 185 | #rewrite_server_properties = true 186 | 187 | [config] 188 | # lazymc version this configuration is for. 189 | # Don't change unless you know what you're doing. 190 | version = "0.2.11" 191 | -------------------------------------------------------------------------------- /res/screenshot/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/demo.mp4 -------------------------------------------------------------------------------- /res/screenshot/join.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/join.png -------------------------------------------------------------------------------- /res/screenshot/lobby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/lobby.png -------------------------------------------------------------------------------- /res/screenshot/sleeping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/sleeping.png -------------------------------------------------------------------------------- /res/screenshot/started.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/started.png -------------------------------------------------------------------------------- /res/screenshot/starting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/screenshot/starting.png -------------------------------------------------------------------------------- /res/start-server: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # See: https://git.io/JMIKH 4 | 5 | # Server JAR file, set this to your own 6 | FILE=server.jar 7 | 8 | # Trap SIGTERM, forward it to server process ID 9 | trap 'kill -TERM $PID' TERM INT 10 | 11 | # Start server 12 | java -Xms1G -Xmx1G -jar $FILE --nogui & 13 | 14 | # Remember server process ID, wait for it to quit, then reset the trap 15 | PID=$! 16 | wait $PID 17 | trap - TERM INT 18 | wait $PID 19 | -------------------------------------------------------------------------------- /res/unknown_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/unknown_server.png -------------------------------------------------------------------------------- /res/unknown_server_optimized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timvisee/lazymc/d058164aa6012b216eaae28e5581a6130dfeb7e6/res/unknown_server_optimized.png -------------------------------------------------------------------------------- /src/action/config_generate.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | 4 | use clap::ArgMatches; 5 | 6 | use crate::util::cli::prompt_yes; 7 | use crate::util::error::{quit, quit_error, ErrorHintsBuilder}; 8 | 9 | /// Invoke config test command. 10 | pub fn invoke(matches: &ArgMatches) { 11 | // Get config path, attempt to canonicalize 12 | let mut path = PathBuf::from(matches.get_one::("config").unwrap()); 13 | if let Ok(p) = path.canonicalize() { 14 | path = p; 15 | } 16 | 17 | // Confirm to overwrite if it exists 18 | if path.is_file() 19 | && !prompt_yes( 20 | &format!( 21 | "Config file already exists, overwrite?\nPath: {}", 22 | path.to_str().unwrap_or("?") 23 | ), 24 | Some(true), 25 | ) 26 | { 27 | quit(); 28 | } 29 | 30 | // Generate file 31 | if let Err(err) = fs::write(&path, include_bytes!("../../res/lazymc.toml")) { 32 | quit_error( 33 | anyhow!(err).context("Failed to generate config file"), 34 | ErrorHintsBuilder::default().build().unwrap(), 35 | ); 36 | } 37 | 38 | eprintln!("Config saved at: {}", path.to_str().unwrap_or("?")); 39 | } 40 | -------------------------------------------------------------------------------- /src/action/config_test.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::ArgMatches; 4 | 5 | use crate::config::Config; 6 | use crate::util::error::{quit_error, quit_error_msg, ErrorHintsBuilder}; 7 | 8 | /// Invoke config test command. 9 | pub fn invoke(matches: &ArgMatches) { 10 | // Get config path, attempt to canonicalize 11 | let mut path = PathBuf::from(matches.get_one::("config").unwrap()); 12 | if let Ok(p) = path.canonicalize() { 13 | path = p; 14 | } 15 | 16 | // Ensure it exists 17 | if !path.is_file() { 18 | quit_error_msg( 19 | format!("Config file does not exist at: {}", path.to_str().unwrap()), 20 | ErrorHintsBuilder::default().build().unwrap(), 21 | ); 22 | } 23 | 24 | // Try to load config 25 | let _config = match Config::load(path) { 26 | Ok(config) => config, 27 | Err(err) => { 28 | quit_error( 29 | anyhow!(err).context("Failed to load and parse config"), 30 | ErrorHintsBuilder::default().build().unwrap(), 31 | ); 32 | } 33 | }; 34 | 35 | // TODO: do additional config tests: server dir correct, command set? 36 | 37 | eprintln!("Config loaded successfully!"); 38 | } 39 | -------------------------------------------------------------------------------- /src/action/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config_generate; 2 | pub mod config_test; 3 | pub mod start; 4 | -------------------------------------------------------------------------------- /src/action/start.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::Arc; 3 | 4 | use clap::ArgMatches; 5 | 6 | use crate::config::{self, Config, Server as ConfigServer}; 7 | use crate::mc::server_properties; 8 | use crate::proto; 9 | use crate::service; 10 | 11 | /// RCON randomized password length. 12 | #[cfg(feature = "rcon")] 13 | const RCON_PASSWORD_LENGTH: usize = 32; 14 | 15 | /// Start lazymc. 16 | pub fn invoke(matches: &ArgMatches) -> Result<(), ()> { 17 | // Load config 18 | #[allow(unused_mut)] 19 | let mut config = config::load(matches); 20 | 21 | // Prepare RCON if enabled 22 | #[cfg(feature = "rcon")] 23 | prepare_rcon(&mut config); 24 | 25 | // Rewrite server server.properties file 26 | rewrite_server_properties(&config); 27 | 28 | // Start server service 29 | let config = Arc::new(config); 30 | service::server::service(config) 31 | } 32 | 33 | /// Prepare RCON. 34 | #[cfg(feature = "rcon")] 35 | fn prepare_rcon(config: &mut Config) { 36 | use crate::util::error::{quit_error_msg, ErrorHintsBuilder}; 37 | 38 | // On Windows, this must be enabled 39 | if cfg!(windows) && !config.rcon.enabled { 40 | quit_error_msg( 41 | "RCON must be enabled on Windows", 42 | ErrorHintsBuilder::default() 43 | .add_info("change 'rcon.enabled' to 'true' in the config file".into()) 44 | .build() 45 | .unwrap(), 46 | ); 47 | } 48 | 49 | // Skip if not enabled 50 | if !config.rcon.enabled { 51 | return; 52 | } 53 | 54 | // Must configure RCON password with no randomization 55 | if config.server.address.port() == config.rcon.port { 56 | quit_error_msg( 57 | "RCON port cannot be the same as the server", 58 | ErrorHintsBuilder::default() 59 | .add_info("change 'rcon.port' in the config file".into()) 60 | .build() 61 | .unwrap(), 62 | ); 63 | } 64 | 65 | // Must configure RCON password with no randomization 66 | if config.rcon.password.trim().is_empty() && !config.rcon.randomize_password { 67 | quit_error_msg( 68 | "RCON password can't be empty, or enable randomization", 69 | ErrorHintsBuilder::default() 70 | .add_info("change 'rcon.randomize_password' to 'true' in the config file".into()) 71 | .add_info("or change 'rcon.password' in the config file".into()) 72 | .build() 73 | .unwrap(), 74 | ); 75 | } 76 | 77 | // RCON password randomization 78 | if config.rcon.randomize_password { 79 | // Must enable server.properties rewrite 80 | if !config.advanced.rewrite_server_properties { 81 | quit_error_msg( 82 | format!( 83 | "You must enable {} rewrite to use RCON password randomization", 84 | server_properties::FILE 85 | ), 86 | ErrorHintsBuilder::default() 87 | .add_info( 88 | "change 'advanced.rewrite_server_properties' to 'true' in the config file" 89 | .into(), 90 | ) 91 | .build() 92 | .unwrap(), 93 | ); 94 | } 95 | 96 | // Randomize password 97 | config.rcon.password = generate_random_password(); 98 | } 99 | } 100 | 101 | /// Generate secure random password. 102 | #[cfg(feature = "rcon")] 103 | fn generate_random_password() -> String { 104 | use rand::{distributions::Alphanumeric, Rng}; 105 | use std::iter; 106 | 107 | let mut rng = rand::thread_rng(); 108 | iter::repeat(()) 109 | .map(|()| rng.sample(Alphanumeric)) 110 | .map(char::from) 111 | .take(RCON_PASSWORD_LENGTH) 112 | .collect() 113 | } 114 | 115 | /// Rewrite server server.properties file with correct internal IP and port. 116 | fn rewrite_server_properties(config: &Config) { 117 | // Rewrite must be enabled 118 | if !config.advanced.rewrite_server_properties { 119 | return; 120 | } 121 | 122 | // Ensure server directory is set, it must exist 123 | let dir = match ConfigServer::server_directory(config) { 124 | Some(dir) => dir, 125 | None => { 126 | warn!(target: "lazymc", "Not rewriting {} file, server directory not configured (server.directory)", server_properties::FILE); 127 | return; 128 | } 129 | }; 130 | 131 | // Build list of changes 132 | #[allow(unused_mut)] 133 | let mut changes = HashMap::from([ 134 | ("server-ip", config.server.address.ip().to_string()), 135 | ("server-port", config.server.address.port().to_string()), 136 | ("enable-status", "true".into()), 137 | ("query.port", config.server.address.port().to_string()), 138 | ]); 139 | 140 | // If connecting to server over non-loopback address, disable proxy blocking 141 | if !config.server.address.ip().is_loopback() { 142 | changes.extend([("prevent-proxy-connections", "false".into())]); 143 | } 144 | 145 | // Update network compression threshold for lobby mode 146 | if config.join.methods.contains(&config::Method::Lobby) { 147 | changes.extend([( 148 | "network-compression-threshold", 149 | proto::COMPRESSION_THRESHOLD.to_string(), 150 | )]); 151 | } 152 | 153 | // Add RCON configuration 154 | #[cfg(feature = "rcon")] 155 | if config.rcon.enabled { 156 | changes.extend([ 157 | ("rcon.port", config.rcon.port.to_string()), 158 | ("rcon.password", config.rcon.password.clone()), 159 | ("enable-rcon", "true".into()), 160 | ]); 161 | } 162 | 163 | // Rewrite file 164 | server_properties::rewrite_dir(dir, changes) 165 | } 166 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, Command}; 2 | 3 | /// The clap app for CLI argument parsing. 4 | pub fn app() -> Command { 5 | Command::new(crate_name!()) 6 | .version(crate_version!()) 7 | .author(crate_authors!()) 8 | .about(crate_description!()) 9 | .subcommand( 10 | Command::new("start") 11 | .alias("run") 12 | .about("Start lazymc and server (default)"), 13 | ) 14 | .subcommand( 15 | Command::new("config") 16 | .alias("cfg") 17 | .about("Config actions") 18 | .arg_required_else_help(true) 19 | .subcommand_required(true) 20 | .subcommand( 21 | Command::new("generate") 22 | .alias("gen") 23 | .about("Generate config"), 24 | ) 25 | .subcommand(Command::new("test").about("Test config")), 26 | ) 27 | .arg( 28 | Arg::new("config") 29 | .short('c') 30 | .alias("cfg") 31 | .long("config") 32 | .global(true) 33 | .value_name("FILE") 34 | .default_value(crate::config::CONFIG_FILE) 35 | .help("Use config file") 36 | .num_args(1), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/forge.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "lobby")] 2 | use std::sync::Arc; 3 | #[cfg(feature = "lobby")] 4 | use std::time::Duration; 5 | 6 | #[cfg(feature = "lobby")] 7 | use bytes::BytesMut; 8 | use minecraft_protocol::decoder::Decoder; 9 | use minecraft_protocol::encoder::Encoder; 10 | use minecraft_protocol::version::forge_v1_13::login::{Acknowledgement, LoginWrapper, ModList}; 11 | use minecraft_protocol::version::v1_14_4::login::{LoginPluginRequest, LoginPluginResponse}; 12 | use minecraft_protocol::version::PacketId; 13 | #[cfg(feature = "lobby")] 14 | use tokio::io::AsyncWriteExt; 15 | use tokio::net::tcp::WriteHalf; 16 | #[cfg(feature = "lobby")] 17 | use tokio::net::TcpStream; 18 | #[cfg(feature = "lobby")] 19 | use tokio::time; 20 | 21 | use crate::forge; 22 | use crate::proto::client::Client; 23 | #[cfg(feature = "lobby")] 24 | use crate::proto::client::ClientState; 25 | use crate::proto::packet; 26 | use crate::proto::packet::RawPacket; 27 | #[cfg(feature = "lobby")] 28 | use crate::proto::packets; 29 | #[cfg(feature = "lobby")] 30 | use crate::server::Server; 31 | 32 | /// Forge status magic. 33 | pub const STATUS_MAGIC: &str = "\0FML2\0"; 34 | 35 | /// Forge plugin wrapper login plugin request channel. 36 | pub const CHANNEL_LOGIN_WRAPPER: &str = "fml:loginwrapper"; 37 | 38 | /// Forge handshake channel. 39 | pub const CHANNEL_HANDSHAKE: &str = "fml:handshake"; 40 | 41 | /// Timeout for draining Forge plugin responses from client. 42 | #[cfg(feature = "lobby")] 43 | const CLIENT_DRAIN_FORGE_TIMEOUT: Duration = Duration::from_secs(5); 44 | 45 | /// Respond with Forge login wrapper packet. 46 | pub async fn respond_forge_login_packet( 47 | client: &Client, 48 | writer: &mut WriteHalf<'_>, 49 | message_id: i32, 50 | forge_channel: String, 51 | forge_packet: impl PacketId + Encoder, 52 | ) -> Result<(), ()> { 53 | // Encode Forge packet to data 54 | let mut forge_data = Vec::new(); 55 | forge_packet.encode(&mut forge_data).map_err(|_| ())?; 56 | 57 | // Encode Forge payload 58 | let forge_payload = 59 | RawPacket::new(forge_packet.packet_id(), forge_data).encode_without_len(client)?; 60 | 61 | // Wrap Forge payload in login wrapper 62 | let mut payload = Vec::new(); 63 | let packet = LoginWrapper { 64 | channel: forge_channel, 65 | packet: forge_payload, 66 | }; 67 | packet.encode(&mut payload).map_err(|_| ())?; 68 | 69 | // Write login plugin request with forge payload 70 | packet::write_packet( 71 | LoginPluginResponse { 72 | message_id, 73 | successful: true, 74 | data: payload, 75 | }, 76 | client, 77 | writer, 78 | ) 79 | .await 80 | } 81 | 82 | /// Respond to a Forge login plugin request. 83 | pub async fn respond_login_plugin_request( 84 | client: &Client, 85 | packet: LoginPluginRequest, 86 | writer: &mut WriteHalf<'_>, 87 | ) -> Result<(), ()> { 88 | // Decode Forge login wrapper packet 89 | let (message_id, login_wrapper, packet) = 90 | forge::decode_forge_login_packet(client, packet).await?; 91 | 92 | // Determine whether we received the mod list 93 | let is_unknown_header = login_wrapper.channel != forge::CHANNEL_HANDSHAKE; 94 | let is_mod_list = !is_unknown_header && packet.id == ModList::PACKET_ID; 95 | 96 | // If not the mod list, just acknowledge 97 | if !is_mod_list { 98 | trace!(target: "lazymc::forge", "Acknowledging login plugin request"); 99 | forge::respond_forge_login_packet( 100 | client, 101 | writer, 102 | message_id, 103 | login_wrapper.channel, 104 | Acknowledgement {}, 105 | ) 106 | .await 107 | .map_err(|_| { 108 | error!(target: "lazymc::forge", "Failed to send Forge login plugin request acknowledgement"); 109 | })?; 110 | return Ok(()); 111 | } 112 | 113 | trace!(target: "lazymc::forge", "Sending mod list reply to server with same contents"); 114 | 115 | // Parse mod list, transform into reply 116 | let mod_list = ModList::decode(&mut packet.data.as_slice()).map_err(|err| { 117 | error!(target: "lazymc::forge", "Failed to decode Forge mod list: {:?}", err); 118 | })?; 119 | let mod_list_reply = mod_list.into_reply(); 120 | 121 | // We got mod list, respond with reply 122 | forge::respond_forge_login_packet( 123 | client, 124 | writer, 125 | message_id, 126 | login_wrapper.channel, 127 | mod_list_reply, 128 | ) 129 | .await 130 | .map_err(|_| { 131 | error!(target: "lazymc::forge", "Failed to send Forge login plugin mod list reply"); 132 | })?; 133 | 134 | Ok(()) 135 | } 136 | 137 | /// Decode a Forge login wrapper packet from login plugin request. 138 | /// 139 | /// Returns (`message_id`, `login_wrapper`, `packet`). 140 | pub async fn decode_forge_login_packet( 141 | client: &Client, 142 | plugin_request: LoginPluginRequest, 143 | ) -> Result<(i32, LoginWrapper, RawPacket), ()> { 144 | // Validate channel 145 | assert_eq!(plugin_request.channel, CHANNEL_LOGIN_WRAPPER); 146 | 147 | // Decode login wrapped packet 148 | let login_wrapper = 149 | LoginWrapper::decode(&mut plugin_request.data.as_slice()).map_err(|err| { 150 | error!(target: "lazymc::forge", "Failed to decode Forge LoginWrapper packet: {:?}", err); 151 | })?; 152 | 153 | // Parse packet 154 | let packet = RawPacket::decode_without_len(client, &login_wrapper.packet).map_err(|err| { 155 | error!(target: "lazymc::forge", "Failed to decode Forge LoginWrapper packet contents: {:?}", err); 156 | })?; 157 | 158 | Ok((plugin_request.message_id, login_wrapper, packet)) 159 | } 160 | 161 | /// Replay the Forge login payload for a client. 162 | #[cfg(feature = "lobby")] 163 | pub async fn replay_login_payload( 164 | client: &Client, 165 | inbound: &mut TcpStream, 166 | server: Arc, 167 | inbound_buf: &mut BytesMut, 168 | ) -> Result<(), ()> { 169 | debug!(target: "lazymc::lobby", "Replaying Forge login procedure for lobby client..."); 170 | 171 | // Replay each Forge packet 172 | for packet in server.forge_payload.read().await.as_slice() { 173 | inbound.write_all(packet).await.map_err(|err| { 174 | error!(target: "lazymc::lobby", "Failed to send Forge join payload to lobby client, will likely cause issues: {}", err); 175 | })?; 176 | } 177 | 178 | // Drain all responses 179 | let count = server.forge_payload.read().await.len(); 180 | drain_forge_responses(client, inbound, inbound_buf, count).await?; 181 | 182 | trace!(target: "lazymc::lobby", "Forge join payload replayed"); 183 | 184 | Ok(()) 185 | } 186 | 187 | /// Drain Forge login plugin response packets from stream. 188 | #[cfg(feature = "lobby")] 189 | async fn drain_forge_responses( 190 | client: &Client, 191 | inbound: &mut TcpStream, 192 | buf: &mut BytesMut, 193 | mut count: usize, 194 | ) -> Result<(), ()> { 195 | let (mut reader, mut _writer) = inbound.split(); 196 | 197 | loop { 198 | // We're done if count is zero 199 | if count == 0 { 200 | trace!(target: "lazymc::forge", "Drained all plugin responses from client"); 201 | return Ok(()); 202 | } 203 | 204 | // Read packet from stream with timeout 205 | let read_packet_task = packet::read_packet(client, buf, &mut reader); 206 | let timeout = time::timeout(CLIENT_DRAIN_FORGE_TIMEOUT, read_packet_task).await; 207 | let read_packet_task = match timeout { 208 | Ok(result) => result, 209 | Err(_) => { 210 | error!(target: "lazymc::forge", "Expected more plugin responses from client, but didn't receive anything in a while, may be problematic"); 211 | return Ok(()); 212 | } 213 | }; 214 | 215 | // Read packet from stream 216 | let (packet, _raw) = match read_packet_task { 217 | Ok(Some(packet)) => packet, 218 | Ok(None) => break, 219 | Err(_) => { 220 | error!(target: "lazymc::forge", "Closing connection, error occurred"); 221 | break; 222 | } 223 | }; 224 | 225 | // Grab client state 226 | let client_state = client.state(); 227 | 228 | // Catch login plugin resposne 229 | if client_state == ClientState::Login 230 | && packet.id == packets::login::SERVER_LOGIN_PLUGIN_RESPONSE 231 | { 232 | trace!(target: "lazymc::forge", "Voiding plugin response from client"); 233 | count -= 1; 234 | continue; 235 | } 236 | 237 | // TODO: instantly return on this packet? 238 | // // Hijack login success 239 | // if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS { 240 | // trace!(target: "lazymc::forge", "Got login success from server connection, change to play mode"); 241 | 242 | // // Switch to play state 243 | // tmp_client.set_state(ClientState::Play); 244 | 245 | // return Ok(forge_payload); 246 | // } 247 | 248 | // Show unhandled packet warning 249 | debug!(target: "lazymc::forge", "Got unhandled packet from server in record_forge_response:"); 250 | debug!(target: "lazymc::forge", "- State: {:?}", client_state); 251 | debug!(target: "lazymc::forge", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id); 252 | } 253 | 254 | Err(()) 255 | } 256 | -------------------------------------------------------------------------------- /src/join/forward.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bytes::BytesMut; 4 | use tokio::net::TcpStream; 5 | 6 | use crate::config::*; 7 | use crate::proxy::ProxyHeader; 8 | use crate::service; 9 | 10 | use super::MethodResult; 11 | 12 | /// Forward the client. 13 | pub async fn occupy( 14 | config: Arc, 15 | inbound: TcpStream, 16 | inbound_history: &mut BytesMut, 17 | ) -> Result { 18 | trace!(target: "lazymc", "Using forward method to occupy joining client"); 19 | 20 | debug!(target: "lazymc", "Forwarding client to {:?}!", config.join.forward.address); 21 | 22 | service::server::route_proxy_address_queue( 23 | inbound, 24 | ProxyHeader::Proxy.not_none(config.join.forward.send_proxy_v2), 25 | config.join.forward.address, 26 | inbound_history.clone(), 27 | ); 28 | 29 | // TODO: do not consume, continue on proxy connect failure 30 | 31 | Ok(MethodResult::Consumed) 32 | } 33 | -------------------------------------------------------------------------------- /src/join/hold.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use bytes::BytesMut; 6 | use tokio::net::TcpStream; 7 | use tokio::time; 8 | 9 | use crate::config::*; 10 | use crate::server::{Server, State}; 11 | use crate::service; 12 | 13 | use super::MethodResult; 14 | 15 | /// Hold the client. 16 | pub async fn occupy( 17 | config: Arc, 18 | server: Arc, 19 | inbound: TcpStream, 20 | inbound_history: &mut BytesMut, 21 | ) -> Result { 22 | trace!(target: "lazymc", "Using hold method to occupy joining client"); 23 | 24 | // Server must be starting 25 | if server.state() != State::Starting { 26 | return Ok(MethodResult::Continue(inbound)); 27 | } 28 | 29 | // Start holding, consume client 30 | if hold(&config, &server).await? { 31 | service::server::route_proxy_queue(inbound, config, inbound_history.clone()); 32 | return Ok(MethodResult::Consumed); 33 | } 34 | 35 | Ok(MethodResult::Continue(inbound)) 36 | } 37 | 38 | /// Hold a client while server starts. 39 | /// 40 | /// Returns holding status. `true` if client is held and it should be proxied, `false` it was held 41 | /// but it timed out. 42 | async fn hold<'a>(config: &Config, server: &Server) -> Result { 43 | trace!(target: "lazymc", "Started holding client"); 44 | 45 | // A task to wait for suitable server state 46 | // Waits for started state, errors if stopping/stopped state is reached 47 | let task_wait = async { 48 | let mut state = server.state_receiver(); 49 | loop { 50 | // Wait for state change 51 | state.changed().await.unwrap(); 52 | 53 | match state.borrow().deref() { 54 | // Still waiting on server start 55 | State::Starting => { 56 | trace!(target: "lazymc", "Server not ready, holding client for longer"); 57 | continue; 58 | } 59 | 60 | // Server started, start relaying and proxy 61 | State::Started => { 62 | break true; 63 | } 64 | 65 | // Server stopping, this shouldn't happen, kick 66 | State::Stopping => { 67 | warn!(target: "lazymc", "Server stopping for held client, disconnecting"); 68 | break false; 69 | } 70 | 71 | // Server stopped, this shouldn't happen, disconnect 72 | State::Stopped => { 73 | error!(target: "lazymc", "Server stopped for held client, disconnecting"); 74 | break false; 75 | } 76 | } 77 | } 78 | }; 79 | 80 | // Wait for server state with timeout 81 | let timeout = Duration::from_secs(config.join.hold.timeout as u64); 82 | match time::timeout(timeout, task_wait).await { 83 | // Relay client to proxy 84 | Ok(true) => { 85 | info!(target: "lazymc", "Server ready for held client, relaying to server"); 86 | Ok(true) 87 | } 88 | 89 | // Server stopping/stopped, this shouldn't happen, kick 90 | Ok(false) => { 91 | warn!(target: "lazymc", "Server stopping for held client"); 92 | Ok(false) 93 | } 94 | 95 | // Timeout reached, kick with starting message 96 | Err(_) => { 97 | warn!(target: "lazymc", "Held client reached timeout of {}s", config.join.hold.timeout); 98 | Ok(false) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/join/kick.rs: -------------------------------------------------------------------------------- 1 | use tokio::net::TcpStream; 2 | 3 | use crate::config::*; 4 | use crate::net; 5 | use crate::proto::action; 6 | use crate::proto::client::Client; 7 | use crate::server::{self, Server}; 8 | 9 | use super::MethodResult; 10 | 11 | /// Kick the client. 12 | pub async fn occupy( 13 | client: &Client, 14 | config: &Config, 15 | server: &Server, 16 | mut inbound: TcpStream, 17 | ) -> Result { 18 | trace!(target: "lazymc", "Using kick method to occupy joining client"); 19 | 20 | // Select message and kick 21 | let msg = match server.state() { 22 | server::State::Starting | server::State::Stopped | server::State::Started => { 23 | &config.join.kick.starting 24 | } 25 | server::State::Stopping => &config.join.kick.stopping, 26 | }; 27 | action::kick(client, msg, &mut inbound.split().1).await?; 28 | 29 | // Gracefully close connection 30 | net::close_tcp_stream(inbound).await.map_err(|_| ())?; 31 | 32 | Ok(MethodResult::Consumed) 33 | } 34 | -------------------------------------------------------------------------------- /src/join/lobby.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bytes::BytesMut; 4 | use tokio::net::TcpStream; 5 | 6 | use crate::config::*; 7 | use crate::lobby; 8 | use crate::proto::client::{Client, ClientInfo}; 9 | use crate::server::Server; 10 | 11 | use super::MethodResult; 12 | 13 | /// Lobby the client. 14 | pub async fn occupy( 15 | client: &Client, 16 | client_info: ClientInfo, 17 | config: Arc, 18 | server: Arc, 19 | inbound: TcpStream, 20 | inbound_queue: BytesMut, 21 | ) -> Result { 22 | trace!(target: "lazymc", "Using lobby method to occupy joining client"); 23 | 24 | // Must be ready to lobby 25 | if must_still_probe(&config, &server).await { 26 | warn!(target: "lazymc", "Client connected but lobby is not ready, using next join method, probing not completed"); 27 | return Ok(MethodResult::Continue(inbound)); 28 | } 29 | 30 | // Start lobby 31 | lobby::serve(client, client_info, inbound, config, server, inbound_queue).await?; 32 | 33 | // TODO: do not consume client here, allow other join method on fail 34 | 35 | Ok(MethodResult::Consumed) 36 | } 37 | 38 | /// Check whether we still have to probe before we can use the lobby. 39 | async fn must_still_probe(config: &Config, server: &Server) -> bool { 40 | must_probe(config) && server.probed_join_game.read().await.is_none() 41 | } 42 | 43 | /// Check whether we must have probed data. 44 | fn must_probe(config: &Config) -> bool { 45 | config.server.forge 46 | } 47 | -------------------------------------------------------------------------------- /src/join/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bytes::BytesMut; 4 | use tokio::net::TcpStream; 5 | 6 | use crate::config::*; 7 | use crate::net; 8 | use crate::proto::client::{Client, ClientInfo, ClientState}; 9 | use crate::server::Server; 10 | 11 | pub mod forward; 12 | pub mod hold; 13 | pub mod kick; 14 | #[cfg(feature = "lobby")] 15 | pub mod lobby; 16 | 17 | /// A result returned by a join occupy method. 18 | pub enum MethodResult { 19 | /// Client is consumed. 20 | Consumed, 21 | 22 | /// Method is done, continue with the next. 23 | Continue(TcpStream), 24 | } 25 | 26 | /// Start occupying client. 27 | /// 28 | /// This assumes the login start packet has just been received. 29 | pub async fn occupy( 30 | client: Client, 31 | #[allow(unused_variables)] client_info: ClientInfo, 32 | config: Arc, 33 | server: Arc, 34 | mut inbound: TcpStream, 35 | mut inbound_history: BytesMut, 36 | #[allow(unused_variables)] login_queue: BytesMut, 37 | ) -> Result<(), ()> { 38 | // Assert state is correct 39 | assert_eq!( 40 | client.state(), 41 | ClientState::Login, 42 | "when occupying client, it should be in login state" 43 | ); 44 | 45 | // Go through all configured join methods 46 | for method in &config.join.methods { 47 | // Invoke method, take result 48 | let result = match method { 49 | // Kick method, immediately kick client 50 | Method::Kick => kick::occupy(&client, &config, &server, inbound).await?, 51 | 52 | // Hold method, hold client connection while server starts 53 | Method::Hold => { 54 | hold::occupy( 55 | config.clone(), 56 | server.clone(), 57 | inbound, 58 | &mut inbound_history, 59 | ) 60 | .await? 61 | } 62 | 63 | // Forward method, forward client connection while server starts 64 | Method::Forward => { 65 | forward::occupy(config.clone(), inbound, &mut inbound_history).await? 66 | } 67 | 68 | // Lobby method, keep client in lobby while server starts 69 | #[cfg(feature = "lobby")] 70 | Method::Lobby => { 71 | lobby::occupy( 72 | &client, 73 | client_info.clone(), 74 | config.clone(), 75 | server.clone(), 76 | inbound, 77 | login_queue.clone(), 78 | ) 79 | .await? 80 | } 81 | 82 | // Lobby method, keep client in lobby while server starts 83 | #[cfg(not(feature = "lobby"))] 84 | Method::Lobby => { 85 | error!(target: "lazymc", "Lobby join method not supported in this lazymc build"); 86 | MethodResult::Continue(inbound) 87 | } 88 | }; 89 | 90 | // Handle method result 91 | match result { 92 | MethodResult::Consumed => return Ok(()), 93 | MethodResult::Continue(stream) => { 94 | inbound = stream; 95 | continue; 96 | } 97 | } 98 | } 99 | 100 | debug!(target: "lazymc", "No method left to occupy joining client, disconnecting"); 101 | 102 | // Gracefully close connection 103 | net::close_tcp_stream(inbound).await.map_err(|_| ())?; 104 | 105 | Ok(()) 106 | } 107 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate anyhow; 3 | #[macro_use] 4 | extern crate clap; 5 | #[macro_use] 6 | extern crate derive_builder; 7 | #[macro_use] 8 | extern crate log; 9 | 10 | pub(crate) mod action; 11 | pub(crate) mod cli; 12 | pub(crate) mod config; 13 | pub(crate) mod forge; 14 | pub(crate) mod join; 15 | #[cfg(feature = "lobby")] 16 | pub(crate) mod lobby; 17 | pub(crate) mod mc; 18 | pub(crate) mod monitor; 19 | pub(crate) mod net; 20 | pub(crate) mod os; 21 | pub(crate) mod probe; 22 | pub(crate) mod proto; 23 | pub(crate) mod proxy; 24 | pub(crate) mod server; 25 | pub(crate) mod service; 26 | pub(crate) mod status; 27 | pub(crate) mod types; 28 | pub(crate) mod util; 29 | 30 | use std::env; 31 | 32 | use clap::Command; 33 | 34 | // Compile time feature compatability check. 35 | #[cfg(all(windows, not(feature = "rcon")))] 36 | compile_error!("Must enable \"rcon\" feature on Windows."); 37 | 38 | /// Default log level if none is set. 39 | const LOG_DEFAULT: &str = "info"; 40 | 41 | /// Main entrypoint. 42 | fn main() -> Result<(), ()> { 43 | // Initialize logger 44 | init_log(); 45 | 46 | // Build clap app, invoke intended action 47 | let app = cli::app(); 48 | invoke_action(app) 49 | } 50 | 51 | /// Initialize logger. 52 | fn init_log() { 53 | // Load .env variables 54 | let _ = dotenv::dotenv(); 55 | 56 | // Set default log level if none is set 57 | if env::var("RUST_LOG").is_err() { 58 | env::set_var("RUST_LOG", LOG_DEFAULT); 59 | } 60 | 61 | // Initialize logger 62 | pretty_env_logger::init(); 63 | } 64 | 65 | /// Invoke an action. 66 | fn invoke_action(app: Command) -> Result<(), ()> { 67 | let matches = app.get_matches(); 68 | 69 | // Config operations 70 | if let Some(matches) = matches.subcommand_matches("config") { 71 | if let Some(matches) = matches.subcommand_matches("generate") { 72 | action::config_generate::invoke(matches); 73 | return Ok(()); 74 | } 75 | 76 | if let Some(matches) = matches.subcommand_matches("test") { 77 | action::config_test::invoke(matches); 78 | return Ok(()); 79 | } 80 | 81 | unreachable!(); 82 | } 83 | 84 | // Start server 85 | action::start::invoke(&matches) 86 | } 87 | -------------------------------------------------------------------------------- /src/mc/ban.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::error::Error; 3 | use std::fs; 4 | use std::net::IpAddr; 5 | use std::path::Path; 6 | 7 | use chrono::{DateTime, Utc}; 8 | use serde::Deserialize; 9 | 10 | /// File name. 11 | pub const FILE: &str = "banned-ips.json"; 12 | 13 | /// The forever expiry literal. 14 | const EXPIRY_FOREVER: &str = "forever"; 15 | 16 | /// List of banned IPs. 17 | #[derive(Debug, Default)] 18 | pub struct BannedIps { 19 | /// List of banned IPs. 20 | ips: HashMap, 21 | } 22 | 23 | impl BannedIps { 24 | /// Get ban entry if IP if it exists. 25 | /// 26 | /// This uses the latest known `banned-ips.json` contents if known. 27 | /// If this feature is disabled, this will always return false. 28 | pub fn get(&self, ip: &IpAddr) -> Option { 29 | self.ips.get(ip).cloned() 30 | } 31 | 32 | /// Check whether the given IP is banned. 33 | /// 34 | /// This uses the latest known `banned-ips.json` contents if known. 35 | /// If this feature is disabled, this will always return false. 36 | pub fn is_banned(&self, ip: &IpAddr) -> bool { 37 | self.ips.get(ip).map(|ip| ip.is_banned()).unwrap_or(false) 38 | } 39 | } 40 | 41 | /// A banned IP entry. 42 | #[derive(Debug, Deserialize, Clone)] 43 | pub struct BannedIp { 44 | /// Banned IP. 45 | pub ip: IpAddr, 46 | 47 | /// Ban creation time. 48 | pub created: Option, 49 | 50 | /// Ban source. 51 | pub source: Option, 52 | 53 | /// Ban expiry time. 54 | pub expires: Option, 55 | 56 | /// Ban reason. 57 | pub reason: Option, 58 | } 59 | 60 | impl BannedIp { 61 | /// Check if this entry is currently banned. 62 | pub fn is_banned(&self) -> bool { 63 | // Get expiry time 64 | let expires = match &self.expires { 65 | Some(expires) => expires, 66 | None => return true, 67 | }; 68 | 69 | // If expiry is forever, the user is banned 70 | if expires.trim().to_lowercase() == EXPIRY_FOREVER { 71 | return true; 72 | } 73 | 74 | // Parse expiry time, check if it has passed 75 | let expiry = match DateTime::parse_from_str(expires, "%Y-%m-%d %H:%M:%S %z") { 76 | Ok(expiry) => expiry, 77 | Err(err) => { 78 | error!(target: "lazymc", "Failed to parse ban expiry '{}', assuming still banned: {}", expires, err); 79 | return true; 80 | } 81 | }; 82 | 83 | expiry > Utc::now() 84 | } 85 | } 86 | 87 | /// Load banned IPs from file. 88 | pub fn load(path: &Path) -> Result> { 89 | // Load file contents 90 | let contents = fs::read_to_string(path)?; 91 | 92 | // Parse contents 93 | let ips: Vec = serde_json::from_str(&contents)?; 94 | debug!(target: "lazymc", "Loaded {} banned IPs", ips.len()); 95 | 96 | // Transform into map 97 | let ips = ips.into_iter().map(|ip| (ip.ip, ip)).collect(); 98 | Ok(BannedIps { ips }) 99 | } 100 | -------------------------------------------------------------------------------- /src/mc/dimension.rs: -------------------------------------------------------------------------------- 1 | use nbt::CompoundTag; 2 | 3 | /// Create lobby dimension from the given codec. 4 | /// 5 | /// This creates a dimension suitable for the lobby that should be suitable for the current server 6 | /// version. 7 | pub fn lobby_dimension(codec: &CompoundTag) -> CompoundTag { 8 | // Retrieve dimension types from codec 9 | let dimension_types = match codec.get_compound_tag("minecraft:dimension_type") { 10 | Ok(types) => types, 11 | Err(_) => return lobby_default_dimension(), 12 | }; 13 | 14 | // Get base dimension 15 | let mut base = lobby_base_dimension(dimension_types); 16 | 17 | // Change known properties on base to get more desirable dimension 18 | base.insert_i8("piglin_safe", 1); 19 | base.insert_f32("ambient_light", 0.0); 20 | // base.insert_str("infiniburn", "minecraft:infiniburn_end"); 21 | base.insert_i8("respawn_anchor_works", 0); 22 | base.insert_i8("has_skylight", 0); 23 | base.insert_i8("bed_works", 0); 24 | base.insert_str("effects", "minecraft:the_end"); 25 | base.insert_i64("fixed_time", 0); 26 | base.insert_i8("has_raids", 0); 27 | base.insert_i32("min_y", 0); 28 | base.insert_i32("height", 16); 29 | base.insert_i32("logical_height", 16); 30 | base.insert_f64("coordinate_scale", 1.0); 31 | base.insert_i8("ultrawarm", 0); 32 | base.insert_i8("has_ceiling", 0); 33 | 34 | base 35 | } 36 | 37 | /// Get lobby base dimension. 38 | /// 39 | /// This retrieves the most desirable dimension to use as base for the lobby from the given list of 40 | /// `dimension_types`. 41 | /// 42 | /// If no dimension is found in the given tag, a default one will be returned. 43 | fn lobby_base_dimension(dimension_types: &CompoundTag) -> CompoundTag { 44 | // The dimension types we prefer the most, in order 45 | let preferred = vec![ 46 | "minecraft:the_end", 47 | "minecraft:the_nether", 48 | "minecraft:the_overworld", 49 | ]; 50 | 51 | let dimensions = dimension_types.get_compound_tag_vec("value").unwrap(); 52 | 53 | for name in preferred { 54 | if let Some(dimension) = dimensions 55 | .iter() 56 | .find(|d| d.get_str("name").map(|n| n == name).unwrap_or(false)) 57 | { 58 | if let Ok(dimension) = dimension.get_compound_tag("element") { 59 | return dimension.clone(); 60 | } 61 | } 62 | } 63 | 64 | // Return first dimension 65 | if let Some(dimension) = dimensions.first() { 66 | if let Ok(dimension) = dimension.get_compound_tag("element") { 67 | return dimension.clone(); 68 | } 69 | } 70 | 71 | // Fall back to default dimension 72 | lobby_default_dimension() 73 | } 74 | 75 | /// Default lobby dimension codec from resource file. 76 | /// 77 | /// This likely breaks if the Minecraft version doesn't match exactly. 78 | /// Please use an up-to-date coded from the server instead. 79 | pub fn default_dimension_codec() -> CompoundTag { 80 | snbt_to_compound_tag(include_str!("../../res/dimension_codec.snbt")) 81 | } 82 | 83 | /// Default lobby dimension from resource file. 84 | /// 85 | /// This likely breaks if the Minecraft version doesn't match exactly. 86 | /// Please use `lobby_dimension` with an up-to-date coded from the server instead. 87 | fn lobby_default_dimension() -> CompoundTag { 88 | snbt_to_compound_tag(include_str!("../../res/dimension.snbt")) 89 | } 90 | 91 | /// Read NBT CompoundTag from SNBT. 92 | fn snbt_to_compound_tag(data: &str) -> CompoundTag { 93 | use quartz_nbt::io::{write_nbt, Flavor}; 94 | use quartz_nbt::snbt; 95 | 96 | // Parse SNBT data 97 | let compound = snbt::parse(data).expect("failed to parse SNBT"); 98 | 99 | // Encode to binary 100 | let mut binary = Vec::new(); 101 | write_nbt(&mut binary, None, &compound, Flavor::Uncompressed) 102 | .expect("failed to encode NBT CompoundTag as binary"); 103 | 104 | // Parse binary with usable NBT create 105 | bin_to_compound_tag(&binary) 106 | } 107 | 108 | /// Read NBT CompoundTag from SNBT. 109 | fn bin_to_compound_tag(data: &[u8]) -> CompoundTag { 110 | use nbt::decode::read_compound_tag; 111 | read_compound_tag(&mut &*data).unwrap() 112 | } 113 | -------------------------------------------------------------------------------- /src/mc/favicon.rs: -------------------------------------------------------------------------------- 1 | use base64::Engine; 2 | 3 | use crate::proto::client::ClientInfo; 4 | 5 | /// Protocol version since when favicons are supported. 6 | const FAVICON_PROTOCOL_VERSION: u32 = 4; 7 | 8 | /// Get default server status favicon. 9 | pub fn default_favicon() -> String { 10 | encode_favicon(include_bytes!("../../res/unknown_server_optimized.png")) 11 | } 12 | 13 | /// Encode favicon bytes to a string Minecraft can read. 14 | /// 15 | /// This assumes the favicon data to be a valid PNG image. 16 | pub fn encode_favicon(data: &[u8]) -> String { 17 | format!( 18 | "{}{}", 19 | "data:image/png;base64,", 20 | base64::engine::general_purpose::STANDARD.encode(data) 21 | ) 22 | } 23 | 24 | /// Check whether the status response favicon is supported based on the given client info. 25 | /// 26 | /// Defaults to `true` if unsure. 27 | pub fn supports_favicon(client_info: &ClientInfo) -> bool { 28 | client_info 29 | .protocol 30 | .map(|p| p >= FAVICON_PROTOCOL_VERSION) 31 | .unwrap_or(true) 32 | } 33 | -------------------------------------------------------------------------------- /src/mc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ban; 2 | #[cfg(feature = "lobby")] 3 | pub mod dimension; 4 | pub mod favicon; 5 | #[cfg(feature = "rcon")] 6 | pub mod rcon; 7 | pub mod server_properties; 8 | #[cfg(feature = "lobby")] 9 | pub mod uuid; 10 | pub mod whitelist; 11 | 12 | /// Minecraft ticks per second. 13 | #[allow(unused)] 14 | pub const TICKS_PER_SECOND: u32 = 20; 15 | -------------------------------------------------------------------------------- /src/mc/rcon.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use rust_rcon::{Connection, Error as RconError}; 4 | use tokio::io::AsyncWriteExt; 5 | use tokio::net::TcpStream; 6 | use tokio::time; 7 | 8 | use crate::config::Config; 9 | use crate::proxy; 10 | 11 | /// Minecraft RCON quirk. 12 | /// 13 | /// Wait this time between RCON operations. 14 | /// The Minecraft RCON implementation is very broken and brittle, this is used in the hopes to 15 | /// improve reliability. 16 | const QUIRK_RCON_GRACE_TIME: Duration = Duration::from_millis(200); 17 | 18 | /// An RCON client. 19 | pub struct Rcon { 20 | con: Connection, 21 | } 22 | 23 | impl Rcon { 24 | /// Connect to a host. 25 | pub async fn connect( 26 | config: &Config, 27 | addr: &str, 28 | pass: &str, 29 | ) -> Result> { 30 | // Connect to our TCP stream 31 | let mut stream = TcpStream::connect(addr).await?; 32 | 33 | // Add proxy header 34 | if config.rcon.send_proxy_v2 { 35 | trace!(target: "lazymc::rcon", "Sending local proxy header for RCON connection"); 36 | stream.write_all(&proxy::local_proxy_header()?).await?; 37 | } 38 | 39 | // Start connection 40 | let con = Connection::builder() 41 | .enable_minecraft_quirks(true) 42 | .handshake(stream, pass) 43 | .await?; 44 | 45 | Ok(Self { con }) 46 | } 47 | 48 | /// Connect to a host from the given configuration. 49 | pub async fn connect_config(config: &Config) -> Result> { 50 | // RCON address 51 | let mut addr = config.server.address; 52 | addr.set_port(config.rcon.port); 53 | let addr = addr.to_string(); 54 | 55 | Self::connect(config, &addr, &config.rcon.password).await 56 | } 57 | 58 | /// Send command over RCON. 59 | pub async fn cmd(&mut self, cmd: &str) -> Result { 60 | // Minecraft quirk 61 | time::sleep(QUIRK_RCON_GRACE_TIME).await; 62 | 63 | // Actually send RCON command 64 | debug!(target: "lazymc::rcon", "Sending RCON: {}", cmd); 65 | self.con.cmd(cmd).await 66 | } 67 | 68 | /// Close connection. 69 | pub async fn close(self) { 70 | // Minecraft quirk 71 | time::sleep(QUIRK_RCON_GRACE_TIME).await; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/mc/server_properties.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | /// File name. 6 | pub const FILE: &str = "server.properties"; 7 | 8 | /// EOL in server.properties file. 9 | const EOL: &str = "\r\n"; 10 | 11 | /// Try to rewrite changes in server.properties file in dir. 12 | /// 13 | /// Prints an error and stops on failure. 14 | pub fn rewrite_dir>(dir: P, changes: HashMap<&str, String>) { 15 | if changes.is_empty() { 16 | return; 17 | } 18 | 19 | // Ensure directory exists 20 | if !dir.as_ref().is_dir() { 21 | warn!(target: "lazymc", 22 | "Not rewriting {} file, configured server directory doesn't exist: {}", 23 | FILE, 24 | dir.as_ref().to_str().unwrap_or("?") 25 | ); 26 | return; 27 | } 28 | 29 | // Rewrite file 30 | rewrite_file(dir.as_ref().join(FILE), changes) 31 | } 32 | 33 | /// Try to rewrite changes in server.properties file. 34 | /// 35 | /// Prints an error and stops on failure. 36 | pub fn rewrite_file>(file: P, changes: HashMap<&str, String>) { 37 | if changes.is_empty() { 38 | return; 39 | } 40 | 41 | // File must exist 42 | if !file.as_ref().is_file() { 43 | warn!(target: "lazymc", 44 | "Not writing {} file, not found at: {}", 45 | FILE, 46 | file.as_ref().to_str().unwrap_or("?"), 47 | ); 48 | return; 49 | } 50 | 51 | // Read contents 52 | let contents = match fs::read_to_string(&file) { 53 | Ok(contents) => contents, 54 | Err(err) => { 55 | error!(target: "lazymc", 56 | "Failed to rewrite {} file, could not load: {}", 57 | FILE, 58 | err, 59 | ); 60 | return; 61 | } 62 | }; 63 | 64 | // Rewrite file contents, return if nothing changed 65 | let contents = match rewrite_contents(contents, changes) { 66 | Some(contents) => contents, 67 | None => { 68 | debug!(target: "lazymc", 69 | "Not rewriting {} file, no changes to apply", 70 | FILE, 71 | ); 72 | return; 73 | } 74 | }; 75 | 76 | // Write changes 77 | match fs::write(file, contents) { 78 | Ok(_) => { 79 | info!(target: "lazymc", 80 | "Rewritten {} file with updated values", 81 | FILE, 82 | ); 83 | } 84 | Err(err) => { 85 | error!(target: "lazymc", 86 | "Failed to rewrite {} file, could not save changes: {}", 87 | FILE, 88 | err, 89 | ); 90 | } 91 | }; 92 | } 93 | 94 | /// Rewrite file contents with new properties. 95 | /// 96 | /// Returns new file contents if anything has changed. 97 | fn rewrite_contents(contents: String, mut changes: HashMap<&str, String>) -> Option { 98 | if changes.is_empty() { 99 | return None; 100 | } 101 | 102 | let mut changed = false; 103 | 104 | // Build new file 105 | let mut new_contents: String = contents 106 | .lines() 107 | .map(|line| { 108 | let mut line = line.to_owned(); 109 | 110 | // Skip comments or empty lines 111 | let trim = line.trim(); 112 | if trim.starts_with('#') || trim.is_empty() { 113 | return line; 114 | } 115 | 116 | // Try to split property 117 | let (key, value) = match line.split_once('=') { 118 | Some(result) => result, 119 | None => return line, 120 | }; 121 | 122 | // Take any new value, and update it 123 | if let Some((_, new)) = changes.remove_entry(key.trim().to_lowercase().as_str()) { 124 | if value != new { 125 | line = format!("{key}={new}"); 126 | changed = true; 127 | } 128 | } 129 | 130 | line 131 | }) 132 | .collect::>() 133 | .join(EOL); 134 | 135 | // Append any missed changes 136 | for (key, value) in changes { 137 | new_contents += &format!("{EOL}{key}={value}"); 138 | changed = true; 139 | } 140 | 141 | // Return new contents if changed 142 | if changed { 143 | Some(new_contents) 144 | } else { 145 | None 146 | } 147 | } 148 | 149 | /// Read the given property from the given server.properties file.o 150 | /// 151 | /// Returns `None` if file does not contain the property. 152 | pub fn read_property>(file: P, property: &str) -> Option { 153 | // File must exist 154 | if !file.as_ref().is_file() { 155 | warn!(target: "lazymc", 156 | "Failed to read property from {} file, it does not exist", 157 | FILE, 158 | ); 159 | return None; 160 | } 161 | 162 | // Read contents 163 | let contents = match fs::read_to_string(&file) { 164 | Ok(contents) => contents, 165 | Err(err) => { 166 | error!(target: "lazymc", 167 | "Failed to read property from {} file, could not load: {}", 168 | FILE, 169 | err, 170 | ); 171 | return None; 172 | } 173 | }; 174 | 175 | // Find property, return value 176 | contents 177 | .lines() 178 | .filter_map(|line| line.split_once('=')) 179 | .find(|(p, _)| p.trim().to_lowercase() == property.to_lowercase()) 180 | .map(|(_, v)| v.trim().to_string()) 181 | } 182 | -------------------------------------------------------------------------------- /src/mc/uuid.rs: -------------------------------------------------------------------------------- 1 | use md5::{Digest, Md5}; 2 | use uuid::Uuid; 3 | 4 | /// Offline player namespace. 5 | const OFFLINE_PLAYER_NAMESPACE: &str = "OfflinePlayer:"; 6 | 7 | /// Get UUID for given player username. 8 | fn player_uuid(username: &str) -> Uuid { 9 | java_name_uuid_from_bytes(username.as_bytes()) 10 | } 11 | 12 | /// Get UUID for given offline player username. 13 | pub fn offline_player_uuid(username: &str) -> Uuid { 14 | player_uuid(&format!("{OFFLINE_PLAYER_NAMESPACE}{username}")) 15 | } 16 | 17 | /// Java's `UUID.nameUUIDFromBytes` 18 | /// 19 | /// Static factory to retrieve a type 3 (name based) `Uuid` based on the specified byte array. 20 | /// 21 | /// Ported from: 22 | fn java_name_uuid_from_bytes(data: &[u8]) -> Uuid { 23 | let mut hasher = Md5::new(); 24 | hasher.update(data); 25 | let mut md5: [u8; 16] = hasher.finalize().into(); 26 | 27 | md5[6] &= 0x0f; // clear version 28 | md5[6] |= 0x30; // set to version 3 29 | md5[8] &= 0x3f; // clear variant 30 | md5[8] |= 0x80; // set to IETF variant 31 | 32 | Uuid::from_bytes(md5) 33 | } 34 | -------------------------------------------------------------------------------- /src/mc/whitelist.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | use serde::Deserialize; 6 | 7 | /// Whitelist file name. 8 | pub const WHITELIST_FILE: &str = "whitelist.json"; 9 | 10 | /// OPs file name. 11 | pub const OPS_FILE: &str = "ops.json"; 12 | 13 | /// Whitelisted users. 14 | /// 15 | /// Includes list of OPs, which are also automatically whitelisted. 16 | #[derive(Debug, Default)] 17 | pub struct Whitelist { 18 | /// Whitelisted users. 19 | whitelist: Vec, 20 | 21 | /// OPd users. 22 | ops: Vec, 23 | } 24 | 25 | impl Whitelist { 26 | /// Check whether a user is whitelisted. 27 | pub fn is_whitelisted(&self, username: &str) -> bool { 28 | self.whitelist.iter().any(|u| u == username) || self.ops.iter().any(|u| u == username) 29 | } 30 | } 31 | 32 | /// A whitelist user. 33 | #[derive(Debug, Deserialize, Clone)] 34 | pub struct WhitelistUser { 35 | /// Whitelisted username. 36 | #[serde(rename = "name", alias = "username")] 37 | pub username: String, 38 | 39 | /// Whitelisted UUID. 40 | pub uuid: Option, 41 | } 42 | 43 | /// An OP user. 44 | #[derive(Debug, Deserialize, Clone)] 45 | pub struct OpUser { 46 | /// OP username. 47 | #[serde(rename = "name", alias = "username")] 48 | pub username: String, 49 | 50 | /// OP UUID. 51 | pub uuid: Option, 52 | 53 | /// OP level. 54 | pub level: Option, 55 | 56 | /// Whether OP can bypass player limit. 57 | #[serde(rename = "bypassesPlayerLimit")] 58 | pub byapsses_player_limit: Option, 59 | } 60 | 61 | /// Load whitelist from directory. 62 | pub fn load_dir(path: &Path) -> Result> { 63 | let whitelist_file = path.join(WHITELIST_FILE); 64 | let ops_file = path.join(OPS_FILE); 65 | 66 | // Load whitelist users 67 | let whitelist = if whitelist_file.is_file() { 68 | load_whitelist(&whitelist_file)? 69 | } else { 70 | vec![] 71 | }; 72 | 73 | // Load OPd users 74 | let ops = if ops_file.is_file() { 75 | load_ops(&ops_file)? 76 | } else { 77 | vec![] 78 | }; 79 | 80 | debug!(target: "lazymc", "Loaded {} whitelist and {} OP users", whitelist.len(), ops.len()); 81 | 82 | Ok(Whitelist { whitelist, ops }) 83 | } 84 | 85 | /// Load whitelist from file. 86 | fn load_whitelist(path: &Path) -> Result, Box> { 87 | // Load file contents 88 | let contents = fs::read_to_string(path)?; 89 | 90 | // Parse contents 91 | let users: Vec = serde_json::from_str(&contents)?; 92 | 93 | // Pluck usernames 94 | Ok(users.into_iter().map(|user| user.username).collect()) 95 | } 96 | 97 | /// Load OPs from file. 98 | fn load_ops(path: &Path) -> Result, Box> { 99 | // Load file contents 100 | let contents = fs::read_to_string(path)?; 101 | 102 | // Parse contents 103 | let users: Vec = serde_json::from_str(&contents)?; 104 | 105 | // Pluck usernames 106 | Ok(users.into_iter().map(|user| user.username).collect()) 107 | } 108 | -------------------------------------------------------------------------------- /src/monitor.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use bytes::BytesMut; 6 | use minecraft_protocol::decoder::Decoder; 7 | use minecraft_protocol::version::v1_14_4::handshake::Handshake; 8 | use minecraft_protocol::version::v1_20_3::status::{ 9 | PingRequest, PingResponse, ServerStatus, StatusRequest, StatusResponse, 10 | }; 11 | use rand::Rng; 12 | use tokio::io::AsyncWriteExt; 13 | use tokio::net::TcpStream; 14 | use tokio::time; 15 | 16 | use crate::config::Config; 17 | use crate::proto::client::{Client, ClientState}; 18 | use crate::proto::{packet, packets}; 19 | use crate::proxy; 20 | use crate::server::{Server, State}; 21 | 22 | /// Monitor ping inverval in seconds. 23 | const MONITOR_POLL_INTERVAL: Duration = Duration::from_secs(2); 24 | 25 | /// Status request timeout in seconds. 26 | const STATUS_TIMEOUT: u64 = 20; 27 | 28 | /// Ping request timeout in seconds. 29 | const PING_TIMEOUT: u64 = 10; 30 | 31 | /// Monitor server. 32 | pub async fn monitor_server(config: Arc, server: Arc) { 33 | // Server address 34 | let addr = config.server.address; 35 | 36 | let mut poll_interval = time::interval(MONITOR_POLL_INTERVAL); 37 | 38 | loop { 39 | poll_interval.tick().await; 40 | 41 | // Poll server state and update internal status 42 | trace!(target: "lazymc::monitor", "Fetching status for {} ... ", addr); 43 | let status = poll_server(&config, &server, addr).await; 44 | match status { 45 | // Got status, update 46 | Ok(Some(status)) => server.update_status(&config, Some(status)).await, 47 | 48 | // Error, reset status 49 | Err(_) => server.update_status(&config, None).await, 50 | 51 | // Didn't get status, but ping fallback worked, leave as-is, show warning 52 | Ok(None) => { 53 | warn!(target: "lazymc::monitor", "Failed to poll server status, ping fallback succeeded"); 54 | } 55 | } 56 | 57 | // Sleep server when it's bedtime 58 | if server.should_sleep(&config).await { 59 | info!(target: "lazymc::monitor", "Server has been idle, sleeping..."); 60 | server.stop(&config).await; 61 | } 62 | 63 | // Check whether we should force kill server 64 | if server.should_kill().await { 65 | error!(target: "lazymc::monitor", "Force killing server, took too long to start or stop"); 66 | if !server.force_kill().await { 67 | warn!(target: "lazymc", "Failed to force kill server"); 68 | } 69 | } 70 | } 71 | } 72 | 73 | /// Poll server state. 74 | /// 75 | /// Returns `Ok` if status/ping succeeded, includes server status most of the time. 76 | /// Returns `Err` if no connection could be established or if an error occurred. 77 | pub async fn poll_server( 78 | config: &Config, 79 | server: &Server, 80 | addr: SocketAddr, 81 | ) -> Result, ()> { 82 | // Fetch status 83 | if let Ok(status) = fetch_status(config, addr).await { 84 | return Ok(Some(status)); 85 | } 86 | 87 | // Try ping fallback if server is currently started 88 | if server.state() == State::Started { 89 | debug!(target: "lazymc::monitor", "Failed to get status from started server, trying ping..."); 90 | do_ping(config, addr).await?; 91 | } 92 | 93 | Err(()) 94 | } 95 | 96 | /// Attemp to fetch status from server. 97 | async fn fetch_status(config: &Config, addr: SocketAddr) -> Result { 98 | let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?; 99 | 100 | // Add proxy header 101 | if config.server.send_proxy_v2 { 102 | trace!(target: "lazymc::monitor", "Sending local proxy header for server connection"); 103 | stream 104 | .write_all(&proxy::local_proxy_header().map_err(|_| ())?) 105 | .await 106 | .map_err(|_| ())?; 107 | } 108 | 109 | // Dummy client 110 | let client = Client::dummy(); 111 | 112 | send_handshake(&client, &mut stream, config, addr).await?; 113 | request_status(&client, &mut stream).await?; 114 | wait_for_status_timeout(&client, &mut stream).await 115 | } 116 | 117 | /// Attemp to ping server. 118 | async fn do_ping(config: &Config, addr: SocketAddr) -> Result<(), ()> { 119 | let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?; 120 | 121 | // Add proxy header 122 | if config.server.send_proxy_v2 { 123 | trace!(target: "lazymc::monitor", "Sending local proxy header for server connection"); 124 | stream 125 | .write_all(&proxy::local_proxy_header().map_err(|_| ())?) 126 | .await 127 | .map_err(|_| ())?; 128 | } 129 | 130 | // Dummy client 131 | let client = Client::dummy(); 132 | 133 | send_handshake(&client, &mut stream, config, addr).await?; 134 | let token = send_ping(&client, &mut stream).await?; 135 | wait_for_ping_timeout(&client, &mut stream, token).await 136 | } 137 | 138 | /// Send handshake. 139 | async fn send_handshake( 140 | client: &Client, 141 | stream: &mut TcpStream, 142 | config: &Config, 143 | addr: SocketAddr, 144 | ) -> Result<(), ()> { 145 | packet::write_packet( 146 | Handshake { 147 | protocol_version: config.public.protocol as i32, 148 | server_addr: addr.ip().to_string(), 149 | server_port: addr.port(), 150 | next_state: ClientState::Status.to_id(), 151 | }, 152 | client, 153 | &mut stream.split().1, 154 | ) 155 | .await 156 | } 157 | 158 | /// Send status request. 159 | async fn request_status(client: &Client, stream: &mut TcpStream) -> Result<(), ()> { 160 | packet::write_packet(StatusRequest {}, client, &mut stream.split().1).await 161 | } 162 | 163 | /// Send status request. 164 | async fn send_ping(client: &Client, stream: &mut TcpStream) -> Result { 165 | let token = rand::thread_rng().gen(); 166 | packet::write_packet(PingRequest { time: token }, client, &mut stream.split().1).await?; 167 | Ok(token) 168 | } 169 | 170 | /// Wait for a status response. 171 | async fn wait_for_status(client: &Client, stream: &mut TcpStream) -> Result { 172 | // Get stream reader, set up buffer 173 | let (mut reader, mut _writer) = stream.split(); 174 | let mut buf = BytesMut::new(); 175 | 176 | loop { 177 | // Read packet from stream 178 | let (packet, _raw) = match packet::read_packet(client, &mut buf, &mut reader).await { 179 | Ok(Some(packet)) => packet, 180 | Ok(None) => break, 181 | Err(_) => continue, 182 | }; 183 | 184 | // Catch status response 185 | if packet.id == packets::status::CLIENT_STATUS { 186 | let status = StatusResponse::decode(&mut packet.data.as_slice()).map_err(|_| ())?; 187 | return Ok(status.server_status); 188 | } 189 | } 190 | 191 | // Some error occurred 192 | Err(()) 193 | } 194 | 195 | /// Wait for a status response. 196 | async fn wait_for_status_timeout( 197 | client: &Client, 198 | stream: &mut TcpStream, 199 | ) -> Result { 200 | let status = wait_for_status(client, stream); 201 | tokio::time::timeout(Duration::from_secs(STATUS_TIMEOUT), status) 202 | .await 203 | .map_err(|_| ())? 204 | } 205 | 206 | /// Wait for a status response. 207 | async fn wait_for_ping(client: &Client, stream: &mut TcpStream, token: u64) -> Result<(), ()> { 208 | // Get stream reader, set up buffer 209 | let (mut reader, mut _writer) = stream.split(); 210 | let mut buf = BytesMut::new(); 211 | 212 | loop { 213 | // Read packet from stream 214 | let (packet, _raw) = match packet::read_packet(client, &mut buf, &mut reader).await { 215 | Ok(Some(packet)) => packet, 216 | Ok(None) => break, 217 | Err(_) => continue, 218 | }; 219 | 220 | // Catch ping response 221 | if packet.id == packets::status::CLIENT_PING { 222 | let ping = PingResponse::decode(&mut packet.data.as_slice()).map_err(|_| ())?; 223 | 224 | // Ping token must match 225 | if ping.time == token { 226 | return Ok(()); 227 | } else { 228 | debug!(target: "lazymc", "Got unmatched ping response when polling server status by ping"); 229 | } 230 | } 231 | } 232 | 233 | // Some error occurred 234 | Err(()) 235 | } 236 | 237 | /// Wait for a status response. 238 | async fn wait_for_ping_timeout( 239 | client: &Client, 240 | stream: &mut TcpStream, 241 | token: u64, 242 | ) -> Result<(), ()> { 243 | let status = wait_for_ping(client, stream, token); 244 | tokio::time::timeout(Duration::from_secs(PING_TIMEOUT), status) 245 | .await 246 | .map_err(|_| ())? 247 | } 248 | -------------------------------------------------------------------------------- /src/net.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::io; 3 | use tokio::io::AsyncWriteExt; 4 | use tokio::net::TcpStream; 5 | 6 | /// Gracefully close given TCP stream. 7 | /// 8 | /// Intended as helper to make code less messy. This also succeeds if already closed. 9 | pub async fn close_tcp_stream(mut stream: TcpStream) -> Result<(), Box> { 10 | close_tcp_stream_ref(&mut stream).await 11 | } 12 | 13 | /// Gracefully close given TCP stream. 14 | /// 15 | /// Intended as helper to make code less messy. This also succeeds if already closed. 16 | pub async fn close_tcp_stream_ref(stream: &mut TcpStream) -> Result<(), Box> { 17 | match stream.shutdown().await { 18 | Ok(_) => Ok(()), 19 | Err(err) if err.kind() == io::ErrorKind::NotConnected => Ok(()), 20 | Err(err) => Err(err.into()), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/os/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(windows)] 2 | pub mod windows; 3 | 4 | #[cfg(unix)] 5 | use nix::{ 6 | sys::signal::{self, Signal}, 7 | unistd::Pid, 8 | }; 9 | 10 | /// Force kill process. 11 | /// 12 | /// Results in undefined behavior if PID is invalid. 13 | #[allow(unreachable_code)] 14 | pub fn force_kill(pid: u32) -> bool { 15 | #[cfg(unix)] 16 | return unix_signal(pid, Signal::SIGKILL); 17 | 18 | #[cfg(windows)] 19 | unsafe { 20 | return windows::force_kill(pid); 21 | } 22 | 23 | unimplemented!("force killing Minecraft server process not implemented on this platform"); 24 | } 25 | 26 | /// Gracefully kill process. 27 | /// Results in undefined behavior if PID is invalid. 28 | /// 29 | /// # Panics 30 | /// Panics on platforms other than Unix. 31 | #[allow(unreachable_code, dead_code, unused_variables)] 32 | pub fn kill_gracefully(pid: u32) -> bool { 33 | #[cfg(unix)] 34 | return unix_signal(pid, Signal::SIGTERM); 35 | 36 | unimplemented!( 37 | "gracefully killing Minecraft server process not implemented on non-Unix platforms" 38 | ); 39 | } 40 | 41 | /// Freeze process. 42 | /// Results in undefined behavior if PID is invaild. 43 | /// 44 | /// # Panics 45 | /// Panics on platforms other than Unix. 46 | #[allow(unreachable_code)] 47 | pub fn freeze(pid: u32) -> bool { 48 | #[cfg(unix)] 49 | return unix_signal(pid, Signal::SIGSTOP); 50 | 51 | unimplemented!( 52 | "freezing the Minecraft server process is not implemented on non-Unix platforms" 53 | ); 54 | } 55 | 56 | /// Unfreeze process. 57 | /// Results in undefined behavior if PID is invaild. 58 | /// 59 | /// # Panics 60 | /// Panics on platforms other than Unix. 61 | #[allow(unreachable_code)] 62 | pub fn unfreeze(pid: u32) -> bool { 63 | #[cfg(unix)] 64 | return unix_signal(pid, Signal::SIGCONT); 65 | 66 | unimplemented!( 67 | "unfreezing the Minecraft server process is not implemented on non-Unix platforms" 68 | ); 69 | } 70 | 71 | #[cfg(unix)] 72 | pub fn unix_signal(pid: u32, signal: Signal) -> bool { 73 | match signal::kill(Pid::from_raw(pid as i32), signal) { 74 | Ok(()) => true, 75 | Err(err) => { 76 | warn!(target: "lazymc", "Sending {signal} signal to server failed: {err}"); 77 | false 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/os/windows.rs: -------------------------------------------------------------------------------- 1 | use winapi::shared::minwindef::{FALSE, TRUE}; 2 | use winapi::shared::ntdef::NULL; 3 | use winapi::um::handleapi::CloseHandle; 4 | use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess}; 5 | use winapi::um::winnt::PROCESS_TERMINATE; 6 | 7 | /// Force kill process on Windows. 8 | /// 9 | /// This is unsafe because the PID isn't checked. 10 | pub unsafe fn force_kill(pid: u32) -> bool { 11 | debug!(target: "lazymc", "Sending force kill to {} to kill server", pid); 12 | let handle = OpenProcess(PROCESS_TERMINATE, FALSE, pid); 13 | if handle == NULL { 14 | warn!(target: "lazymc", "Failed to open process handle in order to kill it"); 15 | return false; 16 | } 17 | 18 | let terminated = TerminateProcess(handle, 1) == TRUE; 19 | let closed = CloseHandle(handle) == TRUE; 20 | 21 | terminated && closed 22 | } 23 | -------------------------------------------------------------------------------- /src/probe.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use bytes::BytesMut; 6 | use minecraft_protocol::decoder::Decoder; 7 | use minecraft_protocol::version::v1_14_4::handshake::Handshake; 8 | use minecraft_protocol::version::v1_14_4::login::{ 9 | LoginPluginRequest, LoginPluginResponse, LoginStart, SetCompression, 10 | }; 11 | use tokio::net::TcpStream; 12 | use tokio::time; 13 | 14 | use crate::config::Config; 15 | use crate::forge; 16 | use crate::net; 17 | use crate::proto::client::{Client, ClientInfo, ClientState}; 18 | use crate::proto::packets::play::join_game::JoinGameData; 19 | use crate::proto::{self, packet, packets}; 20 | use crate::server::{Server, State}; 21 | 22 | /// Minecraft username to use for probing the server. 23 | const PROBE_USER: &str = "_lazymc_probe"; 24 | 25 | /// Timeout for probe user connecting to the server. 26 | const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); 27 | 28 | /// Maximum time the probe may wait for the server to come online. 29 | const PROBE_ONLINE_TIMEOUT: Duration = Duration::from_secs(10 * 60); 30 | 31 | /// Timeout for receiving join game packet. 32 | /// 33 | /// When the play state is reached, the server should immeditely respond with a join game packet. 34 | /// This defines the maximum timeout for waiting on it. 35 | const PROBE_JOIN_GAME_TIMEOUT: Duration = Duration::from_secs(20); 36 | 37 | /// Connect to the Minecraft server and probe useful details from it. 38 | pub async fn probe(config: Arc, server: Arc) -> Result<(), ()> { 39 | debug!(target: "lazymc::probe", "Starting server probe..."); 40 | 41 | // Start server if not starting already 42 | if Server::start(config.clone(), server.clone(), None).await { 43 | info!(target: "lazymc::probe", "Starting server to probe..."); 44 | } 45 | 46 | // Wait for server to come online 47 | if !wait_until_online(&server).await? { 48 | warn!(target: "lazymc::probe", "Couldn't probe server, failed to wait for server to come online"); 49 | return Err(()); 50 | } 51 | 52 | debug!(target: "lazymc::probe", "Connecting to server to probe details..."); 53 | 54 | // Connect to server, record Forge payload 55 | let forge_payload = connect_to_server(&config, &server).await?; 56 | *server.forge_payload.write().await = forge_payload; 57 | 58 | Ok(()) 59 | } 60 | 61 | /// Wait for the server to come online. 62 | /// 63 | /// Returns `true` when it is online. 64 | async fn wait_until_online<'a>(server: &Server) -> Result { 65 | trace!(target: "lazymc::probe", "Waiting for server to come online..."); 66 | 67 | // A task to wait for suitable server state 68 | // Waits for started state, errors if stopping/stopped state is reached 69 | let task_wait = async { 70 | let mut state = server.state_receiver(); 71 | loop { 72 | // Wait for state change 73 | state.changed().await.unwrap(); 74 | 75 | match state.borrow().deref() { 76 | // Still waiting on server start 77 | State::Starting => { 78 | continue; 79 | } 80 | 81 | // Server started, start relaying and proxy 82 | State::Started => { 83 | break true; 84 | } 85 | 86 | // Server stopping, this shouldn't happen, skip 87 | State::Stopping => { 88 | warn!(target: "lazymc::probe", "Server stopping while trying to probe, skipping"); 89 | break false; 90 | } 91 | 92 | // Server stopped, this shouldn't happen, skip 93 | State::Stopped => { 94 | error!(target: "lazymc::probe", "Server stopped while trying to probe, skipping"); 95 | break false; 96 | } 97 | } 98 | } 99 | }; 100 | 101 | // Wait for server state with timeout 102 | match time::timeout(PROBE_ONLINE_TIMEOUT, task_wait).await { 103 | Ok(online) => Ok(online), 104 | 105 | // Timeout reached, kick with starting message 106 | Err(_) => { 107 | warn!(target: "lazymc::probe", "Probe waited for server to come online but timed out after {}s", PROBE_ONLINE_TIMEOUT.as_secs()); 108 | Ok(false) 109 | } 110 | } 111 | } 112 | 113 | /// Create connection to the server, with timeout. 114 | /// 115 | /// This will initialize the connection to the play state. Client details are used. 116 | /// 117 | /// Returns recorded Forge login payload if any. 118 | async fn connect_to_server(config: &Config, server: &Server) -> Result>, ()> { 119 | time::timeout( 120 | PROBE_CONNECT_TIMEOUT, 121 | connect_to_server_no_timeout(config, server), 122 | ) 123 | .await 124 | .map_err(|_| { 125 | error!(target: "lazymc::probe", "Probe tried to connect to server but timed out after {}s", PROBE_CONNECT_TIMEOUT.as_secs()); 126 | })? 127 | } 128 | 129 | /// Create connection to the server, with no timeout. 130 | /// 131 | /// This will initialize the connection to the play state. Client details are used. 132 | /// 133 | /// Returns recorded Forge login payload if any. 134 | // TODO: clean this up 135 | async fn connect_to_server_no_timeout( 136 | config: &Config, 137 | server: &Server, 138 | ) -> Result>, ()> { 139 | // Open connection 140 | // TODO: on connect fail, ping server and redirect to serve_status if offline 141 | let mut outbound = TcpStream::connect(config.server.address) 142 | .await 143 | .map_err(|_| ())?; 144 | 145 | // Construct temporary server client 146 | let tmp_client = match outbound.local_addr() { 147 | Ok(addr) => Client::new(addr), 148 | Err(_) => Client::dummy(), 149 | }; 150 | tmp_client.set_state(ClientState::Login); 151 | 152 | // Construct client info 153 | let mut tmp_client_info = ClientInfo::empty(); 154 | tmp_client_info.protocol.replace(config.public.protocol); 155 | 156 | let (mut reader, mut writer) = outbound.split(); 157 | 158 | // Select server address to use, add magic if Forge 159 | let server_addr = if config.server.forge { 160 | format!("{}{}", config.server.address.ip(), forge::STATUS_MAGIC) 161 | } else { 162 | config.server.address.ip().to_string() 163 | }; 164 | 165 | // Send handshake packet 166 | packet::write_packet( 167 | Handshake { 168 | protocol_version: config.public.protocol as i32, 169 | server_addr, 170 | server_port: config.server.address.port(), 171 | next_state: ClientState::Login.to_id(), 172 | }, 173 | &tmp_client, 174 | &mut writer, 175 | ) 176 | .await?; 177 | 178 | // Request login start 179 | packet::write_packet( 180 | LoginStart { 181 | name: PROBE_USER.into(), 182 | }, 183 | &tmp_client, 184 | &mut writer, 185 | ) 186 | .await?; 187 | 188 | // Incoming buffer, record Forge plugin request payload 189 | let mut buf = BytesMut::new(); 190 | let mut forge_payload = Vec::new(); 191 | 192 | loop { 193 | // Read packet from stream 194 | let (packet, raw) = match packet::read_packet(&tmp_client, &mut buf, &mut reader).await { 195 | Ok(Some(packet)) => packet, 196 | Ok(None) => break, 197 | Err(_) => { 198 | error!(target: "lazymc::forge", "Closing connection, error occurred"); 199 | break; 200 | } 201 | }; 202 | 203 | // Grab client state 204 | let client_state = tmp_client.state(); 205 | 206 | // Catch set compression 207 | if client_state == ClientState::Login && packet.id == packets::login::CLIENT_SET_COMPRESSION 208 | { 209 | // Decode compression packet 210 | let set_compression = 211 | SetCompression::decode(&mut packet.data.as_slice()).map_err(|_| ())?; 212 | 213 | // Client and server compression threshold should match, show warning if not 214 | if set_compression.threshold != proto::COMPRESSION_THRESHOLD { 215 | error!( 216 | target: "lazymc::forge", 217 | "Compression threshold sent to lobby client does not match threshold from server, this may cause errors (client: {}, server: {})", 218 | proto::COMPRESSION_THRESHOLD, 219 | set_compression.threshold 220 | ); 221 | } 222 | 223 | // Set client compression 224 | tmp_client.set_compression(set_compression.threshold); 225 | continue; 226 | } 227 | 228 | // Catch login plugin request 229 | if client_state == ClientState::Login 230 | && packet.id == packets::login::CLIENT_LOGIN_PLUGIN_REQUEST 231 | { 232 | // Decode login plugin request packet 233 | let plugin_request = LoginPluginRequest::decode(&mut packet.data.as_slice()).map_err(|err| { 234 | error!(target: "lazymc::probe", "Failed to decode login plugin request from server, cannot respond properly: {:?}", err); 235 | })?; 236 | 237 | // Handle plugin requests for Forge 238 | if config.server.forge { 239 | // Record Forge login payload 240 | forge_payload.push(raw); 241 | 242 | // Respond to Forge login plugin request 243 | forge::respond_login_plugin_request(&tmp_client, plugin_request, &mut writer) 244 | .await?; 245 | continue; 246 | } 247 | 248 | warn!(target: "lazymc::probe", "Got unexpected login plugin request, responding with error"); 249 | 250 | // Respond with plugin response failure 251 | packet::write_packet( 252 | LoginPluginResponse { 253 | message_id: plugin_request.message_id, 254 | successful: false, 255 | data: vec![], 256 | }, 257 | &tmp_client, 258 | &mut writer, 259 | ) 260 | .await?; 261 | 262 | continue; 263 | } 264 | 265 | // Hijack login success 266 | if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS { 267 | trace!(target: "lazymc::probe", "Got login success from server connection, change to play mode"); 268 | 269 | // Switch to play state 270 | tmp_client.set_state(ClientState::Play); 271 | 272 | // Wait to catch join game packet 273 | let join_game_data = 274 | wait_for_server_join_game(&tmp_client, &tmp_client_info, &mut outbound, &mut buf) 275 | .await?; 276 | server 277 | .probed_join_game 278 | .write() 279 | .await 280 | .replace(join_game_data); 281 | 282 | // Gracefully close connection 283 | let _ = net::close_tcp_stream(outbound).await; 284 | 285 | return Ok(forge_payload); 286 | } 287 | 288 | // Show unhandled packet warning 289 | debug!(target: "lazymc::forge", "Got unhandled packet from server in connect_to_server:"); 290 | debug!(target: "lazymc::forge", "- State: {:?}", client_state); 291 | debug!(target: "lazymc::forge", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id); 292 | } 293 | 294 | // Gracefully close connection 295 | net::close_tcp_stream(outbound).await.map_err(|_| ())?; 296 | 297 | Err(()) 298 | } 299 | 300 | /// Wait for join game packet on server connection, with timeout. 301 | /// 302 | /// This parses, consumes and returns the packet. 303 | async fn wait_for_server_join_game( 304 | client: &Client, 305 | client_info: &ClientInfo, 306 | outbound: &mut TcpStream, 307 | buf: &mut BytesMut, 308 | ) -> Result { 309 | time::timeout( 310 | PROBE_JOIN_GAME_TIMEOUT, 311 | wait_for_server_join_game_no_timeout(client, client_info, outbound, buf), 312 | ) 313 | .await 314 | .map_err(|_| { 315 | error!(target: "lazymc::probe", "Waiting for for game data from server for probe client timed out after {}s", PROBE_JOIN_GAME_TIMEOUT.as_secs()); 316 | })? 317 | } 318 | 319 | /// Wait for join game packet on server connection, with no timeout. 320 | /// 321 | /// This parses, consumes and returns the packet. 322 | // TODO: clean this up 323 | // TODO: do not drop error here, return Box 324 | async fn wait_for_server_join_game_no_timeout( 325 | client: &Client, 326 | client_info: &ClientInfo, 327 | outbound: &mut TcpStream, 328 | buf: &mut BytesMut, 329 | ) -> Result { 330 | let (mut reader, mut _writer) = outbound.split(); 331 | 332 | loop { 333 | // Read packet from stream 334 | let (packet, _raw) = match packet::read_packet(client, buf, &mut reader).await { 335 | Ok(Some(packet)) => packet, 336 | Ok(None) => break, 337 | Err(_) => { 338 | error!(target: "lazymc::probe", "Closing connection, error occurred"); 339 | break; 340 | } 341 | }; 342 | 343 | // Catch join game 344 | if packets::play::join_game::is_packet(client_info, packet.id) { 345 | // Parse join game data 346 | let join_game_data = JoinGameData::from_packet(client_info, packet).map_err(|err| { 347 | warn!(target: "lazymc::probe", "Failed to parse join game packet: {:?}", err); 348 | })?; 349 | 350 | return Ok(join_game_data); 351 | } 352 | 353 | // Show unhandled packet warning 354 | debug!(target: "lazymc::probe", "Got unhandled packet from server in wait_for_server_join_game:"); 355 | debug!(target: "lazymc::probe", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id); 356 | } 357 | 358 | // Gracefully close connection 359 | net::close_tcp_stream_ref(outbound).await.map_err(|_| ())?; 360 | 361 | Err(()) 362 | } 363 | -------------------------------------------------------------------------------- /src/proto/action.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::data::chat::{Message, Payload}; 2 | use minecraft_protocol::version::v1_14_4::game::GameDisconnect; 3 | use minecraft_protocol::version::v1_14_4::login::LoginDisconnect; 4 | use tokio::net::tcp::WriteHalf; 5 | 6 | use crate::proto::client::{Client, ClientState}; 7 | use crate::proto::packet; 8 | 9 | /// Kick client with a message. 10 | /// 11 | /// Should close connection afterwards. 12 | pub async fn kick(client: &Client, msg: &str, writer: &mut WriteHalf<'_>) -> Result<(), ()> { 13 | match client.state() { 14 | ClientState::Login => { 15 | packet::write_packet( 16 | LoginDisconnect { 17 | reason: Message::new(Payload::text(msg)), 18 | }, 19 | client, 20 | writer, 21 | ) 22 | .await 23 | } 24 | ClientState::Play => { 25 | packet::write_packet( 26 | GameDisconnect { 27 | reason: Message::new(Payload::text(msg)), 28 | }, 29 | client, 30 | writer, 31 | ) 32 | .await 33 | } 34 | _ => Err(()), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/proto/client.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::sync::atomic::{AtomicI32, Ordering}; 3 | use std::sync::Mutex; 4 | 5 | use minecraft_protocol::version::v1_14_4::handshake::Handshake; 6 | 7 | /// Client state. 8 | /// 9 | /// Note: this does not keep track of encryption states. 10 | #[derive(Debug)] 11 | pub struct Client { 12 | /// Client peer address. 13 | pub peer: SocketAddr, 14 | 15 | /// Current client state. 16 | pub state: Mutex, 17 | 18 | /// Compression state. 19 | /// 20 | /// 0 or positive if enabled, negative if disabled. 21 | pub compression: AtomicI32, 22 | } 23 | 24 | impl Client { 25 | /// Construct new client with given peer address. 26 | pub fn new(peer: SocketAddr) -> Self { 27 | Self { 28 | peer, 29 | state: Default::default(), 30 | compression: AtomicI32::new(-1), 31 | } 32 | } 33 | 34 | /// Construct dummy client. 35 | pub fn dummy() -> Self { 36 | Self::new("0.0.0.0:0".parse().unwrap()) 37 | } 38 | 39 | /// Get client state. 40 | pub fn state(&self) -> ClientState { 41 | *self.state.lock().unwrap() 42 | } 43 | 44 | /// Set client state. 45 | pub fn set_state(&self, state: ClientState) { 46 | *self.state.lock().unwrap() = state; 47 | } 48 | 49 | /// Get compression threshold. 50 | pub fn compressed(&self) -> i32 { 51 | self.compression.load(Ordering::Relaxed) 52 | } 53 | 54 | /// Whether compression is used. 55 | pub fn is_compressed(&self) -> bool { 56 | self.compressed() >= 0 57 | } 58 | 59 | /// Set compression value. 60 | #[allow(unused)] 61 | pub fn set_compression(&self, threshold: i32) { 62 | trace!(target: "lazymc", "Client now uses compression threshold of {}", threshold); 63 | self.compression.store(threshold, Ordering::Relaxed); 64 | } 65 | } 66 | 67 | /// Protocol state a client may be in. 68 | /// 69 | /// Note: this does not include the `play` state, because this is never used anymore when a client 70 | /// reaches this state. 71 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 72 | pub enum ClientState { 73 | /// Initial client state. 74 | Handshake, 75 | 76 | /// State to query server status. 77 | Status, 78 | 79 | /// State to login to server. 80 | Login, 81 | 82 | /// State to play on the server. 83 | #[allow(unused)] 84 | Play, 85 | } 86 | 87 | impl ClientState { 88 | /// From state ID. 89 | pub fn from_id(id: i32) -> Option { 90 | match id { 91 | 0 => Some(Self::Handshake), 92 | 1 => Some(Self::Status), 93 | 2 => Some(Self::Login), 94 | _ => None, 95 | } 96 | } 97 | 98 | /// Get state ID. 99 | pub fn to_id(self) -> i32 { 100 | match self { 101 | Self::Handshake => 0, 102 | Self::Status => 1, 103 | Self::Login => 2, 104 | Self::Play => -1, 105 | } 106 | } 107 | } 108 | 109 | impl Default for ClientState { 110 | fn default() -> Self { 111 | Self::Handshake 112 | } 113 | } 114 | 115 | /// Client info, useful during connection handling. 116 | #[derive(Debug, Clone, Default)] 117 | pub struct ClientInfo { 118 | /// Used protocol version. 119 | pub protocol: Option, 120 | 121 | /// Handshake as received from client. 122 | pub handshake: Option, 123 | 124 | /// Client username. 125 | pub username: Option, 126 | } 127 | 128 | impl ClientInfo { 129 | pub fn empty() -> Self { 130 | Self::default() 131 | } 132 | 133 | /// Get protocol version. 134 | pub fn protocol(&self) -> Option { 135 | self.protocol 136 | .or_else(|| self.handshake.as_ref().map(|h| h.protocol_version as u32)) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/proto/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod client; 3 | pub mod packet; 4 | pub mod packets; 5 | 6 | /// Default minecraft protocol version name. 7 | /// 8 | /// Just something to default to when real server version isn't known or when no hint is specified 9 | /// in the configuration. 10 | /// 11 | /// Should be kept up-to-date with latest supported Minecraft version by lazymc. 12 | pub const PROTO_DEFAULT_VERSION: &str = "1.20.3"; 13 | 14 | /// Default minecraft protocol version. 15 | /// 16 | /// Just something to default to when real server version isn't known or when no hint is specified 17 | /// in the configuration. 18 | /// 19 | /// Should be kept up-to-date with latest supported Minecraft version by lazymc. 20 | pub const PROTO_DEFAULT_PROTOCOL: u32 = 765; 21 | 22 | /// Compression threshold to use. 23 | // TODO: read this from server.properties instead 24 | pub const COMPRESSION_THRESHOLD: i32 = 256; 25 | 26 | /// Default buffer size when reading packets. 27 | pub(super) const BUF_SIZE: usize = 8 * 1024; 28 | -------------------------------------------------------------------------------- /src/proto/packet.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::io::prelude::*; 3 | 4 | use bytes::BytesMut; 5 | use flate2::read::ZlibDecoder; 6 | use flate2::write::ZlibEncoder; 7 | use flate2::Compression; 8 | use minecraft_protocol::encoder::Encoder; 9 | use minecraft_protocol::version::PacketId; 10 | use tokio::io; 11 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 12 | use tokio::net::tcp::{ReadHalf, WriteHalf}; 13 | 14 | use crate::proto::client::Client; 15 | use crate::proto::BUF_SIZE; 16 | use crate::types; 17 | 18 | /// Raw Minecraft packet. 19 | /// 20 | /// Having a packet ID and a raw data byte array. 21 | pub struct RawPacket { 22 | /// Packet ID. 23 | pub id: u8, 24 | 25 | /// Packet data. 26 | pub data: Vec, 27 | } 28 | 29 | impl RawPacket { 30 | /// Construct new raw packet. 31 | pub fn new(id: u8, data: Vec) -> Self { 32 | Self { id, data } 33 | } 34 | 35 | /// Read packet ID from buffer, use remaining buffer as data. 36 | fn read_packet_id_data(mut buf: &[u8]) -> Result { 37 | // Read packet ID, select buf 38 | let (read, packet_id) = types::read_var_int(buf)?; 39 | buf = &buf[read..]; 40 | 41 | Ok(Self::new(packet_id as u8, buf.to_vec())) 42 | } 43 | 44 | /// Decode packet from raw buffer. 45 | /// 46 | /// This decodes both compressed and uncompressed packets based on the client threshold 47 | /// preference. 48 | pub fn decode_with_len(client: &Client, mut buf: &[u8]) -> Result { 49 | // Read length 50 | let (read, len) = types::read_var_int(buf)?; 51 | buf = &buf[read..][..len as usize]; 52 | 53 | // TODO: assert buffer length! 54 | 55 | Self::decode_without_len(client, buf) 56 | } 57 | 58 | /// Decode packet from raw buffer without packet length. 59 | /// 60 | /// This decodes both compressed and uncompressed packets based on the client threshold 61 | /// preference. 62 | /// The length is given, and not included in the buffer itself. 63 | pub fn decode_without_len(client: &Client, mut buf: &[u8]) -> Result { 64 | // If no compression is used, read remaining packet ID and data 65 | if !client.is_compressed() { 66 | // Read packet ID and data 67 | return Self::read_packet_id_data(buf); 68 | } 69 | 70 | // Read data length 71 | let (read, data_len) = types::read_var_int(buf)?; 72 | buf = &buf[read..]; 73 | 74 | // If data length is zero, the rest is not compressed 75 | if data_len == 0 { 76 | return Self::read_packet_id_data(buf); 77 | } 78 | 79 | // Decompress packet ID and data section 80 | let mut decompressed = Vec::with_capacity(data_len as usize); 81 | ZlibDecoder::new(buf) 82 | .read_to_end(&mut decompressed) 83 | .map_err(|err| { 84 | error!(target: "lazymc", "Packet decompression error: {}", err); 85 | })?; 86 | 87 | // Decompressed data must match length 88 | if decompressed.len() != data_len as usize { 89 | error!(target: "lazymc", "Decompressed packet has different length than expected ({}b != {}b)", decompressed.len(), data_len); 90 | return Err(()); 91 | } 92 | 93 | // Read decompressed packet ID 94 | Self::read_packet_id_data(&decompressed) 95 | } 96 | 97 | /// Encode packet to raw buffer. 98 | /// 99 | /// This compresses packets based on the client threshold preference. 100 | pub fn encode_with_len(&self, client: &Client) -> Result, ()> { 101 | // Encode packet without length 102 | let mut payload = self.encode_without_len(client)?; 103 | 104 | // Add length header 105 | let mut packet = types::encode_var_int(payload.len() as i32)?; 106 | packet.append(&mut payload); 107 | Ok(packet) 108 | } 109 | 110 | /// Encode packet to raw buffer without length header. 111 | /// 112 | /// This compresses packets based on the client threshold preference. 113 | pub fn encode_without_len(&self, client: &Client) -> Result, ()> { 114 | let threshold = client.compressed(); 115 | if threshold >= 0 { 116 | self.encode_compressed(threshold) 117 | } else { 118 | self.encode_uncompressed() 119 | } 120 | } 121 | 122 | /// Encode compressed packet to raw buffer. 123 | fn encode_compressed(&self, threshold: i32) -> Result, ()> { 124 | // Packet payload: packet ID and data buffer 125 | let mut payload = types::encode_var_int(self.id as i32)?; 126 | payload.extend_from_slice(&self.data); 127 | 128 | // Determine whether to compress, encode data length bytes 129 | let data_len = payload.len() as i32; 130 | let compress = data_len > threshold; 131 | let data_len_header = if compress { data_len } else { 0 }; 132 | 133 | // Compress payload 134 | if compress { 135 | let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); 136 | encoder.write_all(&payload).map_err(|err| { 137 | error!(target: "lazymc", "Failed to compress packet: {}", err); 138 | })?; 139 | payload = encoder.finish().map_err(|err| { 140 | error!(target: "lazymc", "Failed to compress packet: {}", err); 141 | })?; 142 | } 143 | 144 | // Add data length header 145 | let mut packet = types::encode_var_int(data_len_header).unwrap(); 146 | packet.append(&mut payload); 147 | 148 | Ok(packet) 149 | } 150 | 151 | /// Encode uncompressed packet to raw buffer. 152 | fn encode_uncompressed(&self) -> Result, ()> { 153 | let mut packet = types::encode_var_int(self.id as i32)?; 154 | packet.extend_from_slice(&self.data); 155 | 156 | Ok(packet) 157 | } 158 | } 159 | 160 | /// Read raw packet from stream. 161 | pub async fn read_packet( 162 | client: &Client, 163 | buf: &mut BytesMut, 164 | stream: &mut ReadHalf<'_>, 165 | ) -> Result)>, ()> { 166 | // Keep reading until we have at least 2 bytes 167 | while buf.len() < 2 { 168 | // Read packet from socket 169 | let mut tmp = Vec::with_capacity(BUF_SIZE); 170 | match stream.read_buf(&mut tmp).await { 171 | Ok(_) => {} 172 | Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None), 173 | Err(err) => { 174 | dbg!(err); 175 | return Err(()); 176 | } 177 | } 178 | 179 | if tmp.is_empty() { 180 | return Ok(None); 181 | } 182 | buf.extend(tmp); 183 | } 184 | 185 | // Attempt to read packet length 186 | let (consumed, len) = match types::read_var_int(buf) { 187 | Ok(result) => result, 188 | Err(err) => { 189 | error!(target: "lazymc", "Malformed packet, could not read packet length"); 190 | return Err(err); 191 | } 192 | }; 193 | 194 | // Keep reading until we have all packet bytes 195 | while buf.len() < consumed + len as usize { 196 | // Read packet from socket 197 | let mut tmp = Vec::with_capacity(BUF_SIZE); 198 | match stream.read_buf(&mut tmp).await { 199 | Ok(_) => {} 200 | Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None), 201 | Err(err) => { 202 | dbg!(err); 203 | return Err(()); 204 | } 205 | } 206 | 207 | if tmp.is_empty() { 208 | return Ok(None); 209 | } 210 | 211 | buf.extend(tmp); 212 | } 213 | 214 | // Parse packet, use full buffer since we'll read the packet length again 215 | // TODO: use decode_without_len, strip len from buffer 216 | let raw = buf.split_to(consumed + len as usize); 217 | let packet = RawPacket::decode_with_len(client, &raw)?; 218 | 219 | Ok(Some((packet, raw.to_vec()))) 220 | } 221 | 222 | /// Write packet to stream writer. 223 | pub async fn write_packet( 224 | packet: impl PacketId + Encoder + Debug, 225 | client: &Client, 226 | writer: &mut WriteHalf<'_>, 227 | ) -> Result<(), ()> { 228 | let mut data = Vec::new(); 229 | packet.encode(&mut data).map_err(|_| ())?; 230 | 231 | let response = RawPacket::new(packet.packet_id(), data).encode_with_len(client)?; 232 | writer.write_all(&response).await.map_err(|_| ())?; 233 | 234 | Ok(()) 235 | } 236 | -------------------------------------------------------------------------------- /src/proto/packets/mod.rs: -------------------------------------------------------------------------------- 1 | //! Minecraft protocol packet IDs. 2 | 3 | pub mod play; 4 | 5 | pub mod handshake { 6 | use minecraft_protocol::version::v1_14_4::handshake::*; 7 | 8 | pub const SERVER_HANDSHAKE: u8 = Handshake::PACKET_ID; 9 | } 10 | 11 | pub mod status { 12 | use minecraft_protocol::version::v1_14_4::status::*; 13 | 14 | pub const CLIENT_STATUS: u8 = StatusResponse::PACKET_ID; 15 | pub const CLIENT_PING: u8 = PingResponse::PACKET_ID; 16 | pub const SERVER_STATUS: u8 = StatusRequest::PACKET_ID; 17 | pub const SERVER_PING: u8 = PingRequest::PACKET_ID; 18 | } 19 | 20 | pub mod login { 21 | use minecraft_protocol::version::v1_14_4::login::*; 22 | 23 | #[cfg(feature = "lobby")] 24 | pub const CLIENT_DISCONNECT: u8 = LoginDisconnect::PACKET_ID; 25 | pub const CLIENT_LOGIN_SUCCESS: u8 = LoginSuccess::PACKET_ID; 26 | pub const CLIENT_SET_COMPRESSION: u8 = SetCompression::PACKET_ID; 27 | #[cfg(feature = "lobby")] 28 | pub const CLIENT_ENCRYPTION_REQUEST: u8 = EncryptionRequest::PACKET_ID; 29 | pub const CLIENT_LOGIN_PLUGIN_REQUEST: u8 = LoginPluginRequest::PACKET_ID; 30 | pub const SERVER_LOGIN_START: u8 = LoginStart::PACKET_ID; 31 | #[cfg(feature = "lobby")] 32 | pub const SERVER_LOGIN_PLUGIN_RESPONSE: u8 = LoginPluginResponse::PACKET_ID; 33 | } 34 | -------------------------------------------------------------------------------- /src/proto/packets/play/join_game.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::decoder::Decoder; 2 | use minecraft_protocol::error::DecodeError; 3 | use minecraft_protocol::version::{v1_16_3, v1_17}; 4 | use nbt::CompoundTag; 5 | #[cfg(feature = "lobby")] 6 | use tokio::net::tcp::WriteHalf; 7 | 8 | #[cfg(feature = "lobby")] 9 | use crate::mc::dimension; 10 | #[cfg(feature = "lobby")] 11 | use crate::proto::client::Client; 12 | use crate::proto::client::ClientInfo; 13 | #[cfg(feature = "lobby")] 14 | use crate::proto::packet; 15 | use crate::proto::packet::RawPacket; 16 | #[cfg(feature = "lobby")] 17 | use crate::server::Server; 18 | 19 | /// Data extracted from `JoinGame` packet. 20 | #[derive(Debug, Clone)] 21 | pub struct JoinGameData { 22 | pub hardcore: Option, 23 | pub game_mode: Option, 24 | pub previous_game_mode: Option, 25 | pub world_names: Option>, 26 | pub dimension: Option, 27 | pub dimension_codec: Option, 28 | pub world_name: Option, 29 | pub hashed_seed: Option, 30 | pub max_players: Option, 31 | pub view_distance: Option, 32 | pub reduced_debug_info: Option, 33 | pub enable_respawn_screen: Option, 34 | pub is_debug: Option, 35 | pub is_flat: Option, 36 | } 37 | 38 | impl JoinGameData { 39 | /// Extract join game data from given packet. 40 | pub fn from_packet(client_info: &ClientInfo, packet: RawPacket) -> Result { 41 | match client_info.protocol() { 42 | Some(p) if p < v1_17::PROTOCOL => { 43 | Ok(v1_16_3::game::JoinGame::decode(&mut packet.data.as_slice())?.into()) 44 | } 45 | _ => Ok(v1_17::game::JoinGame::decode(&mut packet.data.as_slice())?.into()), 46 | } 47 | } 48 | } 49 | 50 | impl From for JoinGameData { 51 | fn from(join_game: v1_16_3::game::JoinGame) -> Self { 52 | Self { 53 | hardcore: Some(join_game.hardcore), 54 | game_mode: Some(join_game.game_mode), 55 | previous_game_mode: Some(join_game.previous_game_mode), 56 | world_names: Some(join_game.world_names.clone()), 57 | dimension: Some(join_game.dimension), 58 | dimension_codec: Some(join_game.dimension_codec), 59 | world_name: Some(join_game.world_name), 60 | hashed_seed: Some(join_game.hashed_seed), 61 | max_players: Some(join_game.max_players), 62 | view_distance: Some(join_game.view_distance), 63 | reduced_debug_info: Some(join_game.reduced_debug_info), 64 | enable_respawn_screen: Some(join_game.enable_respawn_screen), 65 | is_debug: Some(join_game.is_debug), 66 | is_flat: Some(join_game.is_flat), 67 | } 68 | } 69 | } 70 | 71 | impl From for JoinGameData { 72 | fn from(join_game: v1_17::game::JoinGame) -> Self { 73 | Self { 74 | hardcore: Some(join_game.hardcore), 75 | game_mode: Some(join_game.game_mode), 76 | previous_game_mode: Some(join_game.previous_game_mode), 77 | world_names: Some(join_game.world_names.clone()), 78 | dimension: Some(join_game.dimension), 79 | dimension_codec: Some(join_game.dimension_codec), 80 | world_name: Some(join_game.world_name), 81 | hashed_seed: Some(join_game.hashed_seed), 82 | max_players: Some(join_game.max_players), 83 | view_distance: Some(join_game.view_distance), 84 | reduced_debug_info: Some(join_game.reduced_debug_info), 85 | enable_respawn_screen: Some(join_game.enable_respawn_screen), 86 | is_debug: Some(join_game.is_debug), 87 | is_flat: Some(join_game.is_flat), 88 | } 89 | } 90 | } 91 | 92 | /// Check whether the packet ID matches. 93 | pub fn is_packet(client_info: &ClientInfo, packet_id: u8) -> bool { 94 | match client_info.protocol() { 95 | Some(p) if p < v1_17::PROTOCOL => packet_id == v1_16_3::game::JoinGame::PACKET_ID, 96 | _ => packet_id == v1_17::game::JoinGame::PACKET_ID, 97 | } 98 | } 99 | 100 | /// Send initial join game packet to client for lobby. 101 | #[cfg(feature = "lobby")] 102 | pub async fn lobby_send( 103 | client: &Client, 104 | client_info: &ClientInfo, 105 | writer: &mut WriteHalf<'_>, 106 | server: &Server, 107 | ) -> Result<(), ()> { 108 | let status = server.status().await; 109 | let join_game = server.probed_join_game.read().await; 110 | 111 | // Get dimension codec and build lobby dimension 112 | let dimension_codec: CompoundTag = if let Some(join_game) = join_game.as_ref() { 113 | join_game 114 | .dimension_codec 115 | .clone() 116 | .unwrap_or_else(dimension::default_dimension_codec) 117 | } else { 118 | dimension::default_dimension_codec() 119 | }; 120 | 121 | // Get other values from status and probed join game data 122 | let dimension: CompoundTag = dimension::lobby_dimension(&dimension_codec); 123 | let hardcore = join_game.as_ref().and_then(|p| p.hardcore).unwrap_or(false); 124 | let world_names = join_game 125 | .as_ref() 126 | .and_then(|p| p.world_names.clone()) 127 | .unwrap_or_else(|| { 128 | vec![ 129 | "minecraft:overworld".into(), 130 | "minecraft:the_nether".into(), 131 | "minecraft:the_end".into(), 132 | ] 133 | }); 134 | let max_players = status 135 | .as_ref() 136 | .map(|s| s.players.max as i32) 137 | .or_else(|| join_game.as_ref().and_then(|p| p.max_players)) 138 | .unwrap_or(20); 139 | let view_distance = join_game 140 | .as_ref() 141 | .and_then(|p| p.view_distance) 142 | .unwrap_or(10); 143 | let reduced_debug_info = join_game 144 | .as_ref() 145 | .and_then(|p| p.reduced_debug_info) 146 | .unwrap_or(false); 147 | let enable_respawn_screen = join_game 148 | .as_ref() 149 | .and_then(|p| p.enable_respawn_screen) 150 | .unwrap_or(true); 151 | let is_debug = join_game.as_ref().and_then(|p| p.is_debug).unwrap_or(false); 152 | let is_flat = join_game.as_ref().and_then(|p| p.is_flat).unwrap_or(false); 153 | 154 | match client_info.protocol() { 155 | Some(p) if p < v1_17::PROTOCOL => { 156 | packet::write_packet( 157 | v1_16_3::game::JoinGame { 158 | // Player ID must be unique, if it collides with another server entity ID the player gets 159 | // in a weird state and cannot move 160 | entity_id: 0, 161 | hardcore, 162 | game_mode: 3, 163 | previous_game_mode: -1i8 as u8, 164 | world_names, 165 | dimension_codec, 166 | dimension, 167 | world_name: "lazymc:lobby".into(), 168 | hashed_seed: 0, 169 | max_players, 170 | view_distance, 171 | reduced_debug_info, 172 | enable_respawn_screen, 173 | is_debug, 174 | is_flat, 175 | }, 176 | client, 177 | writer, 178 | ) 179 | .await 180 | } 181 | _ => { 182 | packet::write_packet( 183 | v1_17::game::JoinGame { 184 | // Player ID must be unique, if it collides with another server entity ID the player gets 185 | // in a weird state and cannot move 186 | entity_id: 0, 187 | hardcore, 188 | game_mode: 3, 189 | previous_game_mode: -1i8 as u8, 190 | world_names, 191 | dimension_codec, 192 | dimension, 193 | world_name: "lazymc:lobby".into(), 194 | hashed_seed: 0, 195 | max_players, 196 | view_distance, 197 | reduced_debug_info, 198 | enable_respawn_screen, 199 | is_debug, 200 | is_flat, 201 | }, 202 | client, 203 | writer, 204 | ) 205 | .await 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/proto/packets/play/keep_alive.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::{AtomicU64, Ordering}; 2 | 3 | use minecraft_protocol::version::{v1_16_3, v1_17}; 4 | use tokio::net::tcp::WriteHalf; 5 | 6 | use crate::proto::client::{Client, ClientInfo}; 7 | use crate::proto::packet; 8 | 9 | /// Auto incrementing ID source for keep alive packets. 10 | static KEEP_ALIVE_ID: AtomicU64 = AtomicU64::new(0); 11 | 12 | /// Send keep alive packet to client. 13 | /// 14 | /// Required periodically in play mode to prevent client timeout. 15 | pub async fn send( 16 | client: &Client, 17 | client_info: &ClientInfo, 18 | writer: &mut WriteHalf<'_>, 19 | ) -> Result<(), ()> { 20 | // Keep sending new IDs 21 | let id = KEEP_ALIVE_ID.fetch_add(1, Ordering::Relaxed); 22 | 23 | match client_info.protocol() { 24 | Some(p) if p < v1_17::PROTOCOL => { 25 | packet::write_packet(v1_16_3::game::ClientBoundKeepAlive { id }, client, writer).await 26 | } 27 | _ => packet::write_packet(v1_17::game::ClientBoundKeepAlive { id }, client, writer).await, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/proto/packets/play/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod join_game; 2 | #[cfg(feature = "lobby")] 3 | pub mod keep_alive; 4 | #[cfg(feature = "lobby")] 5 | pub mod player_pos; 6 | #[cfg(feature = "lobby")] 7 | pub mod respawn; 8 | #[cfg(feature = "lobby")] 9 | pub mod server_brand; 10 | #[cfg(feature = "lobby")] 11 | pub mod sound; 12 | #[cfg(feature = "lobby")] 13 | pub mod time_update; 14 | #[cfg(feature = "lobby")] 15 | pub mod title; 16 | -------------------------------------------------------------------------------- /src/proto/packets/play/player_pos.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::version::{v1_16_3, v1_17}; 2 | use tokio::net::tcp::WriteHalf; 3 | 4 | use crate::proto::client::{Client, ClientInfo}; 5 | use crate::proto::packet; 6 | 7 | /// Move player to world origin. 8 | pub async fn send( 9 | client: &Client, 10 | client_info: &ClientInfo, 11 | writer: &mut WriteHalf<'_>, 12 | ) -> Result<(), ()> { 13 | match client_info.protocol() { 14 | Some(p) if p < v1_17::PROTOCOL => { 15 | packet::write_packet( 16 | v1_16_3::game::PlayerPositionAndLook { 17 | x: 0.0, 18 | y: 0.0, 19 | z: 0.0, 20 | yaw: 0.0, 21 | pitch: 90.0, 22 | flags: 0b00000000, 23 | teleport_id: 0, 24 | }, 25 | client, 26 | writer, 27 | ) 28 | .await 29 | } 30 | _ => { 31 | packet::write_packet( 32 | v1_17::game::PlayerPositionAndLook { 33 | x: 0.0, 34 | y: 0.0, 35 | z: 0.0, 36 | yaw: 0.0, 37 | pitch: 90.0, 38 | flags: 0b00000000, 39 | teleport_id: 0, 40 | dismount_vehicle: true, 41 | }, 42 | client, 43 | writer, 44 | ) 45 | .await 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/proto/packets/play/respawn.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::version::{v1_16_3, v1_17}; 2 | use tokio::net::tcp::WriteHalf; 3 | 4 | use super::join_game::JoinGameData; 5 | use crate::mc::dimension; 6 | use crate::proto::client::{Client, ClientInfo}; 7 | use crate::proto::packet; 8 | 9 | /// Send respawn packet to client to jump from lobby into now loaded server. 10 | /// 11 | /// The required details will be fetched from the `join_game` packet as provided by the server. 12 | pub async fn lobby_send( 13 | client: &Client, 14 | client_info: &ClientInfo, 15 | writer: &mut WriteHalf<'_>, 16 | data: JoinGameData, 17 | ) -> Result<(), ()> { 18 | match client_info.protocol() { 19 | Some(p) if p < v1_17::PROTOCOL => { 20 | packet::write_packet( 21 | v1_16_3::game::Respawn { 22 | dimension: data.dimension.unwrap_or_else(|| { 23 | dimension::lobby_dimension( 24 | &data 25 | .dimension_codec 26 | .unwrap_or_else(dimension::default_dimension_codec), 27 | ) 28 | }), 29 | world_name: data 30 | .world_name 31 | .unwrap_or_else(|| "minecraft:overworld".into()), 32 | hashed_seed: data.hashed_seed.unwrap_or(0), 33 | game_mode: data.game_mode.unwrap_or(0), 34 | previous_game_mode: data.previous_game_mode.unwrap_or(-1i8 as u8), 35 | is_debug: data.is_debug.unwrap_or(false), 36 | is_flat: data.is_flat.unwrap_or(false), 37 | copy_metadata: false, 38 | }, 39 | client, 40 | writer, 41 | ) 42 | .await 43 | } 44 | _ => { 45 | packet::write_packet( 46 | v1_17::game::Respawn { 47 | dimension: data.dimension.unwrap_or_else(|| { 48 | dimension::lobby_dimension( 49 | &data 50 | .dimension_codec 51 | .unwrap_or_else(dimension::default_dimension_codec), 52 | ) 53 | }), 54 | world_name: data 55 | .world_name 56 | .unwrap_or_else(|| "minecraft:overworld".into()), 57 | hashed_seed: data.hashed_seed.unwrap_or(0), 58 | game_mode: data.game_mode.unwrap_or(0), 59 | previous_game_mode: data.previous_game_mode.unwrap_or(-1i8 as u8), 60 | is_debug: data.is_debug.unwrap_or(false), 61 | is_flat: data.is_flat.unwrap_or(false), 62 | copy_metadata: false, 63 | }, 64 | client, 65 | writer, 66 | ) 67 | .await 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/proto/packets/play/server_brand.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::version::{v1_16_3, v1_17}; 2 | use tokio::net::tcp::WriteHalf; 3 | 4 | use crate::proto::client::{Client, ClientInfo}; 5 | use crate::proto::packet; 6 | 7 | /// Minecraft channel to set brand. 8 | const CHANNEL: &str = "minecraft:brand"; 9 | 10 | /// Server brand to send to client in lobby world. 11 | /// 12 | /// Shown in F3 menu. Updated once client is relayed to real server. 13 | const SERVER_BRAND: &[u8] = b"lazymc"; 14 | 15 | /// Send lobby brand to client. 16 | pub async fn send( 17 | client: &Client, 18 | client_info: &ClientInfo, 19 | writer: &mut WriteHalf<'_>, 20 | ) -> Result<(), ()> { 21 | match client_info.protocol() { 22 | Some(p) if p < v1_17::PROTOCOL => { 23 | packet::write_packet( 24 | v1_16_3::game::ClientBoundPluginMessage { 25 | channel: CHANNEL.into(), 26 | data: SERVER_BRAND.into(), 27 | }, 28 | client, 29 | writer, 30 | ) 31 | .await 32 | } 33 | _ => { 34 | packet::write_packet( 35 | v1_17::game::ClientBoundPluginMessage { 36 | channel: CHANNEL.into(), 37 | data: SERVER_BRAND.into(), 38 | }, 39 | client, 40 | writer, 41 | ) 42 | .await 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/proto/packets/play/sound.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::version::{v1_16_3, v1_17}; 2 | use tokio::net::tcp::WriteHalf; 3 | 4 | use crate::proto::client::{Client, ClientInfo}; 5 | use crate::proto::packet; 6 | 7 | /// Play a sound effect at world origin. 8 | pub async fn send( 9 | client: &Client, 10 | client_info: &ClientInfo, 11 | writer: &mut WriteHalf<'_>, 12 | sound_name: &str, 13 | ) -> Result<(), ()> { 14 | match client_info.protocol() { 15 | Some(p) if p < v1_17::PROTOCOL => { 16 | packet::write_packet( 17 | v1_16_3::game::NamedSoundEffect { 18 | sound_name: sound_name.into(), 19 | sound_category: 0, 20 | effect_pos_x: 0, 21 | effect_pos_y: 0, 22 | effect_pos_z: 0, 23 | volume: 1.0, 24 | pitch: 1.0, 25 | }, 26 | client, 27 | writer, 28 | ) 29 | .await 30 | } 31 | _ => { 32 | packet::write_packet( 33 | v1_17::game::NamedSoundEffect { 34 | sound_name: sound_name.into(), 35 | sound_category: 0, 36 | effect_pos_x: 0, 37 | effect_pos_y: 0, 38 | effect_pos_z: 0, 39 | volume: 1.0, 40 | pitch: 1.0, 41 | }, 42 | client, 43 | writer, 44 | ) 45 | .await 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/proto/packets/play/time_update.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::version::{v1_16_3, v1_17}; 2 | use tokio::net::tcp::WriteHalf; 3 | 4 | use crate::proto::client::{Client, ClientInfo}; 5 | use crate::proto::packet; 6 | 7 | /// Send lobby time update to client. 8 | /// 9 | /// Sets world time to 0. 10 | /// 11 | /// Required once for keep-alive packets. 12 | pub async fn send( 13 | client: &Client, 14 | client_info: &ClientInfo, 15 | writer: &mut WriteHalf<'_>, 16 | ) -> Result<(), ()> { 17 | match client_info.protocol() { 18 | Some(p) if p < v1_17::PROTOCOL => { 19 | packet::write_packet( 20 | v1_16_3::game::TimeUpdate { 21 | world_age: 0, 22 | time_of_day: 0, 23 | }, 24 | client, 25 | writer, 26 | ) 27 | .await 28 | } 29 | _ => { 30 | packet::write_packet( 31 | v1_17::game::TimeUpdate { 32 | world_age: 0, 33 | time_of_day: 0, 34 | }, 35 | client, 36 | writer, 37 | ) 38 | .await 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/proto/packets/play/title.rs: -------------------------------------------------------------------------------- 1 | use minecraft_protocol::data::chat::{Message, Payload}; 2 | use minecraft_protocol::version::{v1_16_3, v1_17}; 3 | use tokio::net::tcp::WriteHalf; 4 | 5 | #[cfg(feature = "lobby")] 6 | use crate::lobby::KEEP_ALIVE_INTERVAL; 7 | use crate::mc; 8 | use crate::proto::client::{Client, ClientInfo}; 9 | use crate::proto::packet; 10 | 11 | #[cfg(feature = "lobby")] 12 | const DISPLAY_TIME: i32 = KEEP_ALIVE_INTERVAL.as_secs() as i32 * mc::TICKS_PER_SECOND as i32 * 2; 13 | #[cfg(not(feature = "lobby"))] 14 | const DISPLAY_TIME: i32 = 10 * mc::TICKS_PER_SECOND as i32 * 2; 15 | 16 | /// Send lobby title packets to client. 17 | /// 18 | /// This will show the given text for two keep-alive periods. Use a newline for the subtitle. 19 | /// 20 | /// If an empty string is given, the title times will be reset to default. 21 | pub async fn send( 22 | client: &Client, 23 | client_info: &ClientInfo, 24 | writer: &mut WriteHalf<'_>, 25 | text: &str, 26 | ) -> Result<(), ()> { 27 | // Grab title and subtitle bits 28 | let title = text.lines().next().unwrap_or(""); 29 | let subtitle = text.lines().skip(1).collect::>().join("\n"); 30 | 31 | match client_info.protocol() { 32 | Some(p) if p < v1_17::PROTOCOL => send_v1_16_3(client, writer, title, &subtitle).await, 33 | _ => send_v1_17(client, writer, title, &subtitle).await, 34 | } 35 | } 36 | 37 | async fn send_v1_16_3( 38 | client: &Client, 39 | writer: &mut WriteHalf<'_>, 40 | title: &str, 41 | subtitle: &str, 42 | ) -> Result<(), ()> { 43 | use v1_16_3::game::{Title, TitleAction}; 44 | 45 | // Set title 46 | packet::write_packet( 47 | Title { 48 | action: TitleAction::SetTitle { 49 | text: Message::new(Payload::text(title)), 50 | }, 51 | }, 52 | client, 53 | writer, 54 | ) 55 | .await?; 56 | 57 | // Set subtitle 58 | packet::write_packet( 59 | Title { 60 | action: TitleAction::SetSubtitle { 61 | text: Message::new(Payload::text(subtitle)), 62 | }, 63 | }, 64 | client, 65 | writer, 66 | ) 67 | .await?; 68 | 69 | // Set title times 70 | packet::write_packet( 71 | Title { 72 | action: if title.is_empty() && subtitle.is_empty() { 73 | // Defaults: https://minecraft.wiki/w/Commands/title#Detail 74 | TitleAction::SetTimesAndDisplay { 75 | fade_in: 10, 76 | stay: 70, 77 | fade_out: 20, 78 | } 79 | } else { 80 | TitleAction::SetTimesAndDisplay { 81 | fade_in: 0, 82 | stay: DISPLAY_TIME, 83 | fade_out: 0, 84 | } 85 | }, 86 | }, 87 | client, 88 | writer, 89 | ) 90 | .await 91 | } 92 | 93 | async fn send_v1_17( 94 | client: &Client, 95 | writer: &mut WriteHalf<'_>, 96 | title: &str, 97 | subtitle: &str, 98 | ) -> Result<(), ()> { 99 | use v1_17::game::{SetTitleSubtitle, SetTitleText, SetTitleTimes}; 100 | 101 | // Set title 102 | packet::write_packet( 103 | SetTitleText { 104 | text: Message::new(Payload::text(title)), 105 | }, 106 | client, 107 | writer, 108 | ) 109 | .await?; 110 | 111 | // Set subtitle 112 | packet::write_packet( 113 | SetTitleSubtitle { 114 | text: Message::new(Payload::text(subtitle)), 115 | }, 116 | client, 117 | writer, 118 | ) 119 | .await?; 120 | 121 | // Set title times 122 | packet::write_packet( 123 | if title.is_empty() && subtitle.is_empty() { 124 | // Defaults: https://minecraft.wiki/w/Commands/title#Detail 125 | SetTitleTimes { 126 | fade_in: 10, 127 | stay: 70, 128 | fade_out: 20, 129 | } 130 | } else { 131 | SetTitleTimes { 132 | fade_in: 0, 133 | stay: DISPLAY_TIME, 134 | fade_out: 0, 135 | } 136 | }, 137 | client, 138 | writer, 139 | ) 140 | .await 141 | } 142 | -------------------------------------------------------------------------------- /src/proxy.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::net::SocketAddr; 3 | 4 | use bytes::BytesMut; 5 | use proxy_protocol::version2::{ProxyAddresses, ProxyCommand, ProxyTransportProtocol}; 6 | use proxy_protocol::EncodeError; 7 | use tokio::io; 8 | use tokio::io::AsyncWriteExt; 9 | use tokio::net::TcpStream; 10 | 11 | use crate::net; 12 | 13 | /// Proxy the inbound stream to a target address. 14 | pub async fn proxy( 15 | inbound: TcpStream, 16 | proxy_header: ProxyHeader, 17 | addr_target: SocketAddr, 18 | ) -> Result<(), Box> { 19 | proxy_with_queue(inbound, proxy_header, addr_target, &[]).await 20 | } 21 | 22 | /// Proxy the inbound stream to a target address. 23 | /// 24 | /// Send the queue to the target server before proxying. 25 | pub async fn proxy_with_queue( 26 | inbound: TcpStream, 27 | proxy_header: ProxyHeader, 28 | addr_target: SocketAddr, 29 | queue: &[u8], 30 | ) -> Result<(), Box> { 31 | // Set up connection to server 32 | // TODO: on connect fail, ping server and redirect to serve_status if offline 33 | let mut outbound = TcpStream::connect(addr_target).await?; 34 | 35 | // Add proxy header 36 | match proxy_header { 37 | ProxyHeader::None => {} 38 | ProxyHeader::Local => { 39 | let header = local_proxy_header()?; 40 | outbound.write_all(&header).await?; 41 | } 42 | ProxyHeader::Proxy => { 43 | let header = stream_proxy_header(&inbound)?; 44 | outbound.write_all(&header).await?; 45 | } 46 | } 47 | 48 | // Start proxy on both streams 49 | proxy_inbound_outbound_with_queue(inbound, outbound, &[], queue).await 50 | } 51 | 52 | /// Proxy the inbound stream to a target address. 53 | /// 54 | /// Send the queue to the target server before proxying. 55 | // TODO: find better name for this 56 | pub async fn proxy_inbound_outbound_with_queue( 57 | mut inbound: TcpStream, 58 | mut outbound: TcpStream, 59 | inbound_queue: &[u8], 60 | outbound_queue: &[u8], 61 | ) -> Result<(), Box> { 62 | let (mut ri, mut wi) = inbound.split(); 63 | let (mut ro, mut wo) = outbound.split(); 64 | 65 | // Forward queued bytes to client once writable 66 | if !inbound_queue.is_empty() { 67 | wi.writable().await?; 68 | trace!(target: "lazymc", "Relaying {} queued bytes to client", inbound_queue.len()); 69 | wi.write_all(inbound_queue).await?; 70 | } 71 | 72 | // Forward queued bytes to server once writable 73 | if !outbound_queue.is_empty() { 74 | wo.writable().await?; 75 | trace!(target: "lazymc", "Relaying {} queued bytes to server", outbound_queue.len()); 76 | wo.write_all(outbound_queue).await?; 77 | } 78 | 79 | let client_to_server = async { 80 | io::copy(&mut ri, &mut wo).await?; 81 | wo.shutdown().await 82 | }; 83 | let server_to_client = async { 84 | io::copy(&mut ro, &mut wi).await?; 85 | wi.shutdown().await 86 | }; 87 | 88 | tokio::try_join!(client_to_server, server_to_client)?; 89 | 90 | // Gracefully close connection if not done already 91 | net::close_tcp_stream(inbound).await?; 92 | 93 | Ok(()) 94 | } 95 | 96 | /// Proxy header. 97 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 98 | pub enum ProxyHeader { 99 | /// Do not add proxy header. 100 | None, 101 | 102 | /// Header for locally initiated connection. 103 | #[allow(unused)] 104 | Local, 105 | 106 | /// Header for proxied connection. 107 | Proxy, 108 | } 109 | 110 | impl ProxyHeader { 111 | /// Changes to `None` if `false` if given. 112 | /// 113 | /// `None` stays `None`. 114 | pub fn not_none(self, not_none: bool) -> Self { 115 | if not_none { 116 | self 117 | } else { 118 | Self::None 119 | } 120 | } 121 | } 122 | 123 | /// Get the proxy header for a locally initiated connection. 124 | /// 125 | /// This header may be sent over the outbound stream to signal client information. 126 | pub fn local_proxy_header() -> Result { 127 | // Build proxy header 128 | let header = proxy_protocol::ProxyHeader::Version2 { 129 | command: ProxyCommand::Local, 130 | transport_protocol: ProxyTransportProtocol::Stream, 131 | addresses: ProxyAddresses::Unspec, 132 | }; 133 | 134 | proxy_protocol::encode(header) 135 | } 136 | 137 | /// Get the proxy header for the given inbound stream. 138 | /// 139 | /// This header may be sent over the outbound stream to signal client information. 140 | pub fn stream_proxy_header(inbound: &TcpStream) -> Result { 141 | // Get peer and local address 142 | let peer = inbound 143 | .peer_addr() 144 | .expect("Peer address not known for TCP stream"); 145 | let local = inbound 146 | .local_addr() 147 | .expect("Local address not known for TCP stream"); 148 | 149 | // Build proxy header 150 | let header = proxy_protocol::ProxyHeader::Version2 { 151 | command: ProxyCommand::Proxy, 152 | transport_protocol: ProxyTransportProtocol::Stream, 153 | addresses: match (peer, local) { 154 | (SocketAddr::V4(source), SocketAddr::V4(destination)) => ProxyAddresses::Ipv4 { 155 | source, 156 | destination, 157 | }, 158 | (SocketAddr::V6(source), SocketAddr::V6(destination)) => ProxyAddresses::Ipv6 { 159 | source, 160 | destination, 161 | }, 162 | (_, _) => unreachable!(), 163 | }, 164 | }; 165 | 166 | proxy_protocol::encode(header) 167 | } 168 | -------------------------------------------------------------------------------- /src/service/file_watcher.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::sync::mpsc::channel; 3 | use std::sync::Arc; 4 | use std::time::Duration; 5 | 6 | use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher}; 7 | 8 | use crate::config::{Config, Server as ConfigServer}; 9 | use crate::mc::ban::{self, BannedIps}; 10 | use crate::mc::{server_properties, whitelist}; 11 | use crate::server::Server; 12 | 13 | /// File watcher debounce time. 14 | const WATCH_DEBOUNCE: Duration = Duration::from_secs(2); 15 | 16 | /// Service to watch server file changes. 17 | pub fn service(config: Arc, server: Arc) { 18 | // Ensure server directory is set, it must exist 19 | let dir = match ConfigServer::server_directory(&config) { 20 | Some(dir) if dir.is_dir() => dir, 21 | _ => { 22 | warn!(target: "lazymc", "Server directory doesn't exist, can't watch file changes to reload whitelist and banned IPs"); 23 | return; 24 | } 25 | }; 26 | 27 | // Keep watching 28 | #[allow(clippy::blocks_in_conditions)] 29 | while { 30 | // Update all files once 31 | reload_bans(&config, &server, &dir.join(ban::FILE)); 32 | reload_whitelist(&config, &server, &dir); 33 | 34 | // Watch for changes, update accordingly 35 | watch_server(&config, &server, &dir) 36 | } {} 37 | } 38 | 39 | /// Watch server directory. 40 | /// 41 | /// Returns `true` if we should watch again. 42 | #[must_use] 43 | fn watch_server(config: &Config, server: &Server, dir: &Path) -> bool { 44 | // Directory must exist 45 | if !dir.is_dir() { 46 | error!(target: "lazymc", "Server directory does not exist at {} anymore, not watching changes", dir.display()); 47 | return false; 48 | } 49 | 50 | // Create watcher for directory 51 | let (tx, rx) = channel(); 52 | let mut watcher = 53 | watcher(tx, WATCH_DEBOUNCE).expect("failed to create watcher for banned-ips.json"); 54 | if let Err(err) = watcher.watch(dir, RecursiveMode::NonRecursive) { 55 | error!(target: "lazymc", "An error occured while creating watcher for server files: {}", err); 56 | return true; 57 | } 58 | 59 | // Handle change events 60 | loop { 61 | match rx.recv().unwrap() { 62 | // Handle file updates 63 | DebouncedEvent::Create(ref path) 64 | | DebouncedEvent::Write(ref path) 65 | | DebouncedEvent::Remove(ref path) => { 66 | update(config, server, dir, path); 67 | } 68 | 69 | // Handle file updates on both paths for rename 70 | DebouncedEvent::Rename(ref before_path, ref after_path) => { 71 | update(config, server, dir, before_path); 72 | update(config, server, dir, after_path); 73 | } 74 | 75 | // Ignore write/remove notices, will receive write/remove event later 76 | DebouncedEvent::NoticeWrite(_) | DebouncedEvent::NoticeRemove(_) => {} 77 | 78 | // Ignore chmod changes 79 | DebouncedEvent::Chmod(_) => {} 80 | 81 | // Rewatch on rescan 82 | DebouncedEvent::Rescan => { 83 | debug!(target: "lazymc", "Rescanning server directory files due to file watching problem"); 84 | return true; 85 | } 86 | 87 | // Rewatch on error 88 | DebouncedEvent::Error(err, _) => { 89 | error!(target: "lazymc", "Error occurred while watching server directory for file changes: {}", err); 90 | return true; 91 | } 92 | } 93 | } 94 | } 95 | 96 | /// Process a file change on the given path. 97 | /// 98 | /// Should be called both when created, changed or removed. 99 | fn update(config: &Config, server: &Server, dir: &Path, path: &Path) { 100 | // Update bans 101 | if path.ends_with(ban::FILE) { 102 | reload_bans(config, server, path); 103 | } 104 | 105 | // Update whitelist 106 | if path.ends_with(whitelist::WHITELIST_FILE) 107 | || path.ends_with(whitelist::OPS_FILE) 108 | || path.ends_with(server_properties::FILE) 109 | { 110 | reload_whitelist(config, server, dir); 111 | } 112 | } 113 | 114 | /// Reload banned IPs. 115 | fn reload_bans(config: &Config, server: &Server, path: &Path) { 116 | // Bans must be enabled 117 | if !config.server.block_banned_ips && !config.server.drop_banned_ips { 118 | return; 119 | } 120 | 121 | trace!(target: "lazymc", "Reloading banned IPs..."); 122 | 123 | // File must exist, clear file otherwise 124 | if !path.is_file() { 125 | debug!(target: "lazymc", "No banned IPs, {} does not exist", ban::FILE); 126 | // warn!(target: "lazymc", "Not blocking banned IPs, {} file does not exist", ban::FILE); 127 | server.set_banned_ips_blocking(BannedIps::default()); 128 | return; 129 | } 130 | 131 | // Load and update banned IPs 132 | match ban::load(path) { 133 | Ok(ips) => server.set_banned_ips_blocking(ips), 134 | Err(err) => { 135 | debug!(target: "lazymc", "Failed load banned IPs from {}, ignoring: {}", ban::FILE, err); 136 | } 137 | } 138 | 139 | // Show warning if 127.0.0.1 is banned 140 | if server.is_banned_ip_blocking(&("127.0.0.1".parse().unwrap())) { 141 | warn!(target: "lazymc", "Local address 127.0.0.1 IP banned, probably not what you want"); 142 | warn!(target: "lazymc", "Use '/pardon-ip 127.0.0.1' on the server to unban"); 143 | } 144 | } 145 | 146 | /// Reload whitelisted users. 147 | fn reload_whitelist(config: &Config, server: &Server, dir: &Path) { 148 | // Whitelist must be enabled 149 | if !config.server.wake_whitelist { 150 | return; 151 | } 152 | 153 | // Must be enabled in server.properties 154 | let enabled = server_properties::read_property(dir.join(server_properties::FILE), "white-list") 155 | .map(|v| v.trim() == "true") 156 | .unwrap_or(false); 157 | if !enabled { 158 | server.set_whitelist_blocking(None); 159 | debug!(target: "lazymc", "Not using whitelist, not enabled in {}", server_properties::FILE); 160 | return; 161 | } 162 | 163 | trace!(target: "lazymc", "Reloading whitelisted users..."); 164 | 165 | // Load and update whitelisted users 166 | match whitelist::load_dir(dir) { 167 | Ok(whitelist) => server.set_whitelist_blocking(Some(whitelist)), 168 | Err(err) => { 169 | debug!(target: "lazymc", "Failed load whitelist from {}, ignoring: {}", dir.display(), err); 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_watcher; 2 | pub mod monitor; 3 | pub mod probe; 4 | pub mod server; 5 | pub mod signal; 6 | -------------------------------------------------------------------------------- /src/service/monitor.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::config::Config; 4 | use crate::monitor; 5 | use crate::server::Server; 6 | 7 | /// Server monitor task. 8 | pub async fn service(config: Arc, state: Arc) { 9 | monitor::monitor_server(config, state).await 10 | } 11 | -------------------------------------------------------------------------------- /src/service/probe.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::config::{Config, Method}; 4 | use crate::probe; 5 | use crate::server::Server; 6 | 7 | /// Probe server. 8 | pub async fn service(config: Arc, state: Arc) { 9 | // Only probe if enabled or if we must 10 | if !config.server.probe_on_start && !must_probe(&config) { 11 | return; 12 | } 13 | 14 | // Probe 15 | match probe::probe(config, state).await { 16 | Ok(_) => info!(target: "lazymc::probe", "Succesfully probed server"), 17 | Err(_) => { 18 | error!(target: "lazymc::probe", "Failed to probe server, this may limit lazymc features") 19 | } 20 | } 21 | } 22 | 23 | /// Check whether we must probe. 24 | fn must_probe(config: &Config) -> bool { 25 | // Must probe with lobby and Forge 26 | if config.server.forge && config.join.methods.contains(&Method::Lobby) { 27 | warn!(target: "lazymc::probe", "Starting server to probe for Forge lobby..."); 28 | warn!(target: "lazymc::probe", "Set 'server.probe_on_start = true' to remove this warning"); 29 | return true; 30 | } 31 | 32 | false 33 | } 34 | -------------------------------------------------------------------------------- /src/service/server.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | use std::sync::Arc; 3 | 4 | use bytes::BytesMut; 5 | use futures::FutureExt; 6 | use tokio::net::{TcpListener, TcpStream}; 7 | 8 | use crate::config::Config; 9 | use crate::proto::client::Client; 10 | use crate::proxy::{self, ProxyHeader}; 11 | use crate::server::{self, Server}; 12 | use crate::service; 13 | use crate::status; 14 | use crate::util::error::{quit_error, ErrorHints}; 15 | 16 | /// Start lazymc. 17 | /// 18 | /// Main entrypoint to start all server/status/proxy logic. 19 | /// 20 | /// Spawns a tokio runtime to complete all work on. 21 | #[tokio::main(flavor = "multi_thread")] 22 | pub async fn service(config: Arc) -> Result<(), ()> { 23 | // Load server state 24 | let server = Arc::new(Server::default()); 25 | 26 | // Listen for new connections 27 | let listener = TcpListener::bind(config.public.address) 28 | .await 29 | .map_err(|err| { 30 | quit_error( 31 | anyhow!(err).context("Failed to start proxy server"), 32 | ErrorHints::default(), 33 | ); 34 | })?; 35 | 36 | info!( 37 | target: "lazymc", 38 | "Proxying public {} to server {}", 39 | config.public.address, config.server.address, 40 | ); 41 | 42 | if config.lockout.enabled { 43 | warn!( 44 | target: "lazymc", 45 | "Lockout mode is enabled, nobody will be able to connect through the proxy", 46 | ); 47 | } 48 | 49 | // Spawn services: monitor, signal handler 50 | tokio::spawn(service::monitor::service(config.clone(), server.clone())); 51 | tokio::spawn(service::signal::service(config.clone(), server.clone())); 52 | 53 | // Initiate server start 54 | if config.server.wake_on_start { 55 | Server::start(config.clone(), server.clone(), None).await; 56 | } 57 | 58 | // Spawn additional services: probe and ban manager 59 | tokio::spawn(service::probe::service(config.clone(), server.clone())); 60 | tokio::task::spawn_blocking({ 61 | let (config, server) = (config.clone(), server.clone()); 62 | || service::file_watcher::service(config, server) 63 | }); 64 | 65 | // Route all incomming connections 66 | while let Ok((inbound, _)) = listener.accept().await { 67 | route(inbound, config.clone(), server.clone()); 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | /// Route inbound TCP stream to correct service, spawning a new task. 74 | #[inline] 75 | fn route(inbound: TcpStream, config: Arc, server: Arc) { 76 | // Get user peer address 77 | let peer = match inbound.peer_addr() { 78 | Ok(peer) => peer, 79 | Err(err) => { 80 | warn!(target: "lazymc", "Connection from unknown peer address, disconnecting: {}", err); 81 | return; 82 | } 83 | }; 84 | 85 | // Check ban state, just drop connection if enabled 86 | let banned = server.is_banned_ip_blocking(&peer.ip()); 87 | if banned && config.server.drop_banned_ips { 88 | info!(target: "lazymc", "Connection from banned IP {}, dropping", peer.ip()); 89 | return; 90 | } 91 | 92 | // Route connection through proper channel 93 | let should_proxy = 94 | !banned && server.state() == server::State::Started && !config.lockout.enabled; 95 | if should_proxy { 96 | route_proxy(inbound, config) 97 | } else { 98 | route_status(inbound, config, server, peer) 99 | } 100 | } 101 | 102 | /// Route inbound TCP stream to status server, spawning a new task. 103 | #[inline] 104 | fn route_status(inbound: TcpStream, config: Arc, server: Arc, peer: SocketAddr) { 105 | // When server is not online, spawn a status server 106 | let client = Client::new(peer); 107 | let service = status::serve(client, inbound, config, server).map(|r| { 108 | if let Err(err) = r { 109 | warn!(target: "lazymc", "Failed to serve status: {:?}", err); 110 | } 111 | }); 112 | 113 | tokio::spawn(service); 114 | } 115 | 116 | /// Route inbound TCP stream to proxy, spawning a new task. 117 | #[inline] 118 | fn route_proxy(inbound: TcpStream, config: Arc) { 119 | // When server is online, proxy all 120 | let service = proxy::proxy( 121 | inbound, 122 | ProxyHeader::Proxy.not_none(config.server.send_proxy_v2), 123 | config.server.address, 124 | ) 125 | .map(|r| { 126 | if let Err(err) = r { 127 | warn!(target: "lazymc", "Failed to proxy: {}", err); 128 | } 129 | }); 130 | 131 | tokio::spawn(service); 132 | } 133 | 134 | /// Route inbound TCP stream to proxy with queued data, spawning a new task. 135 | #[inline] 136 | pub fn route_proxy_queue(inbound: TcpStream, config: Arc, queue: BytesMut) { 137 | route_proxy_address_queue( 138 | inbound, 139 | ProxyHeader::Proxy.not_none(config.server.send_proxy_v2), 140 | config.server.address, 141 | queue, 142 | ); 143 | } 144 | 145 | /// Route inbound TCP stream to proxy with given address and queued data, spawning a new task. 146 | #[inline] 147 | pub fn route_proxy_address_queue( 148 | inbound: TcpStream, 149 | proxy_header: ProxyHeader, 150 | addr: SocketAddr, 151 | queue: BytesMut, 152 | ) { 153 | // When server is online, proxy all 154 | let service = async move { 155 | proxy::proxy_with_queue(inbound, proxy_header, addr, &queue) 156 | .map(|r| { 157 | if let Err(err) = r { 158 | warn!(target: "lazymc", "Failed to proxy: {}", err); 159 | } 160 | }) 161 | .await 162 | }; 163 | 164 | tokio::spawn(service); 165 | } 166 | -------------------------------------------------------------------------------- /src/service/signal.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::config::Config; 4 | use crate::server::{self, Server}; 5 | use crate::util::error; 6 | 7 | /// Signal handler task. 8 | pub async fn service(config: Arc, server: Arc) { 9 | loop { 10 | // Wait for SIGTERM/SIGINT signal 11 | tokio::signal::ctrl_c().await.unwrap(); 12 | 13 | // Quit if stopped 14 | if server.state() == server::State::Stopped { 15 | quit(); 16 | } 17 | 18 | // Try to stop server 19 | let stopping = server.stop(&config).await; 20 | 21 | // If not stopping, maybe due to failure, just quit 22 | if !stopping { 23 | quit(); 24 | } 25 | } 26 | } 27 | 28 | /// Gracefully quit. 29 | fn quit() -> ! { 30 | // TODO: gracefully quit self 31 | error::quit(); 32 | } 33 | -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bytes::BytesMut; 4 | use minecraft_protocol::data::server_status::{OnlinePlayers, ServerVersion}; 5 | use minecraft_protocol::decoder::Decoder; 6 | use minecraft_protocol::encoder::Encoder; 7 | use minecraft_protocol::version::v1_14_4::handshake::Handshake; 8 | use minecraft_protocol::version::v1_14_4::login::LoginStart; 9 | use minecraft_protocol::version::v1_20_3::status::{ServerStatus, StatusResponse}; 10 | use tokio::fs; 11 | use tokio::io::AsyncWriteExt; 12 | use tokio::net::TcpStream; 13 | 14 | use crate::config::{Config, Server as ConfigServer}; 15 | use crate::join; 16 | use crate::mc::favicon; 17 | use crate::proto::action; 18 | use crate::proto::client::{Client, ClientInfo, ClientState}; 19 | use crate::proto::packet::{self, RawPacket}; 20 | use crate::proto::packets; 21 | use crate::server::{self, Server}; 22 | 23 | /// The ban message prefix. 24 | const BAN_MESSAGE_PREFIX: &str = "Your IP address is banned from this server.\nReason: "; 25 | 26 | /// Default ban reason if unknown. 27 | const DEFAULT_BAN_REASON: &str = "Banned by an operator."; 28 | 29 | /// The not-whitelisted kick message. 30 | const WHITELIST_MESSAGE: &str = "You are not white-listed on this server!"; 31 | 32 | /// Server icon file path. 33 | const SERVER_ICON_FILE: &str = "server-icon.png"; 34 | 35 | /// Proxy the given inbound stream to a target address. 36 | // TODO: do not drop error here, return Box 37 | pub async fn serve( 38 | client: Client, 39 | mut inbound: TcpStream, 40 | config: Arc, 41 | server: Arc, 42 | ) -> Result<(), ()> { 43 | let (mut reader, mut writer) = inbound.split(); 44 | 45 | // Incoming buffer and packet holding queue 46 | let mut buf = BytesMut::new(); 47 | 48 | // Remember inbound packets, track client info 49 | let mut inbound_history = BytesMut::new(); 50 | let mut client_info = ClientInfo::empty(); 51 | 52 | loop { 53 | // Read packet from stream 54 | let (packet, raw) = match packet::read_packet(&client, &mut buf, &mut reader).await { 55 | Ok(Some(packet)) => packet, 56 | Ok(None) => break, 57 | Err(_) => { 58 | error!(target: "lazymc", "Closing connection, error occurred"); 59 | break; 60 | } 61 | }; 62 | 63 | // Grab client state 64 | let client_state = client.state(); 65 | 66 | // Hijack handshake 67 | if client_state == ClientState::Handshake 68 | && packet.id == packets::handshake::SERVER_HANDSHAKE 69 | { 70 | // Parse handshake 71 | let handshake = match Handshake::decode(&mut packet.data.as_slice()) { 72 | Ok(handshake) => handshake, 73 | Err(_) => { 74 | debug!(target: "lazymc", "Got malformed handshake from client, disconnecting"); 75 | break; 76 | } 77 | }; 78 | 79 | // Parse new state 80 | let new_state = match ClientState::from_id(handshake.next_state) { 81 | Some(state) => state, 82 | None => { 83 | error!(target: "lazymc", "Client tried to switch into unknown protcol state ({}), disconnecting", handshake.next_state); 84 | break; 85 | } 86 | }; 87 | 88 | // Update client info and client state 89 | client_info 90 | .protocol 91 | .replace(handshake.protocol_version as u32); 92 | client_info.handshake.replace(handshake); 93 | client.set_state(new_state); 94 | 95 | // If loggin in with handshake, remember inbound 96 | if new_state == ClientState::Login { 97 | inbound_history.extend(raw); 98 | } 99 | 100 | continue; 101 | } 102 | 103 | // Hijack server status packet 104 | if client_state == ClientState::Status && packet.id == packets::status::SERVER_STATUS { 105 | let server_status = server_status(&client_info, &config, &server).await; 106 | let packet = StatusResponse { server_status }; 107 | 108 | let mut data = Vec::new(); 109 | packet.encode(&mut data).map_err(|_| ())?; 110 | 111 | let response = RawPacket::new(0, data).encode_with_len(&client)?; 112 | writer.write_all(&response).await.map_err(|_| ())?; 113 | 114 | continue; 115 | } 116 | 117 | // Hijack ping packet 118 | if client_state == ClientState::Status && packet.id == packets::status::SERVER_PING { 119 | writer.write_all(&raw).await.map_err(|_| ())?; 120 | continue; 121 | } 122 | 123 | // Hijack login start 124 | if client_state == ClientState::Login && packet.id == packets::login::SERVER_LOGIN_START { 125 | // Try to get login username, update client info 126 | // TODO: we should always parse this packet successfully 127 | let username = LoginStart::decode(&mut packet.data.as_slice()) 128 | .ok() 129 | .map(|p| p.name); 130 | client_info.username = username.clone(); 131 | 132 | // Kick if lockout is enabled 133 | if config.lockout.enabled { 134 | match username { 135 | Some(username) => { 136 | info!(target: "lazymc", "Kicked '{}' because lockout is enabled", username) 137 | } 138 | None => info!(target: "lazymc", "Kicked player because lockout is enabled"), 139 | } 140 | action::kick(&client, &config.lockout.message, &mut writer).await?; 141 | break; 142 | } 143 | 144 | // Kick if client is banned 145 | if let Some(ban) = server.ban_entry(&client.peer.ip()).await { 146 | if ban.is_banned() { 147 | let msg = if let Some(reason) = ban.reason { 148 | info!(target: "lazymc", "Login from banned IP {} ({}), disconnecting", client.peer.ip(), &reason); 149 | reason.to_string() 150 | } else { 151 | info!(target: "lazymc", "Login from banned IP {}, disconnecting", client.peer.ip()); 152 | DEFAULT_BAN_REASON.to_string() 153 | }; 154 | action::kick(&client, &format!("{BAN_MESSAGE_PREFIX}{msg}"), &mut writer) 155 | .await?; 156 | break; 157 | } 158 | } 159 | 160 | // Kick if client is not whitelisted to wake server 161 | if let Some(ref username) = username { 162 | if !server.is_whitelisted(username).await { 163 | info!(target: "lazymc", "User '{}' tried to wake server but is not whitelisted, disconnecting", username); 164 | action::kick(&client, WHITELIST_MESSAGE, &mut writer).await?; 165 | break; 166 | } 167 | } 168 | 169 | // Start server if not starting yet 170 | Server::start(config.clone(), server.clone(), username).await; 171 | 172 | // Remember inbound packets 173 | inbound_history.extend(&raw); 174 | inbound_history.extend(&buf); 175 | 176 | // Build inbound packet queue with everything from login start (including this) 177 | let mut login_queue = BytesMut::with_capacity(raw.len() + buf.len()); 178 | login_queue.extend(&raw); 179 | login_queue.extend(&buf); 180 | 181 | // Buf is fully consumed here 182 | buf.clear(); 183 | 184 | // Start occupying client 185 | join::occupy( 186 | client, 187 | client_info, 188 | config, 189 | server, 190 | inbound, 191 | inbound_history, 192 | login_queue, 193 | ) 194 | .await?; 195 | return Ok(()); 196 | } 197 | 198 | // Show unhandled packet warning 199 | debug!(target: "lazymc", "Got unhandled packet:"); 200 | debug!(target: "lazymc", "- State: {:?}", client_state); 201 | debug!(target: "lazymc", "- Packet ID: {}", packet.id); 202 | } 203 | 204 | Ok(()) 205 | } 206 | 207 | /// Build server status object to respond to client with. 208 | async fn server_status(client_info: &ClientInfo, config: &Config, server: &Server) -> ServerStatus { 209 | let status = server.status().await; 210 | let server_state = server.state(); 211 | 212 | // Respond with real server status if started 213 | if server_state == server::State::Started && status.is_some() { 214 | return status.as_ref().unwrap().clone(); 215 | } 216 | 217 | // Select version and player max from last known server status 218 | let (version, max) = match status.as_ref() { 219 | Some(status) => (status.version.clone(), status.players.max), 220 | None => ( 221 | ServerVersion { 222 | name: config.public.version.clone(), 223 | protocol: config.public.protocol, 224 | }, 225 | 0, 226 | ), 227 | }; 228 | 229 | // Select description, use server MOTD if enabled, or use configured 230 | let description = { 231 | if config.motd.from_server && status.is_some() { 232 | status.as_ref().unwrap().description.clone() 233 | } else { 234 | match server_state { 235 | server::State::Stopped | server::State::Started => config.motd.sleeping.clone(), 236 | server::State::Starting => config.motd.starting.clone(), 237 | server::State::Stopping => config.motd.stopping.clone(), 238 | } 239 | } 240 | }; 241 | 242 | // Extract favicon from real server status, load from disk, or use default 243 | let mut favicon = None; 244 | if favicon::supports_favicon(client_info) { 245 | if config.motd.from_server && status.is_some() { 246 | favicon = status.as_ref().unwrap().favicon.clone() 247 | } 248 | if favicon.is_none() { 249 | favicon = Some(server_favicon(config).await); 250 | } 251 | } 252 | 253 | // Build status resposne 254 | ServerStatus { 255 | version, 256 | description, 257 | players: OnlinePlayers { 258 | online: 0, 259 | max, 260 | sample: vec![], 261 | }, 262 | favicon, 263 | } 264 | } 265 | 266 | /// Get server status favicon. 267 | /// 268 | /// This always returns a favicon, returning the default one if none is set. 269 | async fn server_favicon(config: &Config) -> String { 270 | // Get server dir 271 | let dir = match ConfigServer::server_directory(config) { 272 | Some(dir) => dir, 273 | None => return favicon::default_favicon(), 274 | }; 275 | 276 | // Server icon file, ensure it exists 277 | let path = dir.join(SERVER_ICON_FILE); 278 | if !path.is_file() { 279 | return favicon::default_favicon(); 280 | } 281 | 282 | // Read icon data 283 | let data = fs::read(path).await.unwrap_or_else(|err| { 284 | error!(target: "lazymc::status", "Failed to read favicon from {}, using default: {err}", SERVER_ICON_FILE); 285 | favicon::default_favicon().into_bytes() 286 | }); 287 | 288 | favicon::encode_favicon(&data) 289 | } 290 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | /// Try to read var-int from data buffer. 2 | pub fn read_var_int(buf: &[u8]) -> Result<(usize, i32), ()> { 3 | for len in 1..=5.min(buf.len()) { 4 | // Find var-int byte size 5 | let extra_byte = (buf[len - 1] & (1 << 7)) > 0; 6 | if extra_byte { 7 | continue; 8 | } 9 | 10 | // Select var-int bytes 11 | let buf = &buf[..len]; 12 | 13 | // Parse var-int, return result 14 | return match minecraft_protocol::decoder::var_int::decode(&mut &*buf) { 15 | Ok(val) => Ok((len, val)), 16 | Err(_) => Err(()), 17 | }; 18 | } 19 | 20 | // The buffer wasn't complete or the var-int is invalid 21 | Err(()) 22 | } 23 | 24 | /// Encode integer into a var-int. 25 | pub fn encode_var_int(i: i32) -> Result, ()> { 26 | let mut buf = Vec::with_capacity(5); 27 | minecraft_protocol::encoder::var_int::encode(&i, &mut buf).map_err(|_| ())?; 28 | Ok(buf) 29 | } 30 | -------------------------------------------------------------------------------- /src/util/cli.rs: -------------------------------------------------------------------------------- 1 | // Allow dead code, until we've fully implemented CLI/error handling 2 | #![allow(dead_code)] 3 | 4 | use std::io::{stderr, stdin, Write}; 5 | 6 | use crate::util::error::{quit_error, ErrorHints}; 7 | 8 | /// Prompt the user to enter some value. 9 | /// The prompt that is shown should be passed to `msg`, 10 | /// excluding the `:` suffix. 11 | pub fn prompt(msg: &str) -> String { 12 | // Show the prompt 13 | eprint!("{msg}: "); 14 | let _ = stderr().flush(); 15 | 16 | // Get the input 17 | let mut input = String::new(); 18 | if let Err(err) = stdin() 19 | .read_line(&mut input) 20 | .map_err(|err| -> anyhow::Error { err.into() }) 21 | { 22 | quit_error( 23 | err.context("failed to read input from prompt"), 24 | ErrorHints::default(), 25 | ); 26 | } 27 | 28 | // Trim and return 29 | input.trim().to_owned() 30 | } 31 | 32 | /// Prompt the user for a question, allowing a yes or now answer. 33 | /// True is returned if yes was answered, false if no. 34 | /// 35 | /// A default may be given, which is chosen if no-interact mode is 36 | /// enabled, or if enter was pressed by the user without entering anything. 37 | pub fn prompt_yes(msg: &str, def: Option) -> bool { 38 | // Define the available options string 39 | let options = format!( 40 | "[{}/{}]", 41 | match def { 42 | Some(def) if def => "Y", 43 | _ => "y", 44 | }, 45 | match def { 46 | Some(def) if !def => "N", 47 | _ => "n", 48 | } 49 | ); 50 | 51 | // Get the user input 52 | let answer = prompt(&format!("{msg} {options}")); 53 | 54 | // Assume the default if the answer is empty 55 | if answer.is_empty() { 56 | if let Some(def) = def { 57 | return def; 58 | } 59 | } 60 | 61 | // Derive a boolean and return 62 | match derive_bool(&answer) { 63 | Some(answer) => answer, 64 | None => prompt_yes(msg, def), 65 | } 66 | } 67 | 68 | /// Try to derive true or false (yes or no) from the given input. 69 | /// None is returned if no boolean could be derived accurately. 70 | fn derive_bool(input: &str) -> Option { 71 | // Process the input 72 | let input = input.trim().to_lowercase(); 73 | 74 | // Handle short or incomplete answers 75 | match input.as_str() { 76 | "y" | "ye" | "t" | "1" => return Some(true), 77 | "n" | "f" | "0" => return Some(false), 78 | _ => {} 79 | } 80 | 81 | // Handle complete answers with any suffix 82 | if input.starts_with("yes") || input.starts_with("true") { 83 | return Some(true); 84 | } 85 | if input.starts_with("no") || input.starts_with("false") { 86 | return Some(false); 87 | } 88 | 89 | // The answer could not be determined, return none 90 | None 91 | } 92 | -------------------------------------------------------------------------------- /src/util/error.rs: -------------------------------------------------------------------------------- 1 | // Allow dead code, until we've fully implemented CLI/error handling 2 | #![allow(dead_code)] 3 | 4 | use std::borrow::Borrow; 5 | use std::fmt::{Debug, Display}; 6 | use std::io::{self, Write}; 7 | pub use std::process::exit; 8 | 9 | use anyhow::anyhow; 10 | 11 | use crate::util::style::{highlight, highlight_error, highlight_info, highlight_warning}; 12 | 13 | /// Print the given error in a proper format for the user, 14 | /// with it's causes. 15 | pub fn print_error(err: anyhow::Error) { 16 | // Report each printable error, count them 17 | let count = err 18 | .chain() 19 | .map(|err| err.to_string()) 20 | .filter(|err| !err.is_empty()) 21 | .enumerate() 22 | .map(|(i, err)| { 23 | if i == 0 { 24 | eprintln!("{} {}", highlight_error("error:"), err); 25 | } else { 26 | eprintln!("{} {}", highlight_error("caused by:"), err); 27 | } 28 | }) 29 | .count(); 30 | 31 | // Fall back to a basic message 32 | if count == 0 { 33 | eprintln!("{} an undefined error occurred", highlight_error("error:"),); 34 | } 35 | } 36 | 37 | /// Print the given error message in a proper format for the user, 38 | /// with it's causes. 39 | pub fn print_error_msg(err: S) 40 | where 41 | S: AsRef + Display + Debug + Sync + Send + 'static, 42 | { 43 | print_error(anyhow!(err)); 44 | } 45 | 46 | /// Print a warning. 47 | pub fn print_warning(err: S) 48 | where 49 | S: AsRef + Display + Debug + Sync + Send + 'static, 50 | { 51 | eprintln!("{} {}", highlight_warning("warning:"), err); 52 | } 53 | 54 | /// Quit the application regularly. 55 | pub fn quit() -> ! { 56 | exit(0); 57 | } 58 | 59 | /// Quit the application with an error code, 60 | /// and print the given error. 61 | pub fn quit_error(err: anyhow::Error, hints: impl Borrow) -> ! { 62 | // Print the error 63 | print_error(err); 64 | 65 | // Print error hints 66 | hints.borrow().print(false); 67 | 68 | // Quit 69 | exit(1); 70 | } 71 | 72 | /// Quit the application with an error code, 73 | /// and print the given error message. 74 | pub fn quit_error_msg(err: S, hints: impl Borrow) -> ! 75 | where 76 | S: AsRef + Display + Debug + Sync + Send + 'static, 77 | { 78 | quit_error(anyhow!(err), hints); 79 | } 80 | 81 | /// The error hint configuration. 82 | #[derive(Clone, Builder)] 83 | #[builder(default)] 84 | pub struct ErrorHints { 85 | /// A list of info messages to print along with the error. 86 | info: Vec, 87 | 88 | /// Show about the config flag. 89 | config: bool, 90 | 91 | /// Show about the config generate command. 92 | config_generate: bool, 93 | 94 | /// Show about the config test command. 95 | config_test: bool, 96 | 97 | /// Show about the verbose flag. 98 | verbose: bool, 99 | 100 | /// Show about the help flag. 101 | help: bool, 102 | } 103 | 104 | impl ErrorHints { 105 | /// Check whether any hint should be printed. 106 | pub fn any(&self) -> bool { 107 | self.config || self.config_generate || self.config_test || self.verbose || self.help 108 | } 109 | 110 | /// Print the error hints. 111 | pub fn print(&self, end_newline: bool) { 112 | // Print info messages 113 | for msg in &self.info { 114 | eprintln!("{} {}", highlight_info("info:"), msg); 115 | } 116 | 117 | // Stop if nothing should be printed 118 | if !self.any() { 119 | return; 120 | } 121 | 122 | eprintln!(); 123 | 124 | // Print hints 125 | let bin = crate::util::bin_name(); 126 | if self.config_generate { 127 | eprintln!( 128 | "Use '{}' to generate a new config file", 129 | highlight(&format!("{bin} config generate")) 130 | ); 131 | } 132 | if self.config { 133 | eprintln!( 134 | "Use '{}' to select a config file", 135 | highlight("--config FILE") 136 | ); 137 | } 138 | if self.config_test { 139 | eprintln!( 140 | "Use '{}' to test a config file", 141 | highlight(&format!("{bin} config test -c FILE")) 142 | ); 143 | } 144 | if self.verbose { 145 | eprintln!("For a detailed log add '{}'", highlight("--verbose")); 146 | } 147 | if self.help { 148 | eprintln!("For more information add '{}'", highlight("--help")); 149 | } 150 | 151 | // End with additional newline 152 | if end_newline { 153 | eprintln!(); 154 | } 155 | 156 | // Flush 157 | let _ = io::stderr().flush(); 158 | } 159 | } 160 | 161 | impl Default for ErrorHints { 162 | fn default() -> Self { 163 | ErrorHints { 164 | info: Vec::new(), 165 | config: false, 166 | config_generate: false, 167 | config_test: false, 168 | verbose: true, 169 | help: true, 170 | } 171 | } 172 | } 173 | 174 | impl ErrorHintsBuilder { 175 | /// Add a single info entry. 176 | pub fn add_info(mut self, info: String) -> Self { 177 | // Initialize the info list 178 | if self.info.is_none() { 179 | self.info = Some(Vec::new()); 180 | } 181 | 182 | // Add the item to the info list 183 | if let Some(ref mut list) = self.info { 184 | list.push(info); 185 | } 186 | 187 | self 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod error; 3 | pub mod serde; 4 | pub mod style; 5 | 6 | use std::env; 7 | use std::path::PathBuf; 8 | 9 | /// Get the name of the executable that was invoked. 10 | /// 11 | /// When a symbolic or hard link is used, the name of the link is returned. 12 | /// 13 | /// This attempts to obtain the binary name in the following order: 14 | /// - name in first item of program arguments via `std::env::args` 15 | /// - current executable name via `std::env::current_exe` 16 | /// - crate name 17 | pub fn bin_name() -> String { 18 | env::args_os() 19 | .next() 20 | .filter(|path| !path.is_empty()) 21 | .map(PathBuf::from) 22 | .or_else(|| env::current_exe().ok()) 23 | .and_then(|p| p.file_name().map(|n| n.to_owned())) 24 | .and_then(|n| n.into_string().ok()) 25 | .unwrap_or_else(|| crate_name!().into()) 26 | } 27 | -------------------------------------------------------------------------------- /src/util/serde.rs: -------------------------------------------------------------------------------- 1 | use std::net::{SocketAddr, ToSocketAddrs}; 2 | 3 | use serde::de::{Error, Unexpected}; 4 | use serde::{Deserialize, Deserializer}; 5 | 6 | /// Deserialize a `Vec` into a `HashMap` by key. 7 | pub fn to_socket_addrs<'de, D>(d: D) -> Result 8 | where 9 | D: Deserializer<'de>, 10 | { 11 | // Deserialize string 12 | let addr = String::deserialize(d)?; 13 | 14 | // Try to socket address to resolve 15 | match addr.to_socket_addrs() { 16 | Ok(mut addr) => { 17 | if let Some(addr) = addr.next() { 18 | return Ok(addr); 19 | } 20 | } 21 | Err(err) => { 22 | dbg!(err); 23 | } 24 | } 25 | 26 | // Parse raw IP address 27 | addr.parse().map_err(|_| { 28 | Error::invalid_value(Unexpected::Str(&addr), &"IP or resolvable host and port") 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/util/style.rs: -------------------------------------------------------------------------------- 1 | use colored::{ColoredString, Colorize}; 2 | 3 | /// Highlight the given text with a color. 4 | pub fn highlight(msg: &str) -> ColoredString { 5 | msg.yellow() 6 | } 7 | 8 | /// Highlight the given text with an error color. 9 | pub fn highlight_error(msg: &str) -> ColoredString { 10 | msg.red().bold() 11 | } 12 | 13 | /// Highlight the given text with an warning color. 14 | pub fn highlight_warning(msg: &str) -> ColoredString { 15 | highlight(msg).bold() 16 | } 17 | 18 | /// Highlight the given text with an info color 19 | pub fn highlight_info(msg: &str) -> ColoredString { 20 | msg.cyan() 21 | } 22 | --------------------------------------------------------------------------------