195 Commits

Author SHA1 Message Date
timvisee
d058164aa6 Bump version to 0.2.11 2024-03-16 18:08:26 +01:00
timvisee
8ed68b1ddf Bump MSRV to 1.74.0 2024-03-16 18:05:04 +01:00
timvisee
0eec1e0b55 Use 1.20.3 protocol version 2024-03-16 18:01:03 +01:00
timvisee
485941cf81 Update uuid dependency 2024-03-16 13:45:26 +01:00
timvisee
a1008ad2a7 Update nix dependency 2024-03-16 13:31:42 +01:00
timvisee
b22d32b951 Upgrade toml dependency 2024-03-16 13:31:38 +01:00
timvisee
18f26b00cb Update dependencies around rcon 2024-03-16 13:31:35 +01:00
timvisee
86428f4501 Update dependencies 2024-03-16 12:54:48 +01:00
timvisee
c311313ecb Improve error handling when reading server favicon 2024-03-16 12:50:32 +01:00
timvisee
6e6d098cf1 Resolve clippy warnings 2024-03-16 12:50:25 +01:00
timvisee
efb047114e Update Cargo.lock 2024-03-16 12:43:03 +01:00
Tim Visée
37fdb9c12a Merge pull request #54 from Spongecade/patch-1
Update Minecraft wiki links to new domain
2023-10-13 18:53:23 +02:00
Spongecade
e7a3db19aa Update Minecraft wiki links to new domain 2023-10-05 15:26:24 -05:00
timvisee
ca4753673d Run CI on shared GitLab runners 2023-08-20 17:33:32 +02:00
timvisee
0124aa723d Merge branch 'Xeyler-master' into master
Refs: https://github.com/timvisee/lazymc/pull/50
2023-05-30 22:41:04 +02:00
Xeyler
afbc54758c Update monitor.rs 2023-05-27 23:14:48 -06:00
timvisee
e54025f02f Bump version to 0.2.10 2023-02-20 10:34:06 +01:00
timvisee
023e46fe64 Allow exit code 143 and 130
Fixes https://github.com/timvisee/lazymc/issues/26
2023-02-20 10:31:04 +01:00
timvisee
6622962d5d Bump version to 0.2.9 2023-02-14 13:25:41 +01:00
timvisee
da60287e10 Update dependencies 2023-02-14 13:21:12 +01:00
timvisee
eb5ee7defd Merge branch 'tW4r-patch-1' into master
See https://github.com/timvisee/lazymc/pull/44
2023-02-14 13:17:55 +01:00
tW4r
fdeb7594c2 Mixing up my booleans myself 2023-02-14 13:49:01 +02:00
tW4r
5c7e17b0ae Only drop banned IPs when drop_banned_ip
Attempted fix of #43
2023-02-14 13:47:01 +02:00
timvisee
be74e053f4 Resolve clippy warnings 2023-02-10 11:11:05 +01:00
timvisee
eb2cf1219e Set MSRV in clippy configuration 2023-02-10 11:08:41 +01:00
timvisee
72d58f051f Bump version to 0.2.8 2023-01-30 19:07:40 +01:00
timvisee
6c3129a8b0 Enable all CI build jobs for tag releases, don't allow Windows failures 2023-01-30 19:07:05 +01:00
timvisee
cc2061ad7d Fix build failure on Windows 2023-01-30 18:40:43 +01:00
timvisee
342a55471c Bump Rust MSRV to 1.64.0 2023-01-30 18:38:57 +01:00
timvisee
982a604d34 Install proper Rust version on Windows CI jobs 2023-01-30 18:37:08 +01:00
timvisee
6b463ac8c8 Update dependencies 2023-01-30 18:23:58 +01:00
Tim Visée
c7caebe6a8 Merge branch 'github-pr-37' into 'master'
Implement server process freezing on Unix platforms

See merge request timvisee/lazymc!3
2023-01-30 17:22:12 +00:00
timvisee
aa1a74682e Fix incorrect state after unfreezing process 2023-01-30 18:18:28 +01:00
timvisee
835ca62c06 Change default protocol version to 1.19.3 (761) 2023-01-30 18:08:23 +01:00
timvisee
b609f86bde Require to build with rcon feature on Windows 2023-01-30 18:06:28 +01:00
timvisee
4b1857f48d Don't disable process freeze in config on Windows, simply ignore setting 2023-01-30 17:57:49 +01:00
timvisee
bc7bd908f6 Fix process freezing not working when rcon is enabled 2023-01-30 17:54:20 +01:00
timvisee
a3fef88eac Don't use deprecated base64 method 2023-01-30 17:42:02 +01:00
[object Object]
57117b29f3 Merge remote-tracking branch 'upstream/master' 2023-01-25 14:46:14 -08:00
[object Object]
5ef7c54ec6 Implement suggested changes 2023-01-25 14:45:15 -08:00
timvisee
5f13132c57 Run release jobs in CI for release tag only 2023-01-25 20:47:43 +01:00
timvisee
226215479c Run many jobs on master branch only 2023-01-25 20:45:55 +01:00
timvisee
f4870c66fa Fix scoop installation on Windows in GitLab CI 2023-01-25 20:45:45 +01:00
timvisee
c415420eae Build Linux jobs CI jobs on custom runner by default 2023-01-25 20:45:21 +01:00
timvisee
c6db4d7c3f Use Rust stable and MSRV in GitLab CI 2023-01-25 20:45:17 +01:00
timvisee
39feb0bdc2 Remove and disable some Windows CI jobs 2023-01-25 20:35:27 +01:00
timvisee
35fff7168b Disable Rust build cache in CI 2023-01-25 20:33:25 +01:00
timvisee
e5e5947a16 Remove macOS builds from GitLab CI, instruct to compile in README 2023-01-25 20:31:22 +01:00
[object Object]
2e6551b009 Merge remote-tracking branch 'upstream/master' 2023-01-22 16:49:32 -08:00
timvisee
2c00dba5e8 Update dependencies 2023-01-11 12:46:34 +01:00
timvisee
0e4d18e9f6 Update clap dependency 2023-01-11 12:44:00 +01:00
timvisee
026aa58b5d Fix broken documentation link
Mentioned in https://github.com/timvisee/lazymc/issues/39
2023-01-10 21:33:09 +01:00
[object Object]
0ac9a07c93 add a todo here because it isn't an ideal solution 2022-12-30 13:32:39 -08:00
[object Object]
540137b93e disable freeze_process on windows 2022-12-30 13:32:11 -08:00
[object Object]
05dfd19d80 Implement config handling 2022-12-30 13:17:04 -08:00
[object Object]
f02217abd3 Update version number (should be 1 above current release)
Also update deps
2022-12-30 13:10:14 -08:00
[object Object]
3185ca855c Add config option for process freezing 2022-12-30 13:09:07 -08:00
[object Object]
2c43446ed0 Error handling and logging 2022-12-28 14:37:08 -08:00
[object Object]
f7fe00aa50 Add nix crate, use it for signaling 2022-12-28 14:24:21 -08:00
[object Object]
f2087792b4 Update deps 2022-12-28 14:09:08 -08:00
[object Object]
d46f8375c7 Fix server infinitly stopping 2022-12-28 12:40:09 -08:00
[object Object]
2d8173aba8 Optimize release builds (and format toml file) 2022-12-28 12:33:05 -08:00
[object Object]
bd9f81f1f0 It would be a good idea to actually start the server 2022-12-28 12:29:17 -08:00
[object Object]
b561351a2a Scuffed code, not tested yet 2022-12-28 12:18:43 -08:00
[object Object]
d3cb880dd0 cargo clippy is angry 2022-12-28 12:03:00 -08:00
[object Object]
716cd48eac Add SIGSTOP and SIGCONT functions, fix some docs stuff 2022-12-28 12:00:32 -08:00
timvisee
c6f860f013 Update dependencies 2022-06-19 02:12:20 +02:00
dependabot[bot]
8d4ace60a6 Bump regex from 1.5.4 to 1.5.6
Bumps [regex](https://github.com/rust-lang/regex) from 1.5.4 to 1.5.6.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.5.4...1.5.6)

---
updated-dependencies:
- dependency-name: regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-06 22:00:45 +00:00
timvisee
09c6d1d996 Allow Windows CI to fail because it causes a lot of issues 2022-03-09 14:15:23 +01:00
timvisee
dca2f8eb5c Install missing Rust target on Windows CI 2022-02-07 17:33:20 +01:00
timvisee
1f6665a90f Update dependencies 2022-02-07 17:19:59 +01:00
timvisee
0c77a18d96 Install GCC on Windows CI 2022-02-07 16:52:03 +01:00
timvisee
0c69752fec Bump version to 0.2.7 2021-12-13 17:03:34 +01:00
timvisee
6e8ff5b1b3 Update dependencies 2021-12-13 17:03:27 +01:00
timvisee
fba581d4bd Update default Minecraft version to 1.18.1 2021-12-13 17:00:43 +01:00
timvisee
d7b601d6e3 Bump version to 0.2.6 2021-11-28 22:20:20 +01:00
timvisee
4d76058472 Update dependencies 2021-11-28 22:18:39 +01:00
timvisee
5b5a2bf8ae Only use wake whitelist if enabled in server.properties 2021-11-28 22:00:59 +01:00
timvisee
c477e45553 Add server whitelist support, use generic server files watcher to reload 2021-11-28 21:45:52 +01:00
timvisee
69de7a95bf Bump version to 0.2.5 2021-11-25 15:04:39 +01:00
timvisee
3e7e5c4c03 Fix Windows CI builds having incorrect feature configuration 2021-11-25 15:04:21 +01:00
timvisee
4d774cd254 Update dependencies 2021-11-25 14:54:05 +01:00
timvisee
a1324d22a7 Various fixes and improvements 2021-11-25 14:41:43 +01:00
timvisee
a500f50064 Use probed server settings in lobby join game packet 2021-11-25 14:36:50 +01:00
timvisee
4ef1481f2b Fix unsupported dimension data in lobby, use proper default world name 2021-11-25 14:21:29 +01:00
timvisee
addfb1c135 Don't use lobby join method if server isn't probed yet when required 2021-11-25 14:20:58 +01:00
timvisee
956c428251 Add compile time feature compatibility check, remove from build.rs 2021-11-25 14:00:59 +01:00
timvisee
270362b152 Use consistent documentation file names 2021-11-25 13:51:24 +01:00
timvisee
93f75adc5c Update lobby limitations, describe whitelist issue with probe user 2021-11-25 13:50:15 +01:00
timvisee
40fe5b5dd2 Cleanup documentation 2021-11-25 13:42:57 +01:00
timvisee
e7d2c6f64c Resolve clippy warnings 2021-11-25 13:39:52 +01:00
timvisee
084c3c5f8b Fix compilation error 2021-11-25 13:27:44 +01:00
timvisee
d39ad9c913 Update TODO 2021-11-25 12:59:52 +01:00
timvisee
00b6cddd78 Change ban list reload log message from info to debug 2021-11-25 12:59:51 +01:00
timvisee
b8744aaf57 Fix protocol versions being handled incorrectly 2021-11-25 12:59:51 +01:00
timvisee
51d3ecf148 Report online mode error if lobby receives server encryption request 2021-11-25 12:59:51 +01:00
timvisee
75c7a09249 Handle unrecognized login plugin requests messages in lobby 2021-11-25 12:59:50 +01:00
timvisee
f6d60318e8 Support Minecraft v1.16.3 to v1.17.1 in lobby and probe logic 2021-11-25 12:59:50 +01:00
timvisee
b404ab0a87 Support multiple Minecraft protocol versions in lobby and probe logic 2021-11-25 12:59:50 +01:00
timvisee
cf6bd526d9 Dynamically build lobby dimension from probed server dimension codec 2021-11-25 12:59:48 +01:00
timvisee
f513957bff Add config option to enable server probing on start 2021-11-25 12:59:32 +01:00
timvisee
9b1f2a7011 Probe server on lazymc start to get up-to-date server details for Forge 2021-11-25 12:59:32 +01:00
timvisee
8b09faae3d Constrain supported lobby versions 2021-11-25 12:59:32 +01:00
timvisee
e23a61ab0f Replay client handshake when lobby connects to real server 2021-11-25 12:59:31 +01:00
timvisee
78a36978f5 Fix broken italic text in Windows usage guide 2021-11-24 14:09:03 +01:00
timvisee
0ba46caf5c Bump version to 0.2.4 2021-11-24 14:05:27 +01:00
timvisee
6b23490919 Resolve clippy warnings 2021-11-24 14:02:08 +01:00
timvisee
9e08ed6cda Update dependencies 2021-11-24 13:57:19 +01:00
timvisee
3271db1cb3 Resolve compile warning 2021-11-24 13:55:23 +01:00
timvisee
cf0e3ef15b Make server directory relative to configuration file directory 2021-11-24 13:53:38 +01:00
timvisee
ee21eb45fd Bump minimum supported Minecraft version to 1.7.2 2021-11-24 13:28:06 +01:00
timvisee
aebb5563e0 Only send status response favicon to client versions that support it 2021-11-24 13:27:42 +01:00
timvisee
20fb6ee715 Always include favicon in status response, fall back to default icon
See https://github.com/timvisee/lazymc/issues/11#issuecomment-977814539
2021-11-24 13:20:49 +01:00
timvisee
ea2dbc905c Add Minecraft default server icon, extracted from Minecraft 1.17.1 2021-11-24 13:02:41 +01:00
timvisee
eb66265670 Handle SIGTERM exit code as successful 2021-11-23 13:00:10 +01:00
timvisee
df101ce53b Fix typo in Cargo.toml key 2021-11-22 20:38:30 +01:00
timvisee
8f2ce9b4b8 Fix compilation error without lobby feature 2021-11-22 20:36:46 +01:00
timvisee
20902e6a94 Derive correct UUID for offline players in lobby logic (2/2) 2021-11-22 20:20:08 +01:00
timvisee
3e933f7566 Derive correct UUID for offline players in lobby logic 2021-11-22 20:14:29 +01:00
timvisee
46fa594065 Update features in README 2021-11-22 19:00:36 +01:00
timvisee
1d92964802 Bump version to 0.2.3 2021-11-22 18:55:50 +01:00
timvisee
9b8d569628 Update dependencies 2021-11-22 18:53:52 +01:00
timvisee
94f2fa01e2 Update TODO 2021-11-22 18:53:23 +01:00
timvisee
9b71052b61 Add extras document with recommendations and tips after installing 2021-11-22 18:51:06 +01:00
timvisee
0049ad456c Bump rcon to 0.5.2, which now includes changes from our fork 2021-11-22 18:36:12 +01:00
timvisee
0f2d7720af Add documentation for proper IP proxying with PROXY header 2021-11-22 18:35:40 +01:00
timvisee
723ebabcfb Minor monitoring tweaks 2021-11-22 17:57:44 +01:00
timvisee
f95682fcd5 Only enable RCON by default on Windows 2021-11-22 17:57:28 +01:00
timvisee
d5c854d16f Add option to send HAProxy header with RCON connections 2021-11-22 17:55:51 +01:00
timvisee
493e24ff4d Send proxy header with monitor requests 2021-11-22 16:53:35 +01:00
timvisee
6916800aeb Add send_proxy_v2 option to send HAProxy header to server 2021-11-22 14:37:07 +01:00
timvisee
e7c31f2619 Add protocol version documentation 2021-11-22 13:37:21 +01:00
timvisee
7da467ff8c Bump version to 0.2.2 2021-11-18 11:37:02 +01:00
timvisee
c9d7af0e3c Add server favicon to status responses 2021-11-18 11:35:49 +01:00
timvisee
0715baed8c Bump version to 0.2.1 2021-11-17 20:31:22 +01:00
timvisee
1f4ec11ad1 Resolve clippy warnings 2021-11-17 20:26:49 +01:00
timvisee
acf6768b49 Show warning if 127.0.0.1 is IP banned 2021-11-17 20:25:49 +01:00
timvisee
75f7b62b16 Kick user with proper ban message, tweak IP file change debounce time 2021-11-17 20:21:22 +01:00
timvisee
9cc1958bbd Automatically reload banned IPs when file changes 2021-11-17 20:15:30 +01:00
timvisee
785bd2f33e Make banned IP format less strict to support possible future changes 2021-11-17 18:46:45 +01:00
timvisee
b168dcefde Respect IP ban expiry times 2021-11-17 18:42:26 +01:00
timvisee
74d772ab42 Respond with real server MOTD if it is currently started 2021-11-17 18:28:55 +01:00
timvisee
a71b3cb24f Add option to drop all connections from banned IPs
This instantly disconnects clients from banned IPs. Clients won't be
able to request or ping the server status. Clients won't get a kick
message with their ban reason either. Clients simply get a
'Disconnected' message on login.
2021-11-17 18:23:17 +01:00
timvisee
28dbcdbfd6 Show MOTD for banned players, kick with reason on login 2021-11-17 18:14:02 +01:00
timvisee
e816d4ff6c Use more efficient structure to manage banned IPs 2021-11-17 17:46:51 +01:00
timvisee
168cbceb4c Disconnect banned IPs based on server banned-ips.json file 2021-11-17 17:36:28 +01:00
timvisee
b1bd9e1837 Add support for host names in config address fields, resolve them to IP 2021-11-17 16:14:05 +01:00
timvisee
ec24f088b2 Fix lobby error due to invalid packet IDs 2021-11-16 18:09:45 +01:00
timvisee
6321999489 Update dependencies 2021-11-16 17:58:09 +01:00
timvisee
47fe7d0387 Extract all packet writing logic to single function 2021-11-16 17:57:34 +01:00
timvisee
7df3829e00 Use u8 for packet IDs 2021-11-16 17:13:30 +01:00
timvisee
b06f26b3e8 Refactor, cleanup status logic, extract join occupy logic into modules 2021-11-16 17:05:44 +01:00
timvisee
4510586169 Bump version to 0.2.0 2021-11-15 21:24:13 +01:00
timvisee
73d0c86780 Describe lobby feature in README, add documentation page with warning 2021-11-15 20:53:24 +01:00
timvisee
aca09dff7b Cleanup 2021-11-15 20:36:15 +01:00
timvisee
de516cf62c Resolve clippy warnings 2021-11-15 20:33:48 +01:00
timvisee
1da8c60323 Add lobby compiler feature flag 2021-11-15 20:30:22 +01:00
timvisee
d213612225 Remove lobby debug code 2021-11-15 20:22:34 +01:00
timvisee
ffc4f00430 Increase buffer size for reading Minecraft packets 2021-11-15 20:19:50 +01:00
timvisee
90e64297c0 Add support for packet compression 2021-11-15 20:18:52 +01:00
timvisee
ae6e877f17 Add lobby method to configuration 2021-11-15 18:17:02 +01:00
timvisee
4907780f7c Patch lobby logic for rebase on latest master 2021-11-15 18:04:34 +01:00
timvisee
40be4bae89 Reset player position when we play lobby ready sound effect 2021-11-15 18:04:34 +01:00
timvisee
d390f866cd Implement timeouts for various lobby client relaying stages 2021-11-15 18:04:34 +01:00
timvisee
374a9fab75 Improve waiting for server for lobby client
Broadcast to server state changes instead of polling the server state
constantly.
2021-11-15 18:04:33 +01:00
timvisee
f7d89a28aa Play sound effect in lobby when server is ready 2021-11-15 18:04:33 +01:00
timvisee
8b88cb16c5 Improve lobby handling, resolve various TODOs 2021-11-15 18:04:32 +01:00
timvisee
2cc64b29e0 Drop obsolete lobby packet, add lobby server warmup, some fixes 2021-11-15 18:04:32 +01:00
timvisee
802fd2990a Resolved all compiler warnings, clean-up proto, remove obsolete code 2021-11-15 18:04:32 +01:00
timvisee
3e7f5719cd Forward excess server packets in lobby to client before proxying 2021-11-15 18:04:31 +01:00
timvisee
32317a4c2f Remove obsolete file 2021-11-15 18:04:31 +01:00
timvisee
c9290827be Send client protocol & username to server from lobby, send server brand 2021-11-15 18:04:31 +01:00
timvisee
518fca90eb Improve lobby handling, implement teleport and proxy to real server 2021-11-15 18:04:30 +01:00
timvisee
e01fd212f7 Start experimenting with lobby, implement loading into lobby with text 2021-11-15 18:04:29 +01:00
timvisee
db99289ea7 Add join forward method to proxy to other address while server starts 2021-11-15 18:03:03 +01:00
timvisee
32cd9ffc73 Improve join occupy method descriptions in config 2021-11-15 17:27:38 +01:00
timvisee
d125140bee Update README for new join method feature 2021-11-15 16:54:45 +01:00
timvisee
e11eca1d5a Implement join method configuration and handling 2021-11-15 16:52:18 +01:00
timvisee
17ec663e15 Show warning if config version is outdated or invalid 2021-11-15 16:32:13 +01:00
timvisee
6b38dce5ab Restructure configuration file 2021-11-15 16:21:51 +01:00
timvisee
234a30aecb Add lockout mode, enable to prevent all players from connecting 2021-11-15 15:46:13 +01:00
timvisee
7a99781a05 Check and test no-rcon builds on GitLab CI 2021-11-15 15:25:54 +01:00
timvisee
5a5fa785b7 Bump version to 0.1.3 2021-11-15 15:18:53 +01:00
timvisee
78e9abec59 Fix binary release on GitLab CI 2021-11-15 15:18:19 +01:00
timvisee
dde9fdeab4 Bump version to 0.1.2 2021-11-15 14:53:05 +01:00
timvisee
901fb62f25 Use future tokio supported sync types throughout server handling logic 2021-11-15 14:41:01 +01:00
timvisee
dabeabeff4 Increase server monitoring timeouts to 20 seconds
Should improve polling reliability for overloaded servers.
2021-11-15 13:59:51 +01:00
timvisee
96d7fc9dec Grab exclusive lock for RCON invocations to server 2021-11-15 13:57:41 +01:00
timvisee
5ffc6ee911 Add RCON cooldown, do not require active PID to stop server
This hopefully improves server stopping reliability.
2021-11-15 13:42:58 +01:00
timvisee
b71d0d1013 Add delay between RCON commands, hopefully improve reliablity
The Minecraft RCON implementation is very broken/brittle. With this we
hope to improve reliablity.
2021-11-15 13:21:12 +01:00
Tim Visée
261acafab0 Merge branch 'ci-arm' into 'master'
Add ARMv7 and aarch64 builds to CI

See merge request timvisee/lazymc!2
2021-11-15 10:07:30 +00:00
timvisee
a04a5f93e9 Add ARMv7 and aarch64 builds to release job 2021-11-15 00:40:52 +01:00
timvisee
10c57f87ea Add GitLab CI builds for ARMv7 and aarch64 2021-11-15 00:20:23 +01:00
timvisee
88fc5892a3 Simplify waiting for server logic even further 2021-11-14 16:37:09 +01:00
timvisee
38d90681c7 Improve waiting for server when holding client
Instead of constantly polling the server state until it is ready, this
now subscribes to server state changes and uses a proper timeout.
2021-11-14 16:21:54 +01:00
75 changed files with 8627 additions and 1681 deletions

1
.gitignore vendored
View File

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

View File

@@ -9,18 +9,16 @@ stages:
# Variable defaults
variables:
RUST_VERSION: stable
TARGET: x86_64-unknown-linux-gnu
# Rust build cache configuration
.rust-build-cache: &rust-build-cache
key: "$CI_PIPELINE_ID"
paths:
- target/
# Install build dependencies
before_script:
- apt-get update
- apt-get install -y --no-install-recommends build-essential
- |
rustup install $RUST_VERSION
rustup default $RUST_VERSION
- |
rustc --version
cargo --version
@@ -29,51 +27,39 @@ before_script:
.before_script-windows: &before_script-windows
before_script:
# Install scoop
- Invoke-Expression (New-Object System.Net.WebClient).DownloadString('https://get.scoop.sh')
- iex "& {$(irm get.scoop.sh)} -RunAsAdmin"
# Install Rust
- scoop install rustup
- scoop install rustup gcc
- rustup install $RUST_VERSION
- rustup default $RUST_VERSION
- rustc --version
- cargo --version
# Install proper Rust target
- rustup target install x86_64-pc-windows-msvc
# Check on stable, beta and nightly
.check-base: &check-base
stage: check
cache:
<<: *rust-build-cache
script:
- cargo check --verbose
- cargo check --no-default-features --verbose
- cargo check --no-default-features --features rcon --verbose
check:
- cargo check --no-default-features --features lobby --verbose
check-stable:
<<: *check-base
check-macos:
tags:
- macos
check-msrv:
<<: *check-base
variables:
RUST_VERSION: 1.74.0
only:
- master
- /^v(\d+\.)*\d+$/
before_script:
- rustup default stable
- |
rustc --version
cargo --version
<<: *check-base
check-windows:
stage: check
tags:
- windows
cache: {}
<<: *before_script-windows
script:
- cargo check --locked --verbose
- cargo check --locked --no-default-features --features rcon --verbose
# Build using Rust stable on Linux
build-x86_64-linux-gnu:
stage: build
needs: []
cache:
<<: *rust-build-cache
script:
- cargo build --target=$TARGET --release --locked --verbose
- mv target/$TARGET/release/lazymc ./lazymc-$TARGET
@@ -87,11 +73,12 @@ build-x86_64-linux-gnu:
# Build a static version
build-x86_64-linux-musl:
stage: build
only:
- master
- /^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-]+)*))?$/
needs: []
variables:
TARGET: x86_64-unknown-linux-musl
cache:
<<: *rust-build-cache
script:
- rustup target add $TARGET
- cargo build --target=$TARGET --release --locked --verbose
@@ -106,27 +93,74 @@ build-x86_64-linux-musl:
- lazymc-$TARGET
expire_in: 1 month
# Build using Rust stable on macOS
build-macos:
# Build using Rust stable on Linux for ARMv7
build-armv7-linux-gnu:
stage: build
tags:
- macos
image: ubuntu
only:
- master
- /^v(\d+\.)*\d+$/
- /^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-]+)*))?$/
needs: []
variables:
TARGET: x86_64-apple-darwin
TARGET: armv7-unknown-linux-gnueabihf
before_script:
- rustup default stable
- apt-get update
- apt-get install -y --no-install-recommends build-essential
- |
apt-get install -y curl
curl https://sh.rustup.rs -sSf | sh -s -- -y
source $HOME/.cargo/env
- |
rustc --version
cargo --version
script:
- apt-get install -y gcc-arm-linux-gnueabihf
- rustup target add $TARGET
- mkdir -p ~/.cargo
- 'echo "[target.$TARGET]" >> ~/.cargo/config'
- 'echo "linker = \"arm-linux-gnueabihf-gcc\"" >> ~/.cargo/config'
- cargo build --target=$TARGET --release --locked --verbose
- mv target/$TARGET/release/lazymc ./lazymc-$TARGET
artifacts:
name: lazymc-x86_64-macos
name: lazymc-armv7-linux-gnu
paths:
- lazymc-$TARGET
expire_in: 1 month
# Build using Rust stable on Linux for aarch64
build-aarch64-linux-gnu:
stage: build
image: ubuntu
only:
- master
- /^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-]+)*))?$/
needs: []
variables:
TARGET: aarch64-unknown-linux-gnu
before_script:
- apt-get update
- apt-get install -y --no-install-recommends build-essential
- |
apt-get install -y curl
curl https://sh.rustup.rs -sSf | sh -s -- -y
source $HOME/.cargo/env
- |
rustc --version
cargo --version
script:
- apt-get install -y gcc-aarch64-linux-gnu
- rustup target add $TARGET
- mkdir -p ~/.cargo
- 'echo "[target.$TARGET]" >> ~/.cargo/config'
- 'echo "linker = \"aarch64-linux-gnu-gcc\"" >> ~/.cargo/config'
- cargo build --target=$TARGET --release --locked --verbose
- mv target/$TARGET/release/lazymc ./lazymc-$TARGET
artifacts:
name: lazymc-aarch64-linux-gnu
paths:
- lazymc-$TARGET
expire_in: 1 month
@@ -136,6 +170,9 @@ build-x86_64-windows:
stage: build
tags:
- windows
only:
- master
- /^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-]+)*))?$/
needs: []
variables:
TARGET: x86_64-pc-windows-msvc
@@ -152,24 +189,28 @@ build-x86_64-windows:
# Run the unit tests through Cargo on Linux
test-cargo-x86_64-linux-gnu:
stage: test
only:
- master
needs: []
dependencies: []
cache:
<<: *rust-build-cache
script:
- cargo test --locked --verbose
- cargo test --locked --no-default-features --verbose
- cargo test --locked --no-default-features --features rcon --verbose
- cargo test --locked --no-default-features --features lobby --verbose
# Run the unit tests through Cargo on Windows
test-cargo-x86_64-windows:
stage: test
tags:
- windows
needs: []
dependencies: []
cache: {}
<<: *before_script-windows
script:
- cargo test --locked --verbose
# # Run the unit tests through Cargo on Windows
# test-cargo-x86_64-windows:
# stage: test
# tags:
# - windows
# needs: []
# dependencies: []
# <<: *before_script-windows
# script:
# - cargo test --locked --verbose
# - cargo test --locked --no-default-features --features rcon --verbose
# - cargo test --locked --no-default-features --features rcon,lobby --verbose
# Release binaries on GitLab as generic package
release-gitlab-generic-package:
@@ -178,14 +219,16 @@ release-gitlab-generic-package:
dependencies:
- build-x86_64-linux-gnu
- build-x86_64-linux-musl
- build-macos
- build-armv7-linux-gnu
- build-aarch64-linux-gnu
- build-x86_64-windows
only:
- /^v(\d+\.)*\d+$/
- /^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-]+)*))?$/
variables:
LINUX_GNU_BIN: "lazymc-x86_64-unknown-linux-gnu"
LINUX_MUSL_BIN: "lazymc-x86_64-unknown-linux-musl"
MACOS_BIN: "lazymc-x86_64-apple-darwin"
LINUX_ARMV7_GNU_BIN: "lazymc-armv7-unknown-linux-gnueabihf"
LINUX_AARCH64_GNU_BIN: "lazymc-aarch64-unknown-linux-gnu"
WINDOWS_BIN: "lazymc-x86_64-pc-windows-msvc.exe"
before_script: []
script:
@@ -199,7 +242,9 @@ release-gitlab-generic-package:
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_MUSL_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_MUSL_BIN}
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${MACOS_BIN} ${PACKAGE_REGISTRY_URL}/${MACOS_BIN}
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_ARMV7_GNU_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_ARMV7_GNU_BIN}
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${LINUX_AARCH64_GNU_BIN} ${PACKAGE_REGISTRY_URL}/${LINUX_AARCH64_GNU_BIN}
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${WINDOWS_BIN} ${PACKAGE_REGISTRY_URL}/${WINDOWS_BIN}
@@ -208,11 +253,12 @@ release-gitlab-release:
image: registry.gitlab.com/gitlab-org/release-cli
stage: release
only:
- /^v(\d+\.)*\d+$/
- /^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-]+)*))?$/
variables:
LINUX_GNU_BIN: "lazymc-x86_64-unknown-linux-gnu"
LINUX_MUSL_BIN: "lazymc-x86_64-unknown-linux-musl"
MACOS_BIN: "lazymc-x86_64-apple-darwin"
LINUX_ARMV7_GNU_BIN: "lazymc-armv7-unknown-linux-gnueabihf"
LINUX_AARCH64_GNU_BIN: "lazymc-aarch64-unknown-linux-gnu"
WINDOWS_BIN: "lazymc-x86_64-pc-windows-msvc.exe"
before_script: []
script:
@@ -225,18 +271,20 @@ release-gitlab-release:
release-cli create --name "lazymc $CI_COMMIT_TAG" --tag-name $CI_COMMIT_TAG \
--assets-link "{\"name\":\"${LINUX_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_GNU_BIN}\"}" \
--assets-link "{\"name\":\"${LINUX_MUSL_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_MUSL_BIN}\"}" \
--assets-link "{\"name\":\"${MACOS_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${MACOS_BIN}\"}" \
--assets-link "{\"name\":\"${LINUX_ARMV7_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_ARMV7_GNU_BIN}\"}" \
--assets-link "{\"name\":\"${LINUX_AARCH64_GNU_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${LINUX_AARCH64_GNU_BIN}\"}" \
--assets-link "{\"name\":\"${WINDOWS_BIN}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${WINDOWS_BIN}\"}"
# Publish GitHub release
release-github:
stage: release
only:
- /^v(\d+\.)*\d+$/
- /^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-]+)*))?$/
dependencies:
- build-x86_64-linux-gnu
- build-x86_64-linux-musl
- build-macos
- build-armv7-linux-gnu
- build-aarch64-linux-gnu
- build-x86_64-windows
before_script: []
script:
@@ -253,5 +301,6 @@ release-github:
- ./github-release release --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --title "lazymc $CI_COMMIT_REF_NAME"
- ./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
- ./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
- ./github-release upload --token "$GITHUB_TOKEN" --owner timvisee --repo lazymc --tag "$CI_COMMIT_REF_NAME" --file ./lazymc-x86_64-apple-darwin --name lazymc-$CI_COMMIT_REF_NAME-macos
- ./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
- ./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
- ./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

View File

@@ -1,5 +1,101 @@
# Changelog
## 0.2.11 (2024-03-16)
- Add support for Minecraft 1.20.3 and 1.20.4
- Improve error handling of parsing server favicon
- Fix typo in log message
- Update dependencies
## 0.2.10 (2023-02-20)
- Do not report an error when server exits with status code 143
## 0.2.9 (2023-02-14)
- Fix dropping all connections when `server.drop_banned_ips` was enabled
- Update dependencies
## 0.2.8 (2023-01-30)
- Add `freeze_process` feature on Unix platforms to freeze a sleeping server
rather than shutting it down.
- Update default Minecraft version to 1.19.3
- Remove macOS builds from releases, users can compile from source
- Update dependencies
## 0.2.7 (2021-12-13)
- Update default Minecraft version to 1.18.1
- Update dependencies
## 0.2.6 (2021-11-28)
- Add whitelist support, use server whitelist to prevent unknown users from waking server
- Update dependencies
## 0.2.5 (2021-11-25)
- Add support Minecraft 1.16.3 to 1.17.1 with lobby join method
- Add support for Forge client/server to lobby join method (partial)
- Probe server on start with fake user to fetch server settings improving compatibility
- Improve lobby compatibility, send probed server data to client when possible
- Skip lobby join method if server probe is not yet finished
- Generate lobby dimension configuration on the fly based on server dimensions
- Fix unsupported lobby dimension configuration values for some Minecraft versions
- Demote IP ban list reload message from info to debug
- Update dependencies
## 0.2.4 (2021-11-24)
- Fix status response issues with missing server icon, fall back to default icon
- Fix incorrect UUID for players in lobby logic
- Make server directory relative to configuration file path
- Assume SIGTERM exit code for server process to be successful on Unix
- Update features in README
- Update dependencies
## 0.2.3 (2021-11-22)
- Add support for `PROXY` header to notify Minecraft server of real client IP
- Only enable RCON by default on Windows
- Update dependencies
## 0.2.2 (2021-11-18)
- Add server favicon to status response
## 0.2.1 (2021-11-17)
- Add support for using host names in config address fields
- Handle banned players within `lazymc` based on server `banned-ips.json`
- Update dependencies
## 0.2.0 (2021-11-15)
- Add lockout feature, enable to kick all connecting clients with a message
- Add option to configure list of join methods to occupy client with while server is starting (kick, hold, forward, lobby)
- Add lobby join method, keeps client in lobby world on emulated server, teleports to real server when it is ready (highly experimental)
- Add forward join method to forward (proxy) client to other host while server is starting
- Restructure `lazymc.toml` configuration
- Increase packet reading buffer size to speed things up
- Add support for Minecraft packet compression
- Show warning if config version is outdated or invalid
- Various fixes and improvements
## 0.1.3 (2021-11-15)
- Fix binary release
## 0.1.2 (2021-11-15)
- Add Linux ARMv7 and aarch64 releases
- RCON now works if server is running while server command already quit
- Various RCON tweaks in an attempt to make it more robust and reliable (cooldown, exclusive lock, invocation spacing)
- Increase server monitoring timeout to 20 seconds
- Improve waiting for server logic when holding client
- Various fixes and improvements
## 0.1.1 (2021-11-14)
- Make server sleeping errors more descriptive

1607
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "lazymc"
version = "0.1.1"
version = "0.2.11"
authors = ["Tim Visee <3a4fb3964f@sinenomine.email>"]
license = "GPL-3.0"
readme = "README.md"
@@ -8,43 +8,90 @@ homepage = "https://timvisee.com/projects/lazymc"
repository = "https://gitlab.com/timvisee/lazymc"
description = "Put your Minecraft server to rest when idle."
keywords = ["minecraft", "server", "idle", "cli"]
categories = [
"command-line-interface",
"games",
]
exclude = [
"/.github",
"/contrib",
]
categories = ["command-line-interface", "games"]
exclude = ["/.github", "/contrib"]
edition = "2021"
rust-version = "1.74.0"
[profile.release]
codegen-units = 1
lto = true
strip = true
[features]
default = ["rcon"]
default = ["rcon", "lobby"]
# RCON support
# Allow use of RCON to manage (stop) server.
# Required on Windows.
rcon = ["rust_rcon"]
# Lobby support
# Add lobby join method, keeps client in fake lobby world until server is ready.
lobby = ["md-5", "uuid"]
[dependencies]
anyhow = "1.0"
base64 = "0.22"
bytes = "1.1"
clap = { version = "3.0.0-beta.5", default-features = false, features = [ "std", "cargo", "color", "env", "suggestions", "unicode" ]}
chrono = "0.4"
clap = { version = "4.0.32", default-features = false, features = [
"std",
"help",
"suggestions",
"color",
"usage",
"cargo",
"env",
"unicode",
] }
colored = "2.0"
derive_builder = "0.10"
derive_builder = "0.20"
dotenv = "0.15"
futures = { version = "0.3", default-features = false }
flate2 = { version = "1.0", default-features = false, features = ["default"] }
futures = { version = "0.3", default-features = false, features = ["executor"] }
log = "0.4"
minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "31041b8" }
pretty_env_logger = "0.4"
minecraft-protocol = { git = "https://github.com/timvisee/rust-minecraft-protocol", rev = "4f93bb3" }
named-binary-tag = "0.6"
nix = { version = "0.28", features = ["process", "signal"] }
notify = "4.0"
pretty_env_logger = "0.5"
proxy-protocol = "0.5"
quartz_nbt = "0.2"
rand = "0.8"
serde = "1.0"
serde_json = "1.0"
shlex = "1.1"
thiserror = "1.0"
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "io-util", "net", "macros", "time", "process", "signal"] }
toml = "0.5"
tokio = { version = "1", default-features = false, features = [
"rt-multi-thread",
"io-util",
"net",
"macros",
"time",
"process",
"signal",
"sync",
"fs",
] }
toml = "0.8"
version-compare = "0.2"
# Feature: rcon
rust_rcon = { package = "rcon", version = "0.5", optional = true }
rust_rcon = { package = "rcon", version = "0.6", default-features = false, features = ["rt-tokio"], optional = true }
# Feature: lobby
md-5 = { version = "0.10", optional = true }
uuid = { version = "1.7", optional = true, features = ["v3"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "handleapi", "ntdef", "minwindef"] }
winapi = { version = "0.3", features = [
"winuser",
"processthreadsapi",
"handleapi",
"ntdef",
"minwindef",
] }

View File

@@ -35,12 +35,19 @@ https://user-images.githubusercontent.com/856222/141378688-882082be-9efa-4cfe-81
## Features
- Very efficient, lightweight & low-profile (~3KB RAM)
- Supports Minecraft Java Edition 1.6+, supports modded (e.g. Forge, FTB)
- Transparent join: hold clients when server starts, relay when ready, without them noticing
- Supports Minecraft Java Edition 1.20.3+
- Configure joining client occupation methods:
- Hold: hold clients when server starts, relay when ready, without them noticing
- Kick: kick clients when server starts, with a starting message
- Forward: forward client to another IP when server starts
- _Lobby: keep client in emulated server with lobby world, teleport to real server when ready ([experimental*](./docs/join-method-lobby.md))_
- Customizable MOTD and login messages
- Automatically manages `server.properties` (host, port and RCON settings)
- Graceful server sleep/shutdown through RCON (with `SIGTERM` fallback on Linux/Unix)
- Automatically block banned IPs from server within lazymc
- Graceful server sleep/shutdown through RCON or `SIGTERM`
- Real client IP on Minecraft server with `PROXY` header ([usage](./docs/proxy-ip.md))
- Restart server on crash
- Lockout mode
## Requirements
@@ -48,6 +55,10 @@ https://user-images.githubusercontent.com/856222/141378688-882082be-9efa-4cfe-81
- Minecraft Java Edition 1.6+
- On Windows: RCON (automatically managed)
Build requirements:
- Rust 1.74 (MSRV)
_Note: You must have access to the system to run the `lazymc` binary. If you're
using a Minecraft shared hosting provider with a custom dashboard, you likely
won't be able to set this up._
@@ -60,7 +71,8 @@ _Note: these instructions are for Linux & macOS, for Windows look
Make sure you meet all [requirements](#requirements).
Download the appropriate binary for your system from the [latest
release][latest-release] page.
release][latest-release] page. On macOS you must [compile from
source](#compile-from-source).
Place the binary in your Minecraft server directory, rename it if you like.
Open a terminal, go to the directory, and make sure you can invoke it:
@@ -70,8 +82,8 @@ chmod a+x ./lazymc
./lazymc --help
```
When `lazymc` is set-up, change into your server directory if you haven't
already. Then set up the [configuration](./res/lazymc.toml) and start it up:
When lazymc is set-up, change into your server directory if you haven't already.
Then set up the [configuration](./res/lazymc.toml) and start it up:
```bash
# Change into your server directory (if you haven't already)
@@ -88,20 +100,14 @@ nano lazymc.toml
lazymc start
```
Before you use this in production, please ensure starting and stopping the
server works as expected by connecting to it once. Watch `lazymc`s output while
it starts and stops. If stopping results in errors, fix this first to prevent
corrupting world/user data.
Please see [extras](./docs/extras.md) for recommendations and additional things
to set up (e.g. how to fix incorrect client IPs and IP banning on your server).
Follow this repository with the _Watch_ button on the top right to be notified of new releases.
Everything should now be ready to go! Connect with your Minecraft client to wake
your server up!
After you've read through the [extras](./docs/extras.md), everything should now
be ready to go! Connect with your Minecraft client to wake your server up!
_Note: If a binary for your system isn't provided, please [compile from
source](#compile-from-source)._
_Note: Installation options are limited at this moment. More will be added
source](#compile-from-source). Installation options are limited at this moment. More will be added
later._
[latest-release]: https://github.com/timvisee/lazymc/releases/latest
@@ -139,7 +145,7 @@ cargo build --release
## Third-party usage & implementations
A list of third-party implementations, projects using `lazymc`, that you might
A list of third-party implementations, projects using lazymc, that you might
find useful:
- Docker: [crbanman/papermc-lazymc](https://hub.docker.com/r/crbanman/papermc-lazymc) _(PaperMC with lazymc in Docker)_

13
TODO.md
View File

@@ -9,14 +9,19 @@
- Use server whitelist/blacklist
- Console error if server already started on port, not through `lazymc`
- Kick with message if proxy-to-server connection fails for new client.
- Kick with message if proxy-to-server connection fails for new client.
- Test configuration on start (server dir exists, command not empty)
- Also quit `lazymc` after CTRL+C signal, after server has stopped
- Dynamically increase/decrease server polling interval based on server state
- Server polling through query (`enable-query` in `server.properties`, uses GameSpy4 protocol)
## Experiment
- Lobby method: let players connect with an emulated empty server (like 2b2t's
queue), redirect them when the server started.
- `io_uring` on Linux for efficient proxying (see `tokio-uring`)
## Lobby join method
- add support for more Minecraft versions (with changed protocols)
- support online mode (encryption)
- hold back packets (whitelist), forward to server at connect before joining
- add support for forge (emulate mod list communication)
- on login plugin request during login state, respond with empty payload, not supported

View File

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

1
clippy.toml Normal file
View File

@@ -0,0 +1 @@
msrv = "1.64.0"

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

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

View File

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

1
docs/command_bash.md Symbolic link
View File

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

28
docs/extras.md Normal file
View File

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

114
docs/join-method-lobby.md Normal file
View File

@@ -0,0 +1,114 @@
# Join method: lobby
**Note: this is highly experimental, incomplete, and may break your game. See
[warning](#warning).**
The lobby join method allows you to keep clients in a lobby world while the
server is starting. When the server is ready, the player is _teleported_ to the
real server.
lazymc emulates a fake server with an empty lobby world. The player is put in
this world, floating in space. A custom message is shown on the client to notify
we're waiting on the server to start.
![Lobby screenshot](../res/screenshot/lobby.png)
## Warning
This feature is highly experimental, incomplete and unstable. This may break the
game and crash clients. Don't use this unless you know what you're doing. Never
enable this in a production environment.
Current limitations:
- Server must be in offline mode (`online-mode=false`)
- Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1)
- Server must use vanilla Minecraft
- May work with Forge (set `server.forge = true`), depends on used mods, test before use
- Does not work with other mods, such as FTB
- This method will consume the client, following configured join methods won't be used.
At this time it is unknown if some of the above limitations will ever be lifted,
or if this will ever be implemented in a robust manner.
## Usage
_Note: you must use `lazymc v0.2.0` or above with the `lobby` feature enabled._
To try this out, simply add the `"lobby"` method to the `join.methods` list in
your `lazymc.toml` configuration file:
```toml
# -- snip --
[join]
methods = [
"lobby",
"kick",
]
# -- snip --
```
Then configure the lobby to your likings:
```toml
# -- snip --
[join.lobby]
# Lobby occupation method.
# The client joins a fake lobby server with an empty world, floating in space.
# A message is overlayed on screen to notify the server is starting.
# The client will be teleported to the real server once it is ready.
# This may keep the client occupied forever if no timeout is set.
# Consumes client, not allowing other join methods afterwards.
# See: https://git.io/JMIi4
# !!! WARNING !!!
# This is highly experimental, incomplete and unstable.
# This may break the game and crash clients.
# Don't enable this unless you know what you're doing.
#
# - Server must be in offline mode
# - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1)
# - Server must use vanilla Minecraft
# - May work with Forge, enable in config, depends on used mods, test before use
# - Does not work with other mods, such as FTB
# Maximum time in seconds in the lobby while the server starts.
timeout = 600
# Message banner in lobby shown to client.
message = "§2Server is starting\n§7⌛ Please wait..."
# Sound effect to play when server is ready.
ready_sound = "block.note_block.chime"
# -- snip --
```
_Note: this might have changed, see the latest configuration
[here](../res/lazymc.toml)._
## Probe issue with whitelist
lazymc may report a _probe_ error on first start when a whitelist is enabled
on your server.
lazymc uses a probe to fetch some required details from your Minecraft
server, such as a mod list. When probing, the server is started once when lazymc
starts. It then connects to the Minecraft server with the _probe_ user
(username: `_lazymc_probe`) and disconnects when everything needed is fetched.
If you use a whitelist on your server it will cause issues if the probe user
isn't whitelisted. Simply whitelist the probe user with the following command
and restart lazymc to fix the issue:
```
/whitelist add _lazymc_probe
```
Probing isn't enabled by default. You may enable this by setting
`server.probe_on_start = true`. Other configuration settings might
automatically enable proving if required for your setup.

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

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

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

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

View File

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

18
res/dimension.snbt Normal file
View File

@@ -0,0 +1,18 @@
{
piglin_safe: 1b,
natural: 0b,
ambient_light: 0.0f,
fixed_time: 0,
infiniburn: "minecraft:infiniburn_overworld",
respawn_anchor_works: 0b,
has_skylight: 1b,
bed_works: 0b,
effects: "minecraft:the_end",
has_raids: 0b,
min_y: 0,
height: 256,
logical_height: 256,
coordinate_scale: 1.0d,
ultrawarm: 0b,
has_ceiling: 0b
}

2093
res/dimension_codec.snbt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
# You can probably leave the rest as-is.
#
# You may generate a new configuration with: lazymc config generate
# Or find the latest at: https://is.gd/WWBIQu
# Or find the latest at: https://git.io/J1Fvq
[public]
# Public address. IP and port users connect to.
@@ -17,9 +17,9 @@
# Server version & protocol hint.
# Sent to clients until actual server version is known.
# See: https://is.gd/FTQKTP
#version = "1.17.1"
#protocol = 756
# See: https://git.io/J1Fvx
#version = "1.20.3"
#protocol = 765
[server]
# Server address. Internal IP and port of server started by lazymc to proxy to.
@@ -30,15 +30,45 @@
directory = "."
# Command to start the server.
# Warning: if using a bash script read: https://is.gd/k8SQYv
# Warning: if using a bash script read: https://git.io/JMIKH
command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# Freeze the server process instead of restarting it when no players online, making it resume faster.
# Only works on Unix (Linux or MacOS), ignored on Windows
#freeze_process = true
# Immediately wake server when starting lazymc.
#wake_on_start = false
# Immediately wake server after crash.
#wake_on_crash = false
# Probe required server details when starting lazymc, wakes server on start.
# Improves client compatibility. Automatically enabled if required by other config properties.
#probe_on_start = false
# Set to true if this server runs Forge.
#forge = false
# Server start/stop timeout in seconds. Force kill server process if it takes too long.
#start_timeout = 300
#stop_timeout = 150
# To wake server, user must be in server whitelist if enabled on server.
#wake_whitelist = true
# Block banned IPs as listed in banned-ips.json in server directory.
#block_banned_ips = true
# Drop connections from banned IPs.
# Banned IPs won't be able to ping or request server status.
# On connect, clients show a 'Disconnected' message rather than the ban reason.
#drop_banned_ips = false
# Add HAProxy v2 header to proxied connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[time]
# Sleep after number of seconds.
#sleep_after = 60
@@ -46,31 +76,97 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
# Minimum time in seconds to stay online when server is started.
#minimum_online_time = 60
# Hold client for number of seconds on connect while server starts.
# 0 to disable and disconnect immediately, keep below Minecraft timeout of 30 seconds.
#hold_client_for = 25
# Server start/stop timeout in seconds. Force kill server process if it takes too long.
#start_timeout = 300
#stop_timeout = 150
[messages]
# MOTDs, shown in server browser.
#motd_sleeping = "☠ Server is sleeping\n§2☻ Join to start it up"
#motd_starting = "§2☻ Server is starting...\n§7⌛ Please wait..."
#motd_stopping = "☠ Server going to sleep...\n⌛ Please wait..."
[motd]
# MOTD, shown in server browser.
#sleeping = "☠ Server is sleeping\n§2☻ Join to start it up"
#starting = "§2☻ Server is starting...\n§7⌛ Please wait..."
#stopping = "☠ Server going to sleep...\n⌛ Please wait..."
# Use MOTD from Minecraft server once known.
#use_server_motd = false
#from_server = false
# Login messages, when user tries to connect.
#login_starting = "Server is starting... §c♥§r\n\nThis may take some time.\n\nPlease try to reconnect in a minute."
#login_stopping = "Server is going to sleep... §7☠§r\n\nPlease try to reconnect in a minute to wake it again."
[join]
# Methods to use to occupy a client on join while the server is starting.
# Read about all methods and configure them below.
# Methods are used in order, if none is set, the client disconnects without a message.
#methods = [
# "hold",
# "kick",
#]
[join.kick]
# Kick occupation method.
# Instantly kicks a client with a message.
# Message shown when client is kicked while server is starting/stopping.
#starting = "Server is starting... §c♥§r\n\nThis may take some time.\n\nPlease try to reconnect in a minute."
#stopping = "Server is going to sleep... §7☠§r\n\nPlease try to reconnect in a minute to wake it again."
[join.hold]
# Hold occupation method.
# Holds back a joining client while the server is started until it is ready.
# 'Connecting the server...' is shown on the client while it's held back.
# If the server starts fast enough, the client won't notice it was sleeping at all.
# This works for a limited time of 30 seconds, after which the Minecraft client times out.
# Hold client for number of seconds on connect while server starts.
# Keep below Minecraft timeout of 30 seconds.
#timeout = 25
[join.forward]
# Forward occupation method.
# Instantly forwards (proxies) the client to a different address.
# You may need to configure target server for it, such as allowing proxies.
# Consumes client, not allowing other join methods afterwards.
# IP and port to forward to.
# The target server will receive original client handshake and login request as received by lazymc.
#address = "127.0.0.1:25565"
# Add HAProxy v2 header to forwarded connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[join.lobby]
# Lobby occupation method.
# The client joins a fake lobby server with an empty world, floating in space.
# A message is overlayed on screen to notify the server is starting.
# The client will be teleported to the real server once it is ready.
# This may keep the client occupied forever if no timeout is set.
# Consumes client, not allowing other join methods afterwards.
# See: https://git.io/JMIi4
# !!! WARNING !!!
# This is highly experimental, incomplete and unstable.
# This may break the game and crash clients.
# Don't enable this unless you know what you're doing.
#
# - Server must be in offline mode
# - Server must use Minecraft version 1.16.3 to 1.17.1 (tested with 1.17.1)
# - Server must use vanilla Minecraft
# - May work with Forge, enable in config, depends on used mods, test before use
# - Does not work with other mods, such as FTB
# Maximum time in seconds in the lobby while the server starts.
#timeout = 600
# Message banner in lobby shown to client.
#message = "§2Server is starting\n§7⌛ Please wait..."
# Sound effect to play when server is ready.
#ready_sound = "block.note_block.chime"
[lockout]
# Enable to prevent everybody from connecting through lazymc. Instantly kicks player.
#enabled = false
# Kick players with following message.
#message = "Server is closed §7☠§r\n\nPlease try to reconnect in a minute."
[rcon]
# Enable sleeping server through RCON.
# Must be enabled on Windows.
#enabled = true
#enabled = false # default: false, true on Windows
# Server RCON port. Must differ from public and server port.
#port = 25575
@@ -80,6 +176,15 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
#password = ""
#randomize_password = true
# Add HAProxy v2 header to RCON connections.
# See: https://git.io/J1bYb
#send_proxy_v2 = false
[advanced]
# Automatically update values in Minecraft server.properties file as required.
#rewrite_server_properties = true
[config]
# lazymc version this configuration is for.
# Don't change unless you know what you're doing.
version = "0.2.11"

BIN
res/screenshot/lobby.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

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

BIN
res/unknown_server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -9,7 +9,7 @@ use crate::util::error::{quit, quit_error, ErrorHintsBuilder};
/// Invoke config test command.
pub fn invoke(matches: &ArgMatches) {
// Get config path, attempt to canonicalize
let mut path = PathBuf::from(matches.value_of("config").unwrap());
let mut path = PathBuf::from(matches.get_one::<String>("config").unwrap());
if let Ok(p) = path.canonicalize() {
path = p;
}

View File

@@ -8,7 +8,7 @@ use crate::util::error::{quit_error, quit_error_msg, ErrorHintsBuilder};
/// Invoke config test command.
pub fn invoke(matches: &ArgMatches) {
// Get config path, attempt to canonicalize
let mut path = PathBuf::from(matches.value_of("config").unwrap());
let mut path = PathBuf::from(matches.get_one::<String>("config").unwrap());
if let Ok(p) = path.canonicalize() {
path = p;
}

View File

@@ -3,8 +3,9 @@ use std::sync::Arc;
use clap::ArgMatches;
use crate::config::{self, Config};
use crate::config::{self, Config, Server as ConfigServer};
use crate::mc::server_properties;
use crate::proto;
use crate::service;
/// RCON randomized password length.
@@ -119,7 +120,7 @@ fn rewrite_server_properties(config: &Config) {
}
// Ensure server directory is set, it must exist
let dir = match &config.server.directory {
let dir = match ConfigServer::server_directory(config) {
Some(dir) => dir,
None => {
warn!(target: "lazymc", "Not rewriting {} file, server directory not configured (server.directory)", server_properties::FILE);
@@ -141,6 +142,14 @@ fn rewrite_server_properties(config: &Config) {
changes.extend([("prevent-proxy-connections", "false".into())]);
}
// Update network compression threshold for lobby mode
if config.join.methods.contains(&config::Method::Lobby) {
changes.extend([(
"network-compression-threshold",
proto::COMPRESSION_THRESHOLD.to_string(),
)]);
}
// Add RCON configuration
#[cfg(feature = "rcon")]
if config.rcon.enabled {

View File

@@ -1,23 +1,28 @@
use clap::{App, AppSettings, Arg};
use clap::{Arg, Command};
/// The clap app for CLI argument parsing.
pub fn app() -> App<'static> {
App::new(crate_name!())
pub fn app() -> Command {
Command::new(crate_name!())
.version(crate_version!())
.author(crate_authors!())
.about(crate_description!())
.subcommand(
App::new("start")
Command::new("start")
.alias("run")
.about("Start lazymc and server (default)"),
)
.subcommand(
App::new("config")
Command::new("config")
.alias("cfg")
.about("Config actions")
.setting(AppSettings::SubcommandRequiredElseHelp)
.subcommand(App::new("generate").alias("gen").about("Generate config"))
.subcommand(App::new("test").about("Test config")),
.arg_required_else_help(true)
.subcommand_required(true)
.subcommand(
Command::new("generate")
.alias("gen")
.about("Generate config"),
)
.subcommand(Command::new("test").about("Test config")),
)
.arg(
Arg::new("config")
@@ -27,7 +32,7 @@ pub fn app() -> App<'static> {
.global(true)
.value_name("FILE")
.default_value(crate::config::CONFIG_FILE)
.about("Use config file")
.takes_value(true),
.help("Use config file")
.num_args(1),
)
}

View File

@@ -1,23 +1,28 @@
use std::fs;
use std::io;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use clap::ArgMatches;
use serde::Deserialize;
use version_compare::Cmp;
use crate::proto;
use crate::util::error::{quit_error, quit_error_msg, ErrorHintsBuilder};
use crate::util::serde::to_socket_addrs;
/// Default configuration file location.
pub const CONFIG_FILE: &str = "lazymc.toml";
/// Configuration version user should be using, or warning will be shown.
const CONFIG_VERSION: &str = "0.2.8";
/// Load config from file, based on CLI arguments.
///
/// Quits with an error message on failure.
pub fn load(matches: &ArgMatches) -> Config {
// Get config path, attempt to canonicalize
let mut path = PathBuf::from(matches.value_of("config").unwrap());
let mut path = PathBuf::from(matches.get_one::<String>("config").unwrap());
if let Ok(p) = path.canonicalize() {
path = p;
}
@@ -58,6 +63,12 @@ pub fn load(matches: &ArgMatches) -> Config {
/// Configuration.
#[derive(Debug, Deserialize)]
pub struct Config {
/// Configuration path if known.
///
/// Should be used as base directory for filesystem operations.
#[serde(skip)]
pub path: Option<PathBuf>,
/// Public configuration.
#[serde(default)]
pub public: Public,
@@ -69,9 +80,17 @@ pub struct Config {
#[serde(default)]
pub time: Time,
/// Messages, shown to the user.
/// MOTD configuration.
#[serde(default)]
pub messages: Messages,
pub motd: Motd,
/// Join configuration.
#[serde(default)]
pub join: Join,
/// Lockout feature.
#[serde(default)]
pub lockout: Lockout,
/// RCON configuration.
#[serde(default)]
@@ -80,13 +99,33 @@ pub struct Config {
/// Advanced configuration.
#[serde(default)]
pub advanced: Advanced,
/// Config configuration.
#[serde(default)]
pub config: ConfigConfig,
}
impl Config {
/// Load configuration from file.
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
let data = fs::read(path)?;
let config = toml::from_slice(&data)?;
pub fn load(path: PathBuf) -> Result<Self, io::Error> {
let data = fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&data).map_err(io::Error::other)?;
// Show warning if config version is problematic
match &config.config.version {
None => warn!(target: "lazymc::config", "Config version unknown, it may be outdated"),
Some(version) => match version_compare::compare_to(version, CONFIG_VERSION, Cmp::Ge) {
Ok(false) => {
warn!(target: "lazymc::config", "Config is for older lazymc version, you may need to update it")
}
Err(_) => {
warn!(target: "lazymc::config", "Config version is invalid, you may need to update it")
}
Ok(true) => {}
},
}
config.path.replace(path);
Ok(config)
}
}
@@ -96,6 +135,7 @@ impl Config {
#[serde(default)]
pub struct Public {
/// Public address.
#[serde(deserialize_with = "to_socket_addrs")]
pub address: SocketAddr,
/// Minecraft protocol version name hint.
@@ -119,16 +159,26 @@ impl Default for Public {
#[derive(Debug, Deserialize)]
pub struct Server {
/// Server directory.
///
/// Private because you should use `Server::server_directory()` instead.
#[serde(default = "option_pathbuf_dot")]
pub directory: Option<PathBuf>,
directory: Option<PathBuf>,
/// Start command.
pub command: String,
/// Server address.
#[serde(default = "server_address_default")]
#[serde(
deserialize_with = "to_socket_addrs",
default = "server_address_default"
)]
pub address: SocketAddr,
/// Freeze the server process instead of restarting it when no players online, making it start up faster.
/// Only works on Unix (Linux or MacOS)
#[serde(default = "bool_true")]
pub freeze_process: bool,
/// Immediately wake server when starting lazymc.
#[serde(default)]
pub wake_on_start: bool,
@@ -136,6 +186,51 @@ pub struct Server {
/// Immediately wake server after crash.
#[serde(default)]
pub wake_on_crash: bool,
/// Probe required server details when starting lazymc, wakes server on start.
#[serde(default)]
pub probe_on_start: bool,
/// Whether this server runs forge.
#[serde(default)]
pub forge: bool,
/// Server starting timeout. Force kill server process if it takes longer.
#[serde(default = "u32_300")]
pub start_timeout: u32,
/// Server stopping timeout. Force kill server process if it takes longer.
#[serde(default = "u32_150")]
pub stop_timeout: u32,
/// To wake server, user must be in server whitelist if enabled on server.
#[serde(default = "bool_true")]
pub wake_whitelist: bool,
/// Block banned IPs as listed in banned-ips.json in server directory.
#[serde(default = "bool_true")]
pub block_banned_ips: bool,
/// Drop connections from banned IPs.
#[serde(default)]
pub drop_banned_ips: bool,
/// Add HAProxy v2 header to proxied connections.
#[serde(default)]
pub send_proxy_v2: bool,
}
impl Server {
/// Get the server directory.
///
/// This does not check whether it exists.
pub fn server_directory(config: &Config) -> Option<PathBuf> {
// Get directory, relative to config directory if known
match config.path.as_ref().and_then(|p| p.parent()) {
Some(config_dir) => Some(config_dir.join(config.server.directory.as_ref()?)),
None => config.server.directory.clone(),
}
}
}
/// Time configuration.
@@ -148,24 +243,6 @@ pub struct Time {
/// Minimum time in seconds to stay online when server is started.
#[serde(default, alias = "minimum_online_time")]
pub min_online_time: u32,
/// Hold client for number of seconds while server starts, instead of kicking immediately.
pub hold_client_for: u32,
/// Server starting timeout. Force kill server process if it takes longer.
#[serde(alias = "starting_timeout")]
pub start_timeout: u32,
/// Server stopping timeout. Force kill server process if it takes longer.
#[serde(alias = "stopping_timeout")]
pub stop_timeout: u32,
}
impl Time {
/// Whether to hold clients.
pub fn hold(&self) -> bool {
self.hold_client_for > 0
}
}
impl Default for Time {
@@ -173,45 +250,186 @@ impl Default for Time {
Self {
sleep_after: 60,
min_online_time: 60,
hold_client_for: 25,
start_timeout: 300,
stop_timeout: 150,
}
}
}
/// Message configuration.
/// MOTD configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Messages {
pub struct Motd {
/// MOTD when server is sleeping.
pub motd_sleeping: String,
pub sleeping: String,
/// MOTD when server is starting.
pub motd_starting: String,
pub starting: String,
/// MOTD when server is stopping.
pub motd_stopping: String,
pub stopping: String,
/// Use MOTD from Minecraft server once known.
pub use_server_motd: bool,
/// Login message when server is starting.
pub login_starting: String,
/// Login message when server is stopping.
pub login_stopping: String,
pub from_server: bool,
}
impl Default for Messages {
impl Default for Motd {
fn default() -> Self {
Self {
motd_sleeping: "☠ Server is sleeping\n§2☻ Join to start it up".into(),
motd_starting: "§2☻ Server is starting...\n§7⌛ Please wait...".into(),
motd_stopping: "☠ Server going to sleep...\n⌛ Please wait...".into(),
use_server_motd: false,
login_starting: "Server is starting... §c♥§r\n\nThis may take some time.\n\nPlease try to reconnect in a minute.".into(),
login_stopping: "Server is going to sleep... §7☠§r\n\nPlease try to reconnect in a minute to wake it again.".into(),
sleeping: "☠ Server is sleeping\n§2☻ Join to start it up".into(),
starting: "§2☻ Server is starting...\n§7⌛ Please wait...".into(),
stopping: "☠ Server going to sleep...\n⌛ Please wait...".into(),
from_server: false,
}
}
}
/// Join method types.
#[derive(Debug, Deserialize, Copy, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Method {
/// Kick client with message.
Kick,
/// Hold client connection until server is ready.
Hold,
/// Forward connection to another host.
Forward,
/// Keep client in temporary fake lobby until server is ready.
Lobby,
}
/// Join configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Join {
/// Join methods.
pub methods: Vec<Method>,
/// Join kick configuration.
#[serde(default)]
pub kick: JoinKick,
/// Join hold configuration.
#[serde(default)]
pub hold: JoinHold,
/// Join forward configuration.
#[serde(default)]
pub forward: JoinForward,
/// Join lobby configuration.
#[serde(default)]
pub lobby: JoinLobby,
}
impl Default for Join {
fn default() -> Self {
Self {
methods: vec![Method::Hold, Method::Kick],
kick: Default::default(),
hold: Default::default(),
forward: Default::default(),
lobby: Default::default(),
}
}
}
/// Join kick configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct JoinKick {
/// Kick message when server is starting.
pub starting: String,
/// Kick message when server is stopping.
pub stopping: String,
}
impl Default for JoinKick {
fn default() -> Self {
Self {
starting: "Server is starting... §c♥§r\n\nThis may take some time.\n\nPlease try to reconnect in a minute.".into(),
stopping: "Server is going to sleep... §7☠§r\n\nPlease try to reconnect in a minute to wake it again.".into(),
}
}
}
/// Join hold configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct JoinHold {
/// Hold client for number of seconds on connect while server starts.
pub timeout: u32,
}
impl Default for JoinHold {
fn default() -> Self {
Self { timeout: 25 }
}
}
/// Join forward configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct JoinForward {
/// IP and port to forward to.
#[serde(deserialize_with = "to_socket_addrs")]
pub address: SocketAddr,
/// Add HAProxy v2 header to proxied connections.
#[serde(default)]
pub send_proxy_v2: bool,
}
impl Default for JoinForward {
fn default() -> Self {
Self {
address: "127.0.0.1:25565".parse().unwrap(),
send_proxy_v2: false,
}
}
}
/// Join lobby configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct JoinLobby {
/// Hold client in lobby for number of seconds on connect while server starts.
pub timeout: u32,
/// Message banner in lobby shown to client.
pub message: String,
/// Sound effect to play when server is ready.
pub ready_sound: Option<String>,
}
impl Default for JoinLobby {
fn default() -> Self {
Self {
timeout: 10 * 60,
message: "§2Server is starting\n§7⌛ Please wait...".into(),
ready_sound: Some("block.note_block.chime".into()),
}
}
}
/// Lockout configuration.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Lockout {
/// Enable to prevent everybody from connecting through lazymc. Instantly kicks player.
pub enabled: bool,
/// Kick players with following message.
pub message: String,
}
impl Default for Lockout {
fn default() -> Self {
Self {
enabled: false,
message: "Server is closed §7☠§r\n\nPlease come back another time.".into(),
}
}
}
@@ -231,15 +449,19 @@ pub struct Rcon {
/// Randomize server RCON password on each start.
pub randomize_password: bool,
/// Add HAProxy v2 header to RCON connections.
pub send_proxy_v2: bool,
}
impl Default for Rcon {
fn default() -> Self {
Self {
enabled: true,
enabled: cfg!(windows),
port: 25575,
password: "".into(),
randomize_password: true,
send_proxy_v2: false,
}
}
}
@@ -260,6 +482,14 @@ impl Default for Advanced {
}
}
/// Config configuration.
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct ConfigConfig {
/// Configuration for lazymc version.
pub version: Option<String>,
}
fn option_pathbuf_dot() -> Option<PathBuf> {
Some(".".into())
}
@@ -267,3 +497,15 @@ fn option_pathbuf_dot() -> Option<PathBuf> {
fn server_address_default() -> SocketAddr {
"127.0.0.1:25566".parse().unwrap()
}
fn u32_300() -> u32 {
300
}
fn u32_150() -> u32 {
300
}
fn bool_true() -> bool {
true
}

255
src/forge.rs Normal file
View File

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

32
src/join/forward.rs Normal file
View File

@@ -0,0 +1,32 @@
use std::sync::Arc;
use bytes::BytesMut;
use tokio::net::TcpStream;
use crate::config::*;
use crate::proxy::ProxyHeader;
use crate::service;
use super::MethodResult;
/// Forward the client.
pub async fn occupy(
config: Arc<Config>,
inbound: TcpStream,
inbound_history: &mut BytesMut,
) -> Result<MethodResult, ()> {
trace!(target: "lazymc", "Using forward method to occupy joining client");
debug!(target: "lazymc", "Forwarding client to {:?}!", config.join.forward.address);
service::server::route_proxy_address_queue(
inbound,
ProxyHeader::Proxy.not_none(config.join.forward.send_proxy_v2),
config.join.forward.address,
inbound_history.clone(),
);
// TODO: do not consume, continue on proxy connect failure
Ok(MethodResult::Consumed)
}

101
src/join/hold.rs Normal file
View File

@@ -0,0 +1,101 @@
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use bytes::BytesMut;
use tokio::net::TcpStream;
use tokio::time;
use crate::config::*;
use crate::server::{Server, State};
use crate::service;
use super::MethodResult;
/// Hold the client.
pub async fn occupy(
config: Arc<Config>,
server: Arc<Server>,
inbound: TcpStream,
inbound_history: &mut BytesMut,
) -> Result<MethodResult, ()> {
trace!(target: "lazymc", "Using hold method to occupy joining client");
// Server must be starting
if server.state() != State::Starting {
return Ok(MethodResult::Continue(inbound));
}
// Start holding, consume client
if hold(&config, &server).await? {
service::server::route_proxy_queue(inbound, config, inbound_history.clone());
return Ok(MethodResult::Consumed);
}
Ok(MethodResult::Continue(inbound))
}
/// Hold a client while server starts.
///
/// Returns holding status. `true` if client is held and it should be proxied, `false` it was held
/// but it timed out.
async fn hold<'a>(config: &Config, server: &Server) -> Result<bool, ()> {
trace!(target: "lazymc", "Started holding client");
// A task to wait for suitable server state
// Waits for started state, errors if stopping/stopped state is reached
let task_wait = async {
let mut state = server.state_receiver();
loop {
// Wait for state change
state.changed().await.unwrap();
match state.borrow().deref() {
// Still waiting on server start
State::Starting => {
trace!(target: "lazymc", "Server not ready, holding client for longer");
continue;
}
// Server started, start relaying and proxy
State::Started => {
break true;
}
// Server stopping, this shouldn't happen, kick
State::Stopping => {
warn!(target: "lazymc", "Server stopping for held client, disconnecting");
break false;
}
// Server stopped, this shouldn't happen, disconnect
State::Stopped => {
error!(target: "lazymc", "Server stopped for held client, disconnecting");
break false;
}
}
}
};
// Wait for server state with timeout
let timeout = Duration::from_secs(config.join.hold.timeout as u64);
match time::timeout(timeout, task_wait).await {
// Relay client to proxy
Ok(true) => {
info!(target: "lazymc", "Server ready for held client, relaying to server");
Ok(true)
}
// Server stopping/stopped, this shouldn't happen, kick
Ok(false) => {
warn!(target: "lazymc", "Server stopping for held client");
Ok(false)
}
// Timeout reached, kick with starting message
Err(_) => {
warn!(target: "lazymc", "Held client reached timeout of {}s", config.join.hold.timeout);
Ok(false)
}
}
}

33
src/join/kick.rs Normal file
View File

@@ -0,0 +1,33 @@
use tokio::net::TcpStream;
use crate::config::*;
use crate::net;
use crate::proto::action;
use crate::proto::client::Client;
use crate::server::{self, Server};
use super::MethodResult;
/// Kick the client.
pub async fn occupy(
client: &Client,
config: &Config,
server: &Server,
mut inbound: TcpStream,
) -> Result<MethodResult, ()> {
trace!(target: "lazymc", "Using kick method to occupy joining client");
// Select message and kick
let msg = match server.state() {
server::State::Starting | server::State::Stopped | server::State::Started => {
&config.join.kick.starting
}
server::State::Stopping => &config.join.kick.stopping,
};
action::kick(client, msg, &mut inbound.split().1).await?;
// Gracefully close connection
net::close_tcp_stream(inbound).await.map_err(|_| ())?;
Ok(MethodResult::Consumed)
}

46
src/join/lobby.rs Normal file
View File

@@ -0,0 +1,46 @@
use std::sync::Arc;
use bytes::BytesMut;
use tokio::net::TcpStream;
use crate::config::*;
use crate::lobby;
use crate::proto::client::{Client, ClientInfo};
use crate::server::Server;
use super::MethodResult;
/// Lobby the client.
pub async fn occupy(
client: &Client,
client_info: ClientInfo,
config: Arc<Config>,
server: Arc<Server>,
inbound: TcpStream,
inbound_queue: BytesMut,
) -> Result<MethodResult, ()> {
trace!(target: "lazymc", "Using lobby method to occupy joining client");
// Must be ready to lobby
if must_still_probe(&config, &server).await {
warn!(target: "lazymc", "Client connected but lobby is not ready, using next join method, probing not completed");
return Ok(MethodResult::Continue(inbound));
}
// Start lobby
lobby::serve(client, client_info, inbound, config, server, inbound_queue).await?;
// TODO: do not consume client here, allow other join method on fail
Ok(MethodResult::Consumed)
}
/// Check whether we still have to probe before we can use the lobby.
async fn must_still_probe(config: &Config, server: &Server) -> bool {
must_probe(config) && server.probed_join_game.read().await.is_none()
}
/// Check whether we must have probed data.
fn must_probe(config: &Config) -> bool {
config.server.forge
}

106
src/join/mod.rs Normal file
View File

@@ -0,0 +1,106 @@
use std::sync::Arc;
use bytes::BytesMut;
use tokio::net::TcpStream;
use crate::config::*;
use crate::net;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::server::Server;
pub mod forward;
pub mod hold;
pub mod kick;
#[cfg(feature = "lobby")]
pub mod lobby;
/// A result returned by a join occupy method.
pub enum MethodResult {
/// Client is consumed.
Consumed,
/// Method is done, continue with the next.
Continue(TcpStream),
}
/// Start occupying client.
///
/// This assumes the login start packet has just been received.
pub async fn occupy(
client: Client,
#[allow(unused_variables)] client_info: ClientInfo,
config: Arc<Config>,
server: Arc<Server>,
mut inbound: TcpStream,
mut inbound_history: BytesMut,
#[allow(unused_variables)] login_queue: BytesMut,
) -> Result<(), ()> {
// Assert state is correct
assert_eq!(
client.state(),
ClientState::Login,
"when occupying client, it should be in login state"
);
// Go through all configured join methods
for method in &config.join.methods {
// Invoke method, take result
let result = match method {
// Kick method, immediately kick client
Method::Kick => kick::occupy(&client, &config, &server, inbound).await?,
// Hold method, hold client connection while server starts
Method::Hold => {
hold::occupy(
config.clone(),
server.clone(),
inbound,
&mut inbound_history,
)
.await?
}
// Forward method, forward client connection while server starts
Method::Forward => {
forward::occupy(config.clone(), inbound, &mut inbound_history).await?
}
// Lobby method, keep client in lobby while server starts
#[cfg(feature = "lobby")]
Method::Lobby => {
lobby::occupy(
&client,
client_info.clone(),
config.clone(),
server.clone(),
inbound,
login_queue.clone(),
)
.await?
}
// Lobby method, keep client in lobby while server starts
#[cfg(not(feature = "lobby"))]
Method::Lobby => {
error!(target: "lazymc", "Lobby join method not supported in this lazymc build");
MethodResult::Continue(inbound)
}
};
// Handle method result
match result {
MethodResult::Consumed => return Ok(()),
MethodResult::Continue(stream) => {
inbound = stream;
continue;
}
}
}
debug!(target: "lazymc", "No method left to occupy joining client, disconnecting");
// Gracefully close connection
net::close_tcp_stream(inbound).await.map_err(|_| ())?;
Ok(())
}

664
src/lobby.rs Normal file
View File

@@ -0,0 +1,664 @@
use std::io::ErrorKind;
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use bytes::BytesMut;
use futures::FutureExt;
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::version::v1_14_4::login::{
LoginPluginRequest, LoginPluginResponse, LoginStart, LoginSuccess, SetCompression,
};
use tokio::io::AsyncWriteExt;
use tokio::net::tcp::{ReadHalf, WriteHalf};
use tokio::net::TcpStream;
use tokio::select;
use tokio::time;
use crate::config::*;
use crate::forge;
use crate::mc::uuid;
use crate::net;
use crate::proto;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::proto::packets::play::join_game::JoinGameData;
use crate::proto::{packet, packets};
use crate::proxy;
use crate::server::{Server, State};
/// Interval to send keep-alive packets at.
pub const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
/// Timeout for creating new server connection for lobby client.
const SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2 * 60);
/// Timeout for server sending join game packet.
///
/// When the play state is reached, the server should immeditely respond with a join game packet.
/// This defines the maximum timeout for waiting on it.
const SERVER_JOIN_GAME_TIMEOUT: Duration = Duration::from_secs(20);
/// Time to wait before responding to newly connected server.
///
/// Notchian servers are slow, we must wait a little before sending play packets, because the
/// server needs time to transition the client into this state.
/// See warning at: <https://wiki.vg/Protocol#Login_Success>
const SERVER_WARMUP: Duration = Duration::from_secs(1);
/// Serve lobby service for given client connection.
///
/// The client must be in the login state, or this will error.
// TODO: do not drop error here, return Box<dyn Error>
// TODO: on error, nicely kick client with message
pub async fn serve(
client: &Client,
client_info: ClientInfo,
mut inbound: TcpStream,
config: Arc<Config>,
server: Arc<Server>,
queue: BytesMut,
) -> Result<(), ()> {
let (mut reader, mut writer) = inbound.split();
// Client must be in login state
if client.state() != ClientState::Login {
error!(target: "lazymc::lobby", "Client reached lobby service with invalid state: {:?}", client.state());
return Err(());
}
// We must have useful client info
if client_info.username.is_none() {
error!(target: "lazymc::lobby", "Client username is unknown, closing connection");
return Err(());
}
// Incoming buffer
let mut inbound_buf = queue;
loop {
// Read packet from stream
let (packet, _raw) = match packet::read_packet(client, &mut inbound_buf, &mut reader).await
{
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc", "Closing connection, error occurred");
break;
}
};
// Grab client state
let client_state = client.state();
// Hijack login start
if client_state == ClientState::Login && packet.id == packets::login::SERVER_LOGIN_START {
// Parse login start packet
let login_start = LoginStart::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
debug!(target: "lazymc::lobby", "Login on lobby server (user: {})", login_start.name);
// Replay Forge payload
if config.server.forge {
forge::replay_login_payload(client, &mut inbound, server.clone(), &mut inbound_buf)
.await?;
let (_returned_reader, returned_writer) = inbound.split();
writer = returned_writer;
}
// Respond with set compression if compression is enabled based on threshold
if proto::COMPRESSION_THRESHOLD >= 0 {
trace!(target: "lazymc::lobby", "Enabling compression for lobby client because server has it enabled (threshold: {})", proto::COMPRESSION_THRESHOLD);
respond_set_compression(client, &mut writer, proto::COMPRESSION_THRESHOLD).await?;
client.set_compression(proto::COMPRESSION_THRESHOLD);
}
// Respond with login success, switch to play state
respond_login_success(client, &mut writer, &login_start).await?;
client.set_state(ClientState::Play);
trace!(target: "lazymc::lobby", "Client login success, sending required play packets for lobby world");
// Send packets to client required to get into workable play state for lobby world
send_lobby_play_packets(client, &client_info, &mut writer, &server).await?;
// Wait for server to come online
stage_wait(client, &client_info, &server, &config, &mut writer).await?;
// Start new connection to server
let server_client_info = client_info.clone();
let (server_client, mut outbound, mut server_buf) =
connect_to_server(&server_client_info, &inbound, &config).await?;
let (returned_reader, returned_writer) = inbound.split();
reader = returned_reader;
writer = returned_writer;
// Grab join game packet from server
let join_game_data = wait_for_server_join_game(
&server_client,
&server_client_info,
&mut outbound,
&mut server_buf,
)
.await?;
// Reset lobby title
packets::play::title::send(client, &client_info, &mut writer, "").await?;
// Play ready sound if configured
play_lobby_ready_sound(client, &client_info, &mut writer, &config).await?;
// Wait a second because Notchian servers are slow
// See: https://wiki.vg/Protocol#Login_Success
trace!(target: "lazymc::lobby", "Waiting a second before relaying client connection...");
time::sleep(SERVER_WARMUP).await;
// Send respawn packet, initiates teleport to real server world
packets::play::respawn::lobby_send(client, &client_info, &mut writer, join_game_data)
.await?;
// Drain inbound connection so we don't confuse the server
// TODO: can we void everything? we might need to forward everything to server except
// for some blacklisted ones
trace!(target: "lazymc::lobby", "Voiding remaining incoming lobby client data before relay to real server");
drain_stream(&mut reader).await?;
// Client and server connection ready now, move client to proxy
debug!(target: "lazymc::lobby", "Server connection ready, relaying lobby client to proxy");
route_proxy(inbound, outbound, server_buf);
return Ok(());
}
// Show unhandled packet warning
debug!(target: "lazymc", "Got unhandled packet:");
debug!(target: "lazymc", "- State: {:?}", client_state);
debug!(target: "lazymc", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
// Gracefully close connection
net::close_tcp_stream(inbound).await.map_err(|_| ())?;
Ok(())
}
/// Respond to client with a set compression packet.
async fn respond_set_compression(
client: &Client,
writer: &mut WriteHalf<'_>,
threshold: i32,
) -> Result<(), ()> {
packet::write_packet(SetCompression { threshold }, client, writer).await
}
/// Respond to client with login success packet
// TODO: support online mode here
async fn respond_login_success(
client: &Client,
writer: &mut WriteHalf<'_>,
login_start: &LoginStart,
) -> Result<(), ()> {
packet::write_packet(
LoginSuccess {
uuid: uuid::offline_player_uuid(&login_start.name),
username: login_start.name.clone(),
},
client,
writer,
)
.await
}
/// Play lobby ready sound effect if configured.
async fn play_lobby_ready_sound(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
config: &Config,
) -> Result<(), ()> {
if let Some(sound_name) = config.join.lobby.ready_sound.as_ref() {
// Must not be empty string
if sound_name.trim().is_empty() {
warn!(target: "lazymc::lobby", "Lobby ready sound effect is an empty string, you should remove the configuration item instead");
return Ok(());
}
// Play sound effect
packets::play::player_pos::send(client, client_info, writer).await?;
packets::play::sound::send(client, client_info, writer, sound_name).await?;
}
Ok(())
}
/// Send packets to client to get workable play state for lobby world.
async fn send_lobby_play_packets(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
server: &Server,
) -> Result<(), ()> {
// See: https://wiki.vg/Protocol_FAQ#What.27s_the_normal_login_sequence_for_a_client.3F
// Send initial game join
packets::play::join_game::lobby_send(client, client_info, writer, server).await?;
// Send server brand
packets::play::server_brand::send(client, client_info, writer).await?;
// Send spawn and player position, disables 'download terrain' screen
packets::play::player_pos::send(client, client_info, writer).await?;
// Notify client of world time, required once before keep-alive packets
packets::play::time_update::send(client, client_info, writer).await?;
Ok(())
}
/// An infinite keep-alive loop.
///
/// This will keep sending keep-alive and title packets to the client until it is dropped.
async fn keep_alive_loop(
client: &Client,
client_info: &ClientInfo,
writer: &mut WriteHalf<'_>,
config: &Config,
) -> Result<(), ()> {
let mut interval = time::interval(KEEP_ALIVE_INTERVAL);
loop {
interval.tick().await;
trace!(target: "lazymc::lobby", "Sending keep-alive sequence to lobby client");
// Send keep alive and title packets
packets::play::keep_alive::send(client, client_info, writer).await?;
packets::play::title::send(client, client_info, writer, &config.join.lobby.message).await?;
// TODO: verify we receive correct keep alive response
}
}
/// Waiting stage.
///
/// In this stage we wait for the server to come online.
///
/// During this stage we keep sending keep-alive and title packets to the client to keep it active.
async fn stage_wait(
client: &Client,
client_info: &ClientInfo,
server: &Server,
config: &Config,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
select! {
a = keep_alive_loop(client, client_info, writer, config) => a,
b = wait_for_server(server, config) => b,
}
}
/// Wait for the server to come online.
///
/// Returns `Ok(())` once the server is online, returns `Err(())` if waiting failed.
async fn wait_for_server(server: &Server, config: &Config) -> Result<(), ()> {
debug!(target: "lazymc::lobby", "Waiting on server to come online...");
// A task to wait for suitable server state
// Waits for started state, errors if stopping/stopped state is reached
let task_wait = async {
let mut state = server.state_receiver();
loop {
// Wait for state change
state.changed().await.unwrap();
match state.borrow().deref() {
// Still waiting on server start
State::Starting => {
trace!(target: "lazymc::lobby", "Server not ready, holding client for longer");
continue;
}
// Server started, start relaying and proxy
State::Started => {
break true;
}
// Server stopping, this shouldn't happen, kick
State::Stopping | State::Stopped => {
break false;
}
}
}
};
// Wait for server state with timeout
let timeout = Duration::from_secs(config.join.lobby.timeout as u64);
match time::timeout(timeout, task_wait).await {
// Relay client to proxy
Ok(true) => {
debug!(target: "lazymc::lobby", "Server ready for lobby client");
return Ok(());
}
// Server stopping/stopped, this shouldn't happen, disconnect
Ok(false) => {}
// Timeout reached, disconnect
Err(_) => {
warn!(target: "lazymc::lobby", "Lobby client waiting for server to come online reached timeout of {}s", timeout.as_secs());
}
}
Err(())
}
/// Create connection to the server, with timeout.
///
/// This will initialize the connection to the play state. Client details are used.
async fn connect_to_server(
client_info: &ClientInfo,
inbound: &TcpStream,
config: &Config,
) -> Result<(Client, TcpStream, BytesMut), ()> {
time::timeout(
SERVER_CONNECT_TIMEOUT,
connect_to_server_no_timeout(client_info, inbound, config),
)
.await
.map_err(|_| {
error!(target: "lazymc::lobby", "Creating new server connection for lobby client timed out after {}s", SERVER_CONNECT_TIMEOUT.as_secs());
})?
}
/// Create connection to the server, with no timeout.
///
/// This will initialize the connection to the play state. Client details are used.
// TODO: clean this up
async fn connect_to_server_no_timeout(
client_info: &ClientInfo,
inbound: &TcpStream,
config: &Config,
) -> Result<(Client, TcpStream, BytesMut), ()> {
// Open connection
// TODO: on connect fail, ping server and redirect to serve_status if offline
let mut outbound = TcpStream::connect(config.server.address)
.await
.map_err(|_| ())?;
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::lobby", "Sending client proxy header for server connection");
outbound
.write_all(&proxy::stream_proxy_header(inbound).map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Construct temporary server client
let tmp_client = match outbound.local_addr() {
Ok(addr) => Client::new(addr),
Err(_) => Client::dummy(),
};
tmp_client.set_state(ClientState::Login);
let (mut reader, mut writer) = outbound.split();
// Replay client handshake packet
assert_eq!(
client_info.handshake.as_ref().unwrap().next_state,
ClientState::Login.to_id(),
"Client handshake should have login as next state"
);
packet::write_packet(
client_info.handshake.clone().unwrap(),
&tmp_client,
&mut writer,
)
.await?;
// Request login start
packet::write_packet(
LoginStart {
name: client_info.username.clone().ok_or(())?,
},
&tmp_client,
&mut writer,
)
.await?;
// Incoming buffer
let mut buf = BytesMut::new();
loop {
// Read packet from stream
let (packet, _raw) = match packet::read_packet(&tmp_client, &mut buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc::lobby", "Closing connection, error occurred");
break;
}
};
// Grab client state
let client_state = tmp_client.state();
// Catch set compression
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_SET_COMPRESSION
{
// Decode compression packet
let set_compression =
SetCompression::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
// Client and server compression threshold should match, show warning if not
if set_compression.threshold != proto::COMPRESSION_THRESHOLD {
error!(
target: "lazymc::lobby",
"Compression threshold sent to lobby client does not match threshold from server, this may cause errors (client: {}, server: {})",
proto::COMPRESSION_THRESHOLD,
set_compression.threshold
);
}
// Set client compression
tmp_client.set_compression(set_compression.threshold);
continue;
}
// Catch encryption requests
if client_state == ClientState::Login
&& packet.id == packets::login::CLIENT_ENCRYPTION_REQUEST
{
error!(
target: "lazymc::lobby",
"Got encryption request from server, this is unsupported. Server must be in offline mode to use lobby.",
);
break;
}
// Hijack login plugin request
if client_state == ClientState::Login
&& packet.id == packets::login::CLIENT_LOGIN_PLUGIN_REQUEST
{
// Decode login plugin request
let plugin_request =
LoginPluginRequest::decode(&mut packet.data.as_slice()).map_err(|err| {
dbg!(err);
})?;
// Respond with Forge messages
if config.server.forge {
trace!(target: "lazymc::lobby", "Got login plugin request from server, responding with Forge reply");
// Respond to Forge login plugin request
forge::respond_login_plugin_request(&tmp_client, plugin_request, &mut writer)
.await?;
continue;
}
warn!(target: "lazymc::lobby", "Got unexpected login plugin request from server, you may need to enable Forge support");
// Write unsuccesful login plugin response
packet::write_packet(
LoginPluginResponse {
message_id: plugin_request.message_id,
successful: false,
data: vec![],
},
&tmp_client,
&mut writer,
)
.await?;
continue;
}
// Hijack login success
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_LOGIN_SUCCESS {
trace!(target: "lazymc::lobby", "Got login success from server connection, change to play mode");
// TODO: parse this packet to ensure it's fine
// let login_success =
// LoginSuccess::decode(&mut packet.data.as_slice()).map_err(|err| {
// dbg!(err);
// ()
// })?;
// Switch to play state
tmp_client.set_state(ClientState::Play);
// Server must enable compression if enabled for client, show warning otherwise
if tmp_client.is_compressed() != (proto::COMPRESSION_THRESHOLD >= 0) {
error!(target: "lazymc::lobby", "Compression enabled for lobby client while the server did not, this will cause errors");
}
return Ok((tmp_client, outbound, buf));
}
// Hijack disconnect
if client_state == ClientState::Login && packet.id == packets::login::CLIENT_DISCONNECT {
error!(target: "lazymc::lobby", "Got disconnect from server connection");
// // Decode disconnect packet
// let login_disconnect =
// LoginDisconnect::decode(&mut packet.data.as_slice()).map_err(|err| {
// dbg!(err);
// })?;
// TODO: report/forward error to client
break;
}
// Show unhandled packet warning
debug!(target: "lazymc::lobby", "Got unhandled packet from server in connect_to_server:");
debug!(target: "lazymc::lobby", "- State: {:?}", client_state);
debug!(target: "lazymc::lobby", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
// Gracefully close connection
net::close_tcp_stream(outbound).await.map_err(|_| ())?;
Err(())
}
/// Wait for join game packet on server connection, with timeout.
///
/// This parses, consumes and returns the packet.
async fn wait_for_server_join_game(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGameData, ()> {
time::timeout(
SERVER_JOIN_GAME_TIMEOUT,
wait_for_server_join_game_no_timeout(client, client_info, outbound, buf),
)
.await
.map_err(|_| {
error!(target: "lazymc::lobby", "Waiting for for game data from server for lobby client timed out after {}s", SERVER_JOIN_GAME_TIMEOUT.as_secs());
})?
}
/// Wait for join game packet on server connection, with no timeout.
///
/// This parses, consumes and returns the packet.
// TODO: clean this up
// TODO: do not drop error here, return Box<dyn Error>
async fn wait_for_server_join_game_no_timeout(
client: &Client,
client_info: &ClientInfo,
outbound: &mut TcpStream,
buf: &mut BytesMut,
) -> Result<JoinGameData, ()> {
let (mut reader, mut _writer) = outbound.split();
loop {
// Read packet from stream
let (packet, _raw) = match packet::read_packet(client, buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
error!(target: "lazymc::lobby", "Closing connection, error occurred");
break;
}
};
// Catch join game
if packets::play::join_game::is_packet(client_info, packet.id) {
// Parse join game data
let join_game_data = JoinGameData::from_packet(client_info, packet).map_err(|err| {
warn!(target: "lazymc::lobby", "Failed to parse join game packet: {:?}", err);
})?;
return Ok(join_game_data);
}
// Show unhandled packet warning
debug!(target: "lazymc::lobby", "Got unhandled packet from server in wait_for_server_join_game:");
debug!(target: "lazymc::lobby", "- Packet ID: 0x{:02X} ({})", packet.id, packet.id);
}
// Gracefully close connection
net::close_tcp_stream_ref(outbound).await.map_err(|_| ())?;
Err(())
}
/// Route our lobby client through the proxy to the real server, spawning a new task.
///
/// `inbound_queue` is used for data already received from the server, that needs to be pushed to
/// the client.
#[inline]
pub fn route_proxy(inbound: TcpStream, outbound: TcpStream, inbound_queue: BytesMut) {
// When server is online, proxy all
let service = async move {
proxy::proxy_inbound_outbound_with_queue(inbound, outbound, &inbound_queue, &[])
.map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to proxy: {}", err);
}
})
.await
};
tokio::spawn(service);
}
/// Drain given reader until nothing is left voiding all data.
async fn drain_stream(reader: &mut ReadHalf<'_>) -> Result<(), ()> {
let mut drain_buf = [0; 8 * 1024];
loop {
match reader.try_read(&mut drain_buf) {
Ok(0) => return Ok(()),
Err(err) if err.kind() == ErrorKind::WouldBlock => return Ok(()),
Ok(_) => continue,
Err(err) => {
error!(target: "lazymc::lobby", "Failed to drain lobby client connection before relaying to real server. Maybe already disconnected? Error: {:?}", err);
return Ok(());
}
}
}
}

View File

@@ -10,9 +10,15 @@ extern crate log;
pub(crate) mod action;
pub(crate) mod cli;
pub(crate) mod config;
pub(crate) mod forge;
pub(crate) mod join;
#[cfg(feature = "lobby")]
pub(crate) mod lobby;
pub(crate) mod mc;
pub(crate) mod monitor;
pub(crate) mod net;
pub(crate) mod os;
pub(crate) mod probe;
pub(crate) mod proto;
pub(crate) mod proxy;
pub(crate) mod server;
@@ -23,7 +29,11 @@ pub(crate) mod util;
use std::env;
use clap::App;
use clap::Command;
// Compile time feature compatability check.
#[cfg(all(windows, not(feature = "rcon")))]
compile_error!("Must enable \"rcon\" feature on Windows.");
/// Default log level if none is set.
const LOG_DEFAULT: &str = "info";
@@ -53,7 +63,7 @@ fn init_log() {
}
/// Invoke an action.
fn invoke_action(app: App) -> Result<(), ()> {
fn invoke_action(app: Command) -> Result<(), ()> {
let matches = app.get_matches();
// Config operations

99
src/mc/ban.rs Normal file
View File

@@ -0,0 +1,99 @@
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::net::IpAddr;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// File name.
pub const FILE: &str = "banned-ips.json";
/// The forever expiry literal.
const EXPIRY_FOREVER: &str = "forever";
/// List of banned IPs.
#[derive(Debug, Default)]
pub struct BannedIps {
/// List of banned IPs.
ips: HashMap<IpAddr, BannedIp>,
}
impl BannedIps {
/// Get ban entry if IP if it exists.
///
/// This uses the latest known `banned-ips.json` contents if known.
/// If this feature is disabled, this will always return false.
pub fn get(&self, ip: &IpAddr) -> Option<BannedIp> {
self.ips.get(ip).cloned()
}
/// Check whether the given IP is banned.
///
/// This uses the latest known `banned-ips.json` contents if known.
/// If this feature is disabled, this will always return false.
pub fn is_banned(&self, ip: &IpAddr) -> bool {
self.ips.get(ip).map(|ip| ip.is_banned()).unwrap_or(false)
}
}
/// A banned IP entry.
#[derive(Debug, Deserialize, Clone)]
pub struct BannedIp {
/// Banned IP.
pub ip: IpAddr,
/// Ban creation time.
pub created: Option<String>,
/// Ban source.
pub source: Option<String>,
/// Ban expiry time.
pub expires: Option<String>,
/// Ban reason.
pub reason: Option<String>,
}
impl BannedIp {
/// Check if this entry is currently banned.
pub fn is_banned(&self) -> bool {
// Get expiry time
let expires = match &self.expires {
Some(expires) => expires,
None => return true,
};
// If expiry is forever, the user is banned
if expires.trim().to_lowercase() == EXPIRY_FOREVER {
return true;
}
// Parse expiry time, check if it has passed
let expiry = match DateTime::parse_from_str(expires, "%Y-%m-%d %H:%M:%S %z") {
Ok(expiry) => expiry,
Err(err) => {
error!(target: "lazymc", "Failed to parse ban expiry '{}', assuming still banned: {}", expires, err);
return true;
}
};
expiry > Utc::now()
}
}
/// Load banned IPs from file.
pub fn load(path: &Path) -> Result<BannedIps, Box<dyn Error>> {
// Load file contents
let contents = fs::read_to_string(path)?;
// Parse contents
let ips: Vec<BannedIp> = serde_json::from_str(&contents)?;
debug!(target: "lazymc", "Loaded {} banned IPs", ips.len());
// Transform into map
let ips = ips.into_iter().map(|ip| (ip.ip, ip)).collect();
Ok(BannedIps { ips })
}

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

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

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

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

View File

@@ -1,3 +1,14 @@
pub mod ban;
#[cfg(feature = "lobby")]
pub mod dimension;
pub mod favicon;
#[cfg(feature = "rcon")]
pub mod rcon;
pub mod server_properties;
#[cfg(feature = "lobby")]
pub mod uuid;
pub mod whitelist;
/// Minecraft ticks per second.
#[allow(unused)]
pub const TICKS_PER_SECOND: u32 = 20;

View File

@@ -1,25 +1,73 @@
use std::time::Duration;
use rust_rcon::{Connection, Error as RconError};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use tokio::time;
use crate::config::Config;
use crate::proxy;
/// Minecraft RCON quirk.
///
/// Wait this time between RCON operations.
/// The Minecraft RCON implementation is very broken and brittle, this is used in the hopes to
/// improve reliability.
const QUIRK_RCON_GRACE_TIME: Duration = Duration::from_millis(200);
/// An RCON client.
pub struct Rcon {
con: Connection,
con: Connection<TcpStream>,
}
impl Rcon {
/// Connect to a host.
pub async fn connect(addr: &str, pass: &str) -> Result<Self, Box<dyn std::error::Error>> {
pub async fn connect(
config: &Config,
addr: &str,
pass: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
// Connect to our TCP stream
let mut stream = TcpStream::connect(addr).await?;
// Add proxy header
if config.rcon.send_proxy_v2 {
trace!(target: "lazymc::rcon", "Sending local proxy header for RCON connection");
stream.write_all(&proxy::local_proxy_header()?).await?;
}
// Start connection
let con = Connection::builder()
.enable_minecraft_quirks(true)
.connect(addr, pass)
.handshake(stream, pass)
.await?;
Ok(Self { con })
}
/// Connect to a host from the given configuration.
pub async fn connect_config(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
// RCON address
let mut addr = config.server.address;
addr.set_port(config.rcon.port);
let addr = addr.to_string();
Self::connect(config, &addr, &config.rcon.password).await
}
/// Send command over RCON.
pub async fn cmd(&mut self, cmd: &str) -> Result<String, RconError> {
// Minecraft quirk
time::sleep(QUIRK_RCON_GRACE_TIME).await;
// Actually send RCON command
debug!(target: "lazymc::rcon", "Sending RCON: {}", cmd);
self.con.cmd(cmd).await
}
/// Close connection.
pub async fn close(self) {
// Minecraft quirk
time::sleep(QUIRK_RCON_GRACE_TIME).await;
}
}

View File

@@ -11,39 +11,39 @@ const EOL: &str = "\r\n";
/// Try to rewrite changes in server.properties file in dir.
///
/// Prints an error and stops on failure.
pub fn rewrite_dir(dir: &Path, changes: HashMap<&str, String>) {
pub fn rewrite_dir<P: AsRef<Path>>(dir: P, changes: HashMap<&str, String>) {
if changes.is_empty() {
return;
}
// Ensure directory exists
if !dir.is_dir() {
if !dir.as_ref().is_dir() {
warn!(target: "lazymc",
"Not rewriting {} file, configured server directory doesn't exist: {}",
FILE,
dir.to_str().unwrap_or("?")
dir.as_ref().to_str().unwrap_or("?")
);
return;
}
// Rewrite file
rewrite_file(&dir.join(FILE), changes)
rewrite_file(dir.as_ref().join(FILE), changes)
}
/// Try to rewrite changes in server.properties file.
///
/// Prints an error and stops on failure.
pub fn rewrite_file(file: &Path, changes: HashMap<&str, String>) {
pub fn rewrite_file<P: AsRef<Path>>(file: P, changes: HashMap<&str, String>) {
if changes.is_empty() {
return;
}
// File must exist
if !file.is_file() {
if !file.as_ref().is_file() {
warn!(target: "lazymc",
"Not writing {} file, not found at: {}",
FILE,
file.to_str().unwrap_or("?"),
file.as_ref().to_str().unwrap_or("?"),
);
return;
}
@@ -114,7 +114,7 @@ fn rewrite_contents(contents: String, mut changes: HashMap<&str, String>) -> Opt
}
// Try to split property
let (key, value) = match line.split_once("=") {
let (key, value) = match line.split_once('=') {
Some(result) => result,
None => return line,
};
@@ -122,7 +122,7 @@ fn rewrite_contents(contents: String, mut changes: HashMap<&str, String>) -> Opt
// Take any new value, and update it
if let Some((_, new)) = changes.remove_entry(key.trim().to_lowercase().as_str()) {
if value != new {
line = format!("{}={}", key, new);
line = format!("{key}={new}");
changed = true;
}
}
@@ -134,7 +134,7 @@ fn rewrite_contents(contents: String, mut changes: HashMap<&str, String>) -> Opt
// Append any missed changes
for (key, value) in changes {
new_contents += &format!("{}{}={}", EOL, key, value);
new_contents += &format!("{EOL}{key}={value}");
changed = true;
}
@@ -145,3 +145,37 @@ fn rewrite_contents(contents: String, mut changes: HashMap<&str, String>) -> Opt
None
}
}
/// Read the given property from the given server.properties file.o
///
/// Returns `None` if file does not contain the property.
pub fn read_property<P: AsRef<Path>>(file: P, property: &str) -> Option<String> {
// File must exist
if !file.as_ref().is_file() {
warn!(target: "lazymc",
"Failed to read property from {} file, it does not exist",
FILE,
);
return None;
}
// Read contents
let contents = match fs::read_to_string(&file) {
Ok(contents) => contents,
Err(err) => {
error!(target: "lazymc",
"Failed to read property from {} file, could not load: {}",
FILE,
err,
);
return None;
}
};
// Find property, return value
contents
.lines()
.filter_map(|line| line.split_once('='))
.find(|(p, _)| p.trim().to_lowercase() == property.to_lowercase())
.map(|(_, v)| v.trim().to_string())
}

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

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

107
src/mc/whitelist.rs Normal file
View File

@@ -0,0 +1,107 @@
use std::error::Error;
use std::fs;
use std::path::Path;
use serde::Deserialize;
/// Whitelist file name.
pub const WHITELIST_FILE: &str = "whitelist.json";
/// OPs file name.
pub const OPS_FILE: &str = "ops.json";
/// Whitelisted users.
///
/// Includes list of OPs, which are also automatically whitelisted.
#[derive(Debug, Default)]
pub struct Whitelist {
/// Whitelisted users.
whitelist: Vec<String>,
/// OPd users.
ops: Vec<String>,
}
impl Whitelist {
/// Check whether a user is whitelisted.
pub fn is_whitelisted(&self, username: &str) -> bool {
self.whitelist.iter().any(|u| u == username) || self.ops.iter().any(|u| u == username)
}
}
/// A whitelist user.
#[derive(Debug, Deserialize, Clone)]
pub struct WhitelistUser {
/// Whitelisted username.
#[serde(rename = "name", alias = "username")]
pub username: String,
/// Whitelisted UUID.
pub uuid: Option<String>,
}
/// An OP user.
#[derive(Debug, Deserialize, Clone)]
pub struct OpUser {
/// OP username.
#[serde(rename = "name", alias = "username")]
pub username: String,
/// OP UUID.
pub uuid: Option<String>,
/// OP level.
pub level: Option<u32>,
/// Whether OP can bypass player limit.
#[serde(rename = "bypassesPlayerLimit")]
pub byapsses_player_limit: Option<bool>,
}
/// Load whitelist from directory.
pub fn load_dir(path: &Path) -> Result<Whitelist, Box<dyn Error>> {
let whitelist_file = path.join(WHITELIST_FILE);
let ops_file = path.join(OPS_FILE);
// Load whitelist users
let whitelist = if whitelist_file.is_file() {
load_whitelist(&whitelist_file)?
} else {
vec![]
};
// Load OPd users
let ops = if ops_file.is_file() {
load_ops(&ops_file)?
} else {
vec![]
};
debug!(target: "lazymc", "Loaded {} whitelist and {} OP users", whitelist.len(), ops.len());
Ok(Whitelist { whitelist, ops })
}
/// Load whitelist from file.
fn load_whitelist(path: &Path) -> Result<Vec<String>, Box<dyn Error>> {
// Load file contents
let contents = fs::read_to_string(path)?;
// Parse contents
let users: Vec<WhitelistUser> = serde_json::from_str(&contents)?;
// Pluck usernames
Ok(users.into_iter().map(|user| user.username).collect())
}
/// Load OPs from file.
fn load_ops(path: &Path) -> Result<Vec<String>, Box<dyn Error>> {
// Load file contents
let contents = fs::read_to_string(path)?;
// Parse contents
let users: Vec<OpUser> = serde_json::from_str(&contents)?;
// Pluck usernames
Ok(users.into_iter().map(|user| user.username).collect())
}

View File

@@ -1,29 +1,29 @@
// TODO: remove all unwraps/expects here!
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::BytesMut;
use minecraft_protocol::data::server_status::ServerStatus;
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::encoder::Encoder;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
use minecraft_protocol::version::v1_14_4::status::{PingRequest, PingResponse, StatusResponse};
use minecraft_protocol::version::v1_20_3::status::{
PingRequest, PingResponse, ServerStatus, StatusRequest, StatusResponse,
};
use rand::Rng;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use tokio::time;
use crate::config::Config;
use crate::proto::{self, ClientState, RawPacket};
use crate::proto::client::{Client, ClientState};
use crate::proto::{packet, packets};
use crate::proxy;
use crate::server::{Server, State};
/// Monitor ping inverval in seconds.
const MONITOR_POLL_INTERVAL: Duration = Duration::from_secs(2);
/// Status request timeout in seconds.
const STATUS_TIMEOUT: u64 = 8;
const STATUS_TIMEOUT: u64 = 20;
/// Ping request timeout in seconds.
const PING_TIMEOUT: u64 = 10;
@@ -36,15 +36,17 @@ pub async fn monitor_server(config: Arc<Config>, server: Arc<Server>) {
let mut poll_interval = time::interval(MONITOR_POLL_INTERVAL);
loop {
poll_interval.tick().await;
// Poll server state and update internal status
trace!(target: "lazymc::monitor", "Fetching status for {} ... ", addr);
let status = poll_server(&config, &server, addr).await;
match status {
// Got status, update
Ok(Some(status)) => server.update_status(&config, Some(status)),
Ok(Some(status)) => server.update_status(&config, Some(status)).await,
// Error, reset status
Err(_) => server.update_status(&config, None),
Err(_) => server.update_status(&config, None).await,
// Didn't get status, but ping fallback worked, leave as-is, show warning
Ok(None) => {
@@ -53,20 +55,18 @@ pub async fn monitor_server(config: Arc<Config>, server: Arc<Server>) {
}
// Sleep server when it's bedtime
if server.should_sleep(&config) {
info!(target: "lazymc::montior", "Server has been idle, sleeping...");
if server.should_sleep(&config).await {
info!(target: "lazymc::monitor", "Server has been idle, sleeping...");
server.stop(&config).await;
}
// Check whether we should force kill server
if server.should_kill() {
error!(target: "lazymc::montior", "Force killing server, took too long to start or stop");
if server.should_kill().await {
error!(target: "lazymc::monitor", "Force killing server, took too long to start or stop");
if !server.force_kill().await {
warn!(target: "lazymc", "Failed to force kill server");
}
}
poll_interval.tick().await;
}
}
@@ -86,6 +86,7 @@ pub async fn poll_server(
// Try ping fallback if server is currently started
if server.state() == State::Started {
debug!(target: "lazymc::monitor", "Failed to get status from started server, trying ping...");
do_ping(config, addr).await?;
}
@@ -96,84 +97,92 @@ pub async fn poll_server(
async fn fetch_status(config: &Config, addr: SocketAddr) -> Result<ServerStatus, ()> {
let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?;
send_handshake(&mut stream, config, addr).await?;
request_status(&mut stream).await?;
wait_for_status_timeout(&mut stream).await
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::monitor", "Sending local proxy header for server connection");
stream
.write_all(&proxy::local_proxy_header().map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Dummy client
let client = Client::dummy();
send_handshake(&client, &mut stream, config, addr).await?;
request_status(&client, &mut stream).await?;
wait_for_status_timeout(&client, &mut stream).await
}
/// Attemp to ping server.
async fn do_ping(config: &Config, addr: SocketAddr) -> Result<(), ()> {
let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?;
send_handshake(&mut stream, config, addr).await?;
let token = send_ping(&mut stream).await?;
wait_for_ping_timeout(&mut stream, token).await
// Add proxy header
if config.server.send_proxy_v2 {
trace!(target: "lazymc::monitor", "Sending local proxy header for server connection");
stream
.write_all(&proxy::local_proxy_header().map_err(|_| ())?)
.await
.map_err(|_| ())?;
}
// Dummy client
let client = Client::dummy();
send_handshake(&client, &mut stream, config, addr).await?;
let token = send_ping(&client, &mut stream).await?;
wait_for_ping_timeout(&client, &mut stream, token).await
}
/// Send handshake.
async fn send_handshake(
client: &Client,
stream: &mut TcpStream,
config: &Config,
addr: SocketAddr,
) -> Result<(), ()> {
let handshake = Handshake {
protocol_version: config.public.protocol as i32,
server_addr: addr.ip().to_string(),
server_port: addr.port(),
next_state: ClientState::Status.to_id(),
};
let mut packet = Vec::new();
handshake.encode(&mut packet).map_err(|_| ())?;
let raw = RawPacket::new(proto::HANDSHAKE_PACKET_ID_HANDSHAKE, packet)
.encode()
.map_err(|_| ())?;
stream.write_all(&raw).await.map_err(|_| ())?;
Ok(())
packet::write_packet(
Handshake {
protocol_version: config.public.protocol as i32,
server_addr: addr.ip().to_string(),
server_port: addr.port(),
next_state: ClientState::Status.to_id(),
},
client,
&mut stream.split().1,
)
.await
}
/// Send status request.
async fn request_status(stream: &mut TcpStream) -> Result<(), ()> {
let raw = RawPacket::new(proto::STATUS_PACKET_ID_STATUS, vec![])
.encode()
.map_err(|_| ())?;
stream.write_all(&raw).await.map_err(|_| ())?;
Ok(())
async fn request_status(client: &Client, stream: &mut TcpStream) -> Result<(), ()> {
packet::write_packet(StatusRequest {}, client, &mut stream.split().1).await
}
/// Send status request.
async fn send_ping(stream: &mut TcpStream) -> Result<u64, ()> {
async fn send_ping(client: &Client, stream: &mut TcpStream) -> Result<u64, ()> {
let token = rand::thread_rng().gen();
let ping = PingRequest { time: token };
let mut packet = Vec::new();
ping.encode(&mut packet).map_err(|_| ())?;
let raw = RawPacket::new(proto::STATUS_PACKET_ID_PING, packet)
.encode()
.map_err(|_| ())?;
stream.write_all(&raw).await.map_err(|_| ())?;
packet::write_packet(PingRequest { time: token }, client, &mut stream.split().1).await?;
Ok(token)
}
/// Wait for a status response.
async fn wait_for_status(stream: &mut TcpStream) -> Result<ServerStatus, ()> {
async fn wait_for_status(client: &Client, stream: &mut TcpStream) -> Result<ServerStatus, ()> {
// Get stream reader, set up buffer
let (mut reader, mut _writer) = stream.split();
let mut buf = BytesMut::new();
loop {
// Read packet from stream
let (packet, _raw) = match proto::read_packet(&mut buf, &mut reader).await {
let (packet, _raw) = match packet::read_packet(client, &mut buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => continue,
};
// Catch status response
if packet.id == proto::STATUS_PACKET_ID_STATUS {
if packet.id == packets::status::CLIENT_STATUS {
let status = StatusResponse::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
return Ok(status.server_status);
}
@@ -184,29 +193,32 @@ async fn wait_for_status(stream: &mut TcpStream) -> Result<ServerStatus, ()> {
}
/// Wait for a status response.
async fn wait_for_status_timeout(stream: &mut TcpStream) -> Result<ServerStatus, ()> {
let status = wait_for_status(stream);
async fn wait_for_status_timeout(
client: &Client,
stream: &mut TcpStream,
) -> Result<ServerStatus, ()> {
let status = wait_for_status(client, stream);
tokio::time::timeout(Duration::from_secs(STATUS_TIMEOUT), status)
.await
.map_err(|_| ())?
}
/// Wait for a status response.
async fn wait_for_ping(stream: &mut TcpStream, token: u64) -> Result<(), ()> {
async fn wait_for_ping(client: &Client, stream: &mut TcpStream, token: u64) -> Result<(), ()> {
// Get stream reader, set up buffer
let (mut reader, mut _writer) = stream.split();
let mut buf = BytesMut::new();
loop {
// Read packet from stream
let (packet, _raw) = match proto::read_packet(&mut buf, &mut reader).await {
let (packet, _raw) = match packet::read_packet(client, &mut buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => continue,
};
// Catch ping response
if packet.id == proto::STATUS_PACKET_ID_PING {
if packet.id == packets::status::CLIENT_PING {
let ping = PingResponse::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
// Ping token must match
@@ -223,8 +235,12 @@ async fn wait_for_ping(stream: &mut TcpStream, token: u64) -> Result<(), ()> {
}
/// Wait for a status response.
async fn wait_for_ping_timeout(stream: &mut TcpStream, token: u64) -> Result<(), ()> {
let status = wait_for_ping(stream, token);
async fn wait_for_ping_timeout(
client: &Client,
stream: &mut TcpStream,
token: u64,
) -> Result<(), ()> {
let status = wait_for_ping(client, stream, token);
tokio::time::timeout(Duration::from_secs(PING_TIMEOUT), status)
.await
.map_err(|_| ())?

22
src/net.rs Normal file
View File

@@ -0,0 +1,22 @@
use std::error::Error;
use std::io;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
/// Gracefully close given TCP stream.
///
/// Intended as helper to make code less messy. This also succeeds if already closed.
pub async fn close_tcp_stream(mut stream: TcpStream) -> Result<(), Box<dyn Error>> {
close_tcp_stream_ref(&mut stream).await
}
/// Gracefully close given TCP stream.
///
/// Intended as helper to make code less messy. This also succeeds if already closed.
pub async fn close_tcp_stream_ref(stream: &mut TcpStream) -> Result<(), Box<dyn Error>> {
match stream.shutdown().await {
Ok(_) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotConnected => Ok(()),
Err(err) => Err(err.into()),
}
}

View File

@@ -1,17 +1,19 @@
#[cfg(unix)]
pub mod unix;
#[cfg(windows)]
pub mod windows;
#[cfg(unix)]
use nix::{
sys::signal::{self, Signal},
unistd::Pid,
};
/// Force kill process.
///
/// Results in undefined behavior if PID is invalid.
#[allow(unreachable_code)]
pub fn force_kill(pid: u32) -> bool {
#[cfg(unix)]
unsafe {
return unix::force_kill(pid);
}
return unix_signal(pid, Signal::SIGKILL);
#[cfg(windows)]
unsafe {
@@ -22,20 +24,57 @@ pub fn force_kill(pid: u32) -> bool {
}
/// Gracefully kill process.
///
/// Results in undefined behavior if PID is invalid.
///
/// # Panics
///
/// Panics on platforms other than Unix.
#[allow(unreachable_code, dead_code, unused_variables)]
pub fn kill_gracefully(pid: u32) -> bool {
#[cfg(unix)]
unsafe {
return unix::kill_gracefully(pid);
}
return unix_signal(pid, Signal::SIGTERM);
unimplemented!(
"gracefully killing Minecraft server process not implemented on non-Unix platforms"
);
}
/// Freeze process.
/// Results in undefined behavior if PID is invaild.
///
/// # Panics
/// Panics on platforms other than Unix.
#[allow(unreachable_code)]
pub fn freeze(pid: u32) -> bool {
#[cfg(unix)]
return unix_signal(pid, Signal::SIGSTOP);
unimplemented!(
"freezing the Minecraft server process is not implemented on non-Unix platforms"
);
}
/// Unfreeze process.
/// Results in undefined behavior if PID is invaild.
///
/// # Panics
/// Panics on platforms other than Unix.
#[allow(unreachable_code)]
pub fn unfreeze(pid: u32) -> bool {
#[cfg(unix)]
return unix_signal(pid, Signal::SIGCONT);
unimplemented!(
"unfreezing the Minecraft server process is not implemented on non-Unix platforms"
);
}
#[cfg(unix)]
pub fn unix_signal(pid: u32, signal: Signal) -> bool {
match signal::kill(Pid::from_raw(pid as i32), signal) {
Ok(()) => true,
Err(err) => {
warn!(target: "lazymc", "Sending {signal} signal to server failed: {err}");
false
}
}
}

View File

@@ -1,27 +0,0 @@
/// Force kill process on Unix by sending SIGKILL.
///
/// This is unsafe because the PID isn't checked.
pub unsafe fn force_kill(pid: u32) -> bool {
debug!(target: "lazymc", "Sending SIGKILL signal to {} to kill server", pid);
let result = libc::kill(pid as i32, libc::SIGKILL);
if result != 0 {
trace!(target: "lazymc", "SIGKILL failed: {}", result);
}
result == 0
}
/// Gracefully kill process on Unix by sending SIGTERM.
///
/// This is unsafe because the PID isn't checked.
pub unsafe fn kill_gracefully(pid: u32) -> bool {
debug!(target: "lazymc", "Sending SIGTERM signal to {} to kill server", pid);
let result = libc::kill(pid as i32, libc::SIGTERM);
if result != 0 {
warn!(target: "lazymc", "Sending SIGTERM signal to server failed: {}", result);
}
result == 0
}

362
src/probe.rs Normal file
View File

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

View File

@@ -1,203 +0,0 @@
use std::sync::Mutex;
use bytes::BytesMut;
use tokio::io;
use tokio::io::AsyncReadExt;
use tokio::net::tcp::ReadHalf;
use crate::types;
/// Default minecraft protocol version name.
///
/// Send to clients when the server is sleeping when the real server version is not known yet.
pub const PROTO_DEFAULT_VERSION: &str = "1.17.1";
/// Default minecraft protocol version.
///
/// Send to clients when the server is sleeping when the real server version is not known yet, and
/// with server status polling requests.
pub const PROTO_DEFAULT_PROTOCOL: u32 = 756;
/// Handshake state, handshake packet ID.
pub const HANDSHAKE_PACKET_ID_HANDSHAKE: i32 = 0;
/// Status state, status packet ID.
pub const STATUS_PACKET_ID_STATUS: i32 = 0;
/// Status state, ping packet ID.
pub const STATUS_PACKET_ID_PING: i32 = 1;
/// Login state, login start packet ID.
pub const LOGIN_PACKET_ID_LOGIN_START: i32 = 0;
/// Client state.
///
/// Note: this does not keep track of compression/encryption states because packets are never
/// inspected when these modes are enabled.
#[derive(Debug, Default)]
pub struct Client {
/// Current client state.
pub state: Mutex<ClientState>,
}
impl Client {
/// Get client state.
pub fn state(&self) -> ClientState {
*self.state.lock().unwrap()
}
/// Set client state.
pub fn set_state(&self, state: ClientState) {
*self.state.lock().unwrap() = state;
}
}
/// Protocol state a client may be in.
///
/// Note: this does not include the `play` state, because this is never used anymore when a client
/// reaches this state.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum ClientState {
/// Initial client state.
Handshake,
/// State to query server status.
Status,
/// State to login to server.
Login,
}
impl ClientState {
/// From state ID.
pub fn from_id(id: i32) -> Option<Self> {
match id {
0 => Some(Self::Handshake),
1 => Some(Self::Status),
2 => Some(Self::Login),
_ => None,
}
}
/// Get state ID.
pub fn to_id(self) -> i32 {
match self {
Self::Handshake => 0,
Self::Status => 1,
Self::Login => 2,
}
}
}
impl Default for ClientState {
fn default() -> Self {
Self::Handshake
}
}
/// Raw Minecraft packet.
///
/// Having a packet ID and a raw data byte array.
pub struct RawPacket {
/// Packet ID.
pub id: i32,
/// Packet data.
pub data: Vec<u8>,
}
impl RawPacket {
/// Construct new raw packet.
pub fn new(id: i32, data: Vec<u8>) -> Self {
Self { id, data }
}
/// Decode packet from raw buffer.
pub fn decode(mut buf: &[u8]) -> Result<Self, ()> {
// Read length
let (read, len) = types::read_var_int(buf)?;
buf = &buf[read..][..len as usize];
// Read packet ID, select buf
let (read, packet_id) = types::read_var_int(buf)?;
buf = &buf[read..];
Ok(Self::new(packet_id, buf.to_vec()))
}
/// Encode packet to raw buffer.
pub fn encode(&self) -> Result<Vec<u8>, ()> {
let mut data = types::encode_var_int(self.id)?;
data.extend_from_slice(&self.data);
let len = data.len() as i32;
let mut packet = types::encode_var_int(len)?;
packet.append(&mut data);
Ok(packet)
}
}
/// Read raw packet from stream.
///
/// Note: this does not support reading compressed/encrypted packets.
/// We should never need this though, as we're done reading user packets before any of this is
/// enabled. See: https://wiki.vg/Protocol#Packet_format
pub async fn read_packet(
buf: &mut BytesMut,
stream: &mut ReadHalf<'_>,
) -> Result<Option<(RawPacket, Vec<u8>)>, ()> {
// Keep reading until we have at least 2 bytes
while buf.len() < 2 {
// Read packet from socket
let mut tmp = Vec::with_capacity(64);
match stream.read_buf(&mut tmp).await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None),
Err(err) => {
dbg!(err);
return Err(());
}
}
if tmp.is_empty() {
return Ok(None);
}
buf.extend(tmp);
}
// Attempt to read packet length
let (consumed, len) = match types::read_var_int(buf) {
Ok(result) => result,
Err(err) => {
error!(target: "lazymc", "Malformed packet, could not read packet length");
return Err(err);
}
};
// Keep reading until we have all packet bytes
while buf.len() < consumed + len as usize {
// Read packet from socket
let mut tmp = Vec::with_capacity(64);
match stream.read_buf(&mut tmp).await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None),
Err(err) => {
dbg!(err);
return Err(());
}
}
if tmp.is_empty() {
return Ok(None);
}
buf.extend(tmp);
}
// Parse packet
let raw = buf.split_to(consumed + len as usize);
let packet = RawPacket::decode(&raw)?;
Ok(Some((packet, raw.to_vec())))
}

36
src/proto/action.rs Normal file
View File

@@ -0,0 +1,36 @@
use minecraft_protocol::data::chat::{Message, Payload};
use minecraft_protocol::version::v1_14_4::game::GameDisconnect;
use minecraft_protocol::version::v1_14_4::login::LoginDisconnect;
use tokio::net::tcp::WriteHalf;
use crate::proto::client::{Client, ClientState};
use crate::proto::packet;
/// Kick client with a message.
///
/// Should close connection afterwards.
pub async fn kick(client: &Client, msg: &str, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
match client.state() {
ClientState::Login => {
packet::write_packet(
LoginDisconnect {
reason: Message::new(Payload::text(msg)),
},
client,
writer,
)
.await
}
ClientState::Play => {
packet::write_packet(
GameDisconnect {
reason: Message::new(Payload::text(msg)),
},
client,
writer,
)
.await
}
_ => Err(()),
}
}

138
src/proto/client.rs Normal file
View File

@@ -0,0 +1,138 @@
use std::net::SocketAddr;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Mutex;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
/// Client state.
///
/// Note: this does not keep track of encryption states.
#[derive(Debug)]
pub struct Client {
/// Client peer address.
pub peer: SocketAddr,
/// Current client state.
pub state: Mutex<ClientState>,
/// Compression state.
///
/// 0 or positive if enabled, negative if disabled.
pub compression: AtomicI32,
}
impl Client {
/// Construct new client with given peer address.
pub fn new(peer: SocketAddr) -> Self {
Self {
peer,
state: Default::default(),
compression: AtomicI32::new(-1),
}
}
/// Construct dummy client.
pub fn dummy() -> Self {
Self::new("0.0.0.0:0".parse().unwrap())
}
/// Get client state.
pub fn state(&self) -> ClientState {
*self.state.lock().unwrap()
}
/// Set client state.
pub fn set_state(&self, state: ClientState) {
*self.state.lock().unwrap() = state;
}
/// Get compression threshold.
pub fn compressed(&self) -> i32 {
self.compression.load(Ordering::Relaxed)
}
/// Whether compression is used.
pub fn is_compressed(&self) -> bool {
self.compressed() >= 0
}
/// Set compression value.
#[allow(unused)]
pub fn set_compression(&self, threshold: i32) {
trace!(target: "lazymc", "Client now uses compression threshold of {}", threshold);
self.compression.store(threshold, Ordering::Relaxed);
}
}
/// Protocol state a client may be in.
///
/// Note: this does not include the `play` state, because this is never used anymore when a client
/// reaches this state.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum ClientState {
/// Initial client state.
Handshake,
/// State to query server status.
Status,
/// State to login to server.
Login,
/// State to play on the server.
#[allow(unused)]
Play,
}
impl ClientState {
/// From state ID.
pub fn from_id(id: i32) -> Option<Self> {
match id {
0 => Some(Self::Handshake),
1 => Some(Self::Status),
2 => Some(Self::Login),
_ => None,
}
}
/// Get state ID.
pub fn to_id(self) -> i32 {
match self {
Self::Handshake => 0,
Self::Status => 1,
Self::Login => 2,
Self::Play => -1,
}
}
}
impl Default for ClientState {
fn default() -> Self {
Self::Handshake
}
}
/// Client info, useful during connection handling.
#[derive(Debug, Clone, Default)]
pub struct ClientInfo {
/// Used protocol version.
pub protocol: Option<u32>,
/// Handshake as received from client.
pub handshake: Option<Handshake>,
/// Client username.
pub username: Option<String>,
}
impl ClientInfo {
pub fn empty() -> Self {
Self::default()
}
/// Get protocol version.
pub fn protocol(&self) -> Option<u32> {
self.protocol
.or_else(|| self.handshake.as_ref().map(|h| h.protocol_version as u32))
}
}

27
src/proto/mod.rs Normal file
View File

@@ -0,0 +1,27 @@
pub mod action;
pub mod client;
pub mod packet;
pub mod packets;
/// Default minecraft protocol version name.
///
/// Just something to default to when real server version isn't known or when no hint is specified
/// in the configuration.
///
/// Should be kept up-to-date with latest supported Minecraft version by lazymc.
pub const PROTO_DEFAULT_VERSION: &str = "1.20.3";
/// Default minecraft protocol version.
///
/// Just something to default to when real server version isn't known or when no hint is specified
/// in the configuration.
///
/// Should be kept up-to-date with latest supported Minecraft version by lazymc.
pub const PROTO_DEFAULT_PROTOCOL: u32 = 765;
/// Compression threshold to use.
// TODO: read this from server.properties instead
pub const COMPRESSION_THRESHOLD: i32 = 256;
/// Default buffer size when reading packets.
pub(super) const BUF_SIZE: usize = 8 * 1024;

235
src/proto/packet.rs Normal file
View File

@@ -0,0 +1,235 @@
use std::fmt::Debug;
use std::io::prelude::*;
use bytes::BytesMut;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use minecraft_protocol::encoder::Encoder;
use minecraft_protocol::version::PacketId;
use tokio::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{ReadHalf, WriteHalf};
use crate::proto::client::Client;
use crate::proto::BUF_SIZE;
use crate::types;
/// Raw Minecraft packet.
///
/// Having a packet ID and a raw data byte array.
pub struct RawPacket {
/// Packet ID.
pub id: u8,
/// Packet data.
pub data: Vec<u8>,
}
impl RawPacket {
/// Construct new raw packet.
pub fn new(id: u8, data: Vec<u8>) -> Self {
Self { id, data }
}
/// Read packet ID from buffer, use remaining buffer as data.
fn read_packet_id_data(mut buf: &[u8]) -> Result<Self, ()> {
// Read packet ID, select buf
let (read, packet_id) = types::read_var_int(buf)?;
buf = &buf[read..];
Ok(Self::new(packet_id as u8, buf.to_vec()))
}
/// Decode packet from raw buffer.
///
/// This decodes both compressed and uncompressed packets based on the client threshold
/// preference.
pub fn decode_with_len(client: &Client, mut buf: &[u8]) -> Result<Self, ()> {
// Read length
let (read, len) = types::read_var_int(buf)?;
buf = &buf[read..][..len as usize];
// TODO: assert buffer length!
Self::decode_without_len(client, buf)
}
/// Decode packet from raw buffer without packet length.
///
/// This decodes both compressed and uncompressed packets based on the client threshold
/// preference.
/// The length is given, and not included in the buffer itself.
pub fn decode_without_len(client: &Client, mut buf: &[u8]) -> Result<Self, ()> {
// If no compression is used, read remaining packet ID and data
if !client.is_compressed() {
// Read packet ID and data
return Self::read_packet_id_data(buf);
}
// Read data length
let (read, data_len) = types::read_var_int(buf)?;
buf = &buf[read..];
// If data length is zero, the rest is not compressed
if data_len == 0 {
return Self::read_packet_id_data(buf);
}
// Decompress packet ID and data section
let mut decompressed = Vec::with_capacity(data_len as usize);
ZlibDecoder::new(buf)
.read_to_end(&mut decompressed)
.map_err(|err| {
error!(target: "lazymc", "Packet decompression error: {}", err);
})?;
// Decompressed data must match length
if decompressed.len() != data_len as usize {
error!(target: "lazymc", "Decompressed packet has different length than expected ({}b != {}b)", decompressed.len(), data_len);
return Err(());
}
// Read decompressed packet ID
Self::read_packet_id_data(&decompressed)
}
/// Encode packet to raw buffer.
///
/// This compresses packets based on the client threshold preference.
pub fn encode_with_len(&self, client: &Client) -> Result<Vec<u8>, ()> {
// Encode packet without length
let mut payload = self.encode_without_len(client)?;
// Add length header
let mut packet = types::encode_var_int(payload.len() as i32)?;
packet.append(&mut payload);
Ok(packet)
}
/// Encode packet to raw buffer without length header.
///
/// This compresses packets based on the client threshold preference.
pub fn encode_without_len(&self, client: &Client) -> Result<Vec<u8>, ()> {
let threshold = client.compressed();
if threshold >= 0 {
self.encode_compressed(threshold)
} else {
self.encode_uncompressed()
}
}
/// Encode compressed packet to raw buffer.
fn encode_compressed(&self, threshold: i32) -> Result<Vec<u8>, ()> {
// Packet payload: packet ID and data buffer
let mut payload = types::encode_var_int(self.id as i32)?;
payload.extend_from_slice(&self.data);
// Determine whether to compress, encode data length bytes
let data_len = payload.len() as i32;
let compress = data_len > threshold;
let data_len_header = if compress { data_len } else { 0 };
// Compress payload
if compress {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&payload).map_err(|err| {
error!(target: "lazymc", "Failed to compress packet: {}", err);
})?;
payload = encoder.finish().map_err(|err| {
error!(target: "lazymc", "Failed to compress packet: {}", err);
})?;
}
// Add data length header
let mut packet = types::encode_var_int(data_len_header).unwrap();
packet.append(&mut payload);
Ok(packet)
}
/// Encode uncompressed packet to raw buffer.
fn encode_uncompressed(&self) -> Result<Vec<u8>, ()> {
let mut packet = types::encode_var_int(self.id as i32)?;
packet.extend_from_slice(&self.data);
Ok(packet)
}
}
/// Read raw packet from stream.
pub async fn read_packet(
client: &Client,
buf: &mut BytesMut,
stream: &mut ReadHalf<'_>,
) -> Result<Option<(RawPacket, Vec<u8>)>, ()> {
// Keep reading until we have at least 2 bytes
while buf.len() < 2 {
// Read packet from socket
let mut tmp = Vec::with_capacity(BUF_SIZE);
match stream.read_buf(&mut tmp).await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None),
Err(err) => {
dbg!(err);
return Err(());
}
}
if tmp.is_empty() {
return Ok(None);
}
buf.extend(tmp);
}
// Attempt to read packet length
let (consumed, len) = match types::read_var_int(buf) {
Ok(result) => result,
Err(err) => {
error!(target: "lazymc", "Malformed packet, could not read packet length");
return Err(err);
}
};
// Keep reading until we have all packet bytes
while buf.len() < consumed + len as usize {
// Read packet from socket
let mut tmp = Vec::with_capacity(BUF_SIZE);
match stream.read_buf(&mut tmp).await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(None),
Err(err) => {
dbg!(err);
return Err(());
}
}
if tmp.is_empty() {
return Ok(None);
}
buf.extend(tmp);
}
// Parse packet, use full buffer since we'll read the packet length again
// TODO: use decode_without_len, strip len from buffer
let raw = buf.split_to(consumed + len as usize);
let packet = RawPacket::decode_with_len(client, &raw)?;
Ok(Some((packet, raw.to_vec())))
}
/// Write packet to stream writer.
pub async fn write_packet(
packet: impl PacketId + Encoder + Debug,
client: &Client,
writer: &mut WriteHalf<'_>,
) -> Result<(), ()> {
let mut data = Vec::new();
packet.encode(&mut data).map_err(|_| ())?;
let response = RawPacket::new(packet.packet_id(), data).encode_with_len(client)?;
writer.write_all(&response).await.map_err(|_| ())?;
Ok(())
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,30 @@
use std::error::Error;
use std::net::SocketAddr;
use bytes::BytesMut;
use proxy_protocol::version2::{ProxyAddresses, ProxyCommand, ProxyTransportProtocol};
use proxy_protocol::EncodeError;
use tokio::io;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use crate::net;
/// Proxy the inbound stream to a target address.
pub async fn proxy(inbound: TcpStream, addr_target: SocketAddr) -> Result<(), Box<dyn Error>> {
proxy_with_queue(inbound, addr_target, &[]).await
pub async fn proxy(
inbound: TcpStream,
proxy_header: ProxyHeader,
addr_target: SocketAddr,
) -> Result<(), Box<dyn Error>> {
proxy_with_queue(inbound, proxy_header, addr_target, &[]).await
}
/// Proxy the inbound stream to a target address.
///
/// Send the queue to the target server before proxying.
pub async fn proxy_with_queue(
mut inbound: TcpStream,
inbound: TcpStream,
proxy_header: ProxyHeader,
addr_target: SocketAddr,
queue: &[u8],
) -> Result<(), Box<dyn Error>> {
@@ -22,14 +32,48 @@ pub async fn proxy_with_queue(
// TODO: on connect fail, ping server and redirect to serve_status if offline
let mut outbound = TcpStream::connect(addr_target).await?;
// Add proxy header
match proxy_header {
ProxyHeader::None => {}
ProxyHeader::Local => {
let header = local_proxy_header()?;
outbound.write_all(&header).await?;
}
ProxyHeader::Proxy => {
let header = stream_proxy_header(&inbound)?;
outbound.write_all(&header).await?;
}
}
// Start proxy on both streams
proxy_inbound_outbound_with_queue(inbound, outbound, &[], queue).await
}
/// Proxy the inbound stream to a target address.
///
/// Send the queue to the target server before proxying.
// TODO: find better name for this
pub async fn proxy_inbound_outbound_with_queue(
mut inbound: TcpStream,
mut outbound: TcpStream,
inbound_queue: &[u8],
outbound_queue: &[u8],
) -> Result<(), Box<dyn Error>> {
let (mut ri, mut wi) = inbound.split();
let (mut ro, mut wo) = outbound.split();
// Forward queued bytes to client once writable
if !inbound_queue.is_empty() {
wi.writable().await?;
trace!(target: "lazymc", "Relaying {} queued bytes to client", inbound_queue.len());
wi.write_all(inbound_queue).await?;
}
// Forward queued bytes to server once writable
if !queue.is_empty() {
if !outbound_queue.is_empty() {
wo.writable().await?;
trace!(target: "lazymc", "Relaying {} queued bytes to server", queue.len());
wo.write_all(queue).await?;
trace!(target: "lazymc", "Relaying {} queued bytes to server", outbound_queue.len());
wo.write_all(outbound_queue).await?;
}
let client_to_server = async {
@@ -43,5 +87,81 @@ pub async fn proxy_with_queue(
tokio::try_join!(client_to_server, server_to_client)?;
// Gracefully close connection if not done already
net::close_tcp_stream(inbound).await?;
Ok(())
}
/// Proxy header.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ProxyHeader {
/// Do not add proxy header.
None,
/// Header for locally initiated connection.
#[allow(unused)]
Local,
/// Header for proxied connection.
Proxy,
}
impl ProxyHeader {
/// Changes to `None` if `false` if given.
///
/// `None` stays `None`.
pub fn not_none(self, not_none: bool) -> Self {
if not_none {
self
} else {
Self::None
}
}
}
/// Get the proxy header for a locally initiated connection.
///
/// This header may be sent over the outbound stream to signal client information.
pub fn local_proxy_header() -> Result<BytesMut, EncodeError> {
// Build proxy header
let header = proxy_protocol::ProxyHeader::Version2 {
command: ProxyCommand::Local,
transport_protocol: ProxyTransportProtocol::Stream,
addresses: ProxyAddresses::Unspec,
};
proxy_protocol::encode(header)
}
/// Get the proxy header for the given inbound stream.
///
/// This header may be sent over the outbound stream to signal client information.
pub fn stream_proxy_header(inbound: &TcpStream) -> Result<BytesMut, EncodeError> {
// Get peer and local address
let peer = inbound
.peer_addr()
.expect("Peer address not known for TCP stream");
let local = inbound
.local_addr()
.expect("Local address not known for TCP stream");
// Build proxy header
let header = proxy_protocol::ProxyHeader::Version2 {
command: ProxyCommand::Proxy,
transport_protocol: ProxyTransportProtocol::Stream,
addresses: match (peer, local) {
(SocketAddr::V4(source), SocketAddr::V4(destination)) => ProxyAddresses::Ipv4 {
source,
destination,
},
(SocketAddr::V6(source), SocketAddr::V6(destination)) => ProxyAddresses::Ipv6 {
source,
destination,
},
(_, _) => unreachable!(),
},
};
proxy_protocol::encode(header)
}

View File

@@ -1,19 +1,426 @@
use std::net::IpAddr;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard};
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::FutureExt;
use minecraft_protocol::data::server_status::ServerStatus;
use minecraft_protocol::version::v1_20_3::status::ServerStatus;
use tokio::process::Command;
use tokio::sync::watch;
#[cfg(feature = "rcon")]
use tokio::sync::Semaphore;
use tokio::sync::{Mutex, RwLock, RwLockReadGuard};
use tokio::time;
use crate::config::Config;
use crate::config::{Config, Server as ConfigServer};
use crate::mc::ban::{BannedIp, BannedIps};
use crate::mc::whitelist::Whitelist;
use crate::os;
use crate::proto::packets::play::join_game::JoinGameData;
/// Server cooldown after the process quit.
/// Used to give it some more time to quit forgotten threads, such as for RCON.
const SERVER_QUIT_COOLDOWN: Duration = Duration::from_millis(2500);
/// RCON cooldown. Required period between RCON invocations.
///
/// The Minecraft RCON implementation is very broken and brittle, this is used in the hopes to
/// improve reliability.
#[cfg(feature = "rcon")]
const RCON_COOLDOWN: Duration = Duration::from_secs(15);
/// Exit codes that are allowed.
///
/// - 143: https://github.com/timvisee/lazymc/issues/26#issuecomment-1435670029
/// - 130: https://unix.stackexchange.com/q/386836/61092
const ALLOWED_EXIT_CODES: [i32; 2] = [130, 143];
/// Shared server state.
#[derive(Debug)]
pub struct Server {
/// Server state.
///
/// Matches `State`, utilzes AtomicU8 for better performance.
state: AtomicU8,
/// State watch sender, broadcast state changes.
state_watch_sender: watch::Sender<State>,
/// State watch receiver, subscribe to state changes.
state_watch_receiver: watch::Receiver<State>,
/// Server process PID.
///
/// Set if a server process is running.
pid: Mutex<Option<u32>>,
/// Last known server status.
///
/// Will remain set once known, not cleared if server goes offline.
status: RwLock<Option<ServerStatus>>,
/// Last active time.
///
/// The last time there was activity on the server. Also set at the moment the server comes
/// online.
last_active: RwLock<Option<Instant>>,
/// Force server to stay online until.
keep_online_until: RwLock<Option<Instant>>,
/// Time to force kill the server process at.
///
/// Used as starting/stopping timeout.
kill_at: RwLock<Option<Instant>>,
/// List of banned IPs.
banned_ips: RwLock<BannedIps>,
/// Whitelist if enabled.
whitelist: RwLock<Option<Whitelist>>,
/// Lock for exclusive RCON operations.
#[cfg(feature = "rcon")]
rcon_lock: Semaphore,
/// Last time server was stopped over RCON.
#[cfg(feature = "rcon")]
rcon_last_stop: Mutex<Option<Instant>>,
/// Probed join game data.
pub probed_join_game: RwLock<Option<JoinGameData>>,
/// Forge payload.
///
/// Sent to clients when they connect to lobby. Recorded from server by probe.
pub forge_payload: RwLock<Vec<Vec<u8>>>,
}
impl Server {
/// Get current state.
pub fn state(&self) -> State {
State::from_u8(self.state.load(Ordering::Relaxed))
}
/// Get state receiver to subscribe on server state changes.
pub fn state_receiver(&self) -> watch::Receiver<State> {
self.state_watch_receiver.clone()
}
/// Set a new state.
///
/// This updates various other internal things depending on how the state changes.
///
/// Returns false if the state didn't change, in which case nothing happens.
async fn update_state(&self, state: State, config: &Config) -> bool {
self.update_state_from(None, state, config).await
}
/// Set new state, from a current state.
///
/// This updates various other internal things depending on how the state changes.
///
/// Returns false if current state didn't match `from` or if nothing changed.
async fn update_state_from(&self, from: Option<State>, new: State, config: &Config) -> bool {
// Atomically swap state to new, return if from doesn't match
let old = State::from_u8(match from {
Some(from) => match self.state.compare_exchange(
from.to_u8(),
new.to_u8(),
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(old) => old,
Err(_) => return false,
},
None => self.state.swap(new.to_u8(), Ordering::Relaxed),
});
// State must be changed
if old == new {
return false;
}
trace!("Change server state from {:?} to {:?}", old, new);
// Broadcast change
let _ = self.state_watch_sender.send(new);
// Update kill at time for starting/stopping state
*self.kill_at.write().await = match new {
State::Starting if config.server.start_timeout > 0 => {
Some(Instant::now() + Duration::from_secs(config.server.start_timeout as u64))
}
State::Stopping if config.server.stop_timeout > 0 => {
Some(Instant::now() + Duration::from_secs(config.server.stop_timeout as u64))
}
_ => None,
};
// Online/offline messages
match new {
State::Started => info!(target: "lazymc::monitor", "Server is now online"),
State::Stopped => info!(target: "lazymc::monitor", "Server is now sleeping"),
_ => {}
}
// If Starting -> Started, update active time and keep it online for configured time
if old == State::Starting && new == State::Started {
self.update_last_active().await;
self.keep_online_for(Some(config.time.min_online_time))
.await;
}
true
}
/// Update status as obtained from the server.
///
/// This updates various other internal things depending on the current state and the given
/// status.
pub async fn update_status(&self, config: &Config, status: Option<ServerStatus>) {
// Update state based on curren
match (self.state(), &status) {
(State::Stopped | State::Starting, Some(_)) => {
self.update_state(State::Started, config).await;
}
(State::Started, None) => {
self.update_state(State::Stopped, config).await;
}
_ => {}
}
// Update last status if known
if let Some(status) = status {
// Update last active time if there are online players
if status.players.online > 0 {
self.update_last_active().await;
}
self.status.write().await.replace(status);
}
}
/// Try to start the server.
///
/// Does nothing if currently not in stopped state.
pub async fn start(config: Arc<Config>, server: Arc<Server>, username: Option<String>) -> bool {
// Must set state from stopped to starting
if !server
.update_state_from(Some(State::Stopped), State::Starting, &config)
.await
{
return false;
}
// Log starting message
match username {
Some(username) => info!(target: "lazymc", "Starting server for '{}'...", username),
None => info!(target: "lazymc", "Starting server..."),
}
// Unfreeze server if it is frozen
#[cfg(unix)]
if config.server.freeze_process && unfreeze_server_signal(&config, &server).await {
return true;
}
// Spawn server in new task
Self::spawn_server_task(config, server);
true
}
/// Spawn the server task.
///
/// This should not be called directly.
fn spawn_server_task(config: Arc<Config>, server: Arc<Server>) {
tokio::spawn(invoke_server_cmd(config, server).map(|_| ()));
}
/// Stop running server.
///
/// This will attempt to stop the server with all available methods.
#[allow(unused_variables)]
pub async fn stop(&self, config: &Config) -> bool {
// Try to freeze through signal
#[cfg(unix)]
if config.server.freeze_process && freeze_server_signal(config, self).await {
return true;
}
// Try to stop through RCON if started
#[cfg(feature = "rcon")]
if self.state() == State::Started && stop_server_rcon(config, self).await {
return true;
}
// Try to stop through signal
#[cfg(unix)]
if stop_server_signal(config, self).await {
return true;
}
warn!(target: "lazymc", "Failed to stop server, no more suitable stopping method to use");
false
}
/// Force kill running server.
///
/// This requires the server PID to be known.
pub async fn force_kill(&self) -> bool {
if let Some(pid) = *self.pid.lock().await {
return os::force_kill(pid);
}
false
}
/// Decide whether the server should sleep.
///
/// Always returns false if it is currently not online.
pub async fn should_sleep(&self, config: &Config) -> bool {
// Server must be online
if self.state() != State::Started {
return false;
}
// Never sleep if players are online
let players_online = self
.status
.read()
.await
.as_ref()
.map(|status| status.players.online > 0)
.unwrap_or(false);
if players_online {
trace!(target: "lazymc", "Not sleeping because players are online");
return false;
}
// Don't sleep when keep online until isn't expired
let keep_online = self
.keep_online_until
.read()
.await
.map(|i| i >= Instant::now())
.unwrap_or(false);
if keep_online {
trace!(target: "lazymc", "Not sleeping because of keep online");
return false;
}
// Last active time must have passed sleep threshold
if let Some(last_idle) = self.last_active.read().await.as_ref() {
return last_idle.elapsed() >= Duration::from_secs(config.time.sleep_after as u64);
}
false
}
/// Decide whether to force kill the server process.
pub async fn should_kill(&self) -> bool {
self.kill_at
.read()
.await
.map(|t| t <= Instant::now())
.unwrap_or(false)
}
/// Read last known server status.
pub async fn status(&self) -> RwLockReadGuard<'_, Option<ServerStatus>> {
self.status.read().await
}
/// Update the last active time.
async fn update_last_active(&self) {
self.last_active.write().await.replace(Instant::now());
}
/// Force the server to be online for the given number of seconds.
async fn keep_online_for(&self, duration: Option<u32>) {
*self.keep_online_until.write().await = duration
.filter(|d| *d > 0)
.map(|d| Instant::now() + Duration::from_secs(d as u64));
}
/// Check whether the given IP is banned.
///
/// This uses the latest known `banned-ips.json` contents if known.
/// If this feature is disabled, this will always return false.
pub async fn is_banned_ip(&self, ip: &IpAddr) -> bool {
self.banned_ips.read().await.is_banned(ip)
}
/// Get user ban entry.
pub async fn ban_entry(&self, ip: &IpAddr) -> Option<BannedIp> {
self.banned_ips.read().await.get(ip)
}
/// Check whether the given IP is banned.
///
/// This uses the latest known `banned-ips.json` contents if known.
/// If this feature is disabled, this will always return false.
pub fn is_banned_ip_blocking(&self, ip: &IpAddr) -> bool {
futures::executor::block_on(async { self.is_banned_ip(ip).await })
}
/// Check whether the given username is whitelisted.
///
/// Returns `true` if no whitelist is currently used.
pub async fn is_whitelisted(&self, username: &str) -> bool {
self.whitelist
.read()
.await
.as_ref()
.map(|w| w.is_whitelisted(username))
.unwrap_or(true)
}
/// Update the list of banned IPs.
pub async fn set_banned_ips(&self, ips: BannedIps) {
*self.banned_ips.write().await = ips;
}
/// Update the list of banned IPs.
pub fn set_banned_ips_blocking(&self, ips: BannedIps) {
futures::executor::block_on(async { self.set_banned_ips(ips).await })
}
/// Update the whitelist.
pub async fn set_whitelist(&self, whitelist: Option<Whitelist>) {
*self.whitelist.write().await = whitelist;
}
/// Update the whitelist.
pub fn set_whitelist_blocking(&self, whitelist: Option<Whitelist>) {
futures::executor::block_on(async { self.set_whitelist(whitelist).await })
}
}
impl Default for Server {
fn default() -> Self {
let (state_watch_sender, state_watch_receiver) = watch::channel(State::Stopped);
Self {
state: AtomicU8::new(State::Stopped.to_u8()),
state_watch_sender,
state_watch_receiver,
pid: Default::default(),
status: Default::default(),
last_active: Default::default(),
keep_online_until: Default::default(),
kill_at: Default::default(),
banned_ips: Default::default(),
whitelist: Default::default(),
#[cfg(feature = "rcon")]
rcon_lock: Semaphore::new(1),
#[cfg(feature = "rcon")]
rcon_last_stop: Default::default(),
probed_join_game: Default::default(),
forge_payload: Default::default(),
}
}
}
/// Server state.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum State {
@@ -53,288 +460,19 @@ impl State {
}
}
/// Shared server state.
#[derive(Debug)]
pub struct Server {
/// Server state.
///
/// Matches `State`, utilzes AtomicU8 for better performance.
state: AtomicU8,
/// Server process PID.
///
/// Set if a server process is running.
pid: Mutex<Option<u32>>,
/// Last known server status.
///
/// Will remain set once known, not cleared if server goes offline.
status: RwLock<Option<ServerStatus>>,
/// Last active time.
///
/// The last time there was activity on the server. Also set at the moment the server comes
/// online.
last_active: RwLock<Option<Instant>>,
/// Force server to stay online until.
keep_online_until: RwLock<Option<Instant>>,
/// Time to force kill the server process at.
///
/// Used as starting/stopping timeout.
kill_at: RwLock<Option<Instant>>,
}
impl Server {
/// Get current state.
pub fn state(&self) -> State {
State::from_u8(self.state.load(Ordering::Relaxed))
}
/// Set a new state.
///
/// This updates various other internal things depending on how the state changes.
///
/// Returns false if the state didn't change, in which case nothing happens.
fn update_state(&self, state: State, config: &Config) -> bool {
self.update_state_from(None, state, config)
}
/// Set new state, from a current state.
///
/// This updates various other internal things depending on how the state changes.
///
/// Returns false if current state didn't match `from` or if nothing changed.
fn update_state_from(&self, from: Option<State>, new: State, config: &Config) -> bool {
// Atomically swap state to new, return if from doesn't match
let old = State::from_u8(match from {
Some(from) => match self.state.compare_exchange(
from.to_u8(),
new.to_u8(),
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(old) => old,
Err(_) => return false,
},
None => self.state.swap(new.to_u8(), Ordering::Relaxed),
});
// State must be changed
if old == new {
return false;
}
trace!("Change server state from {:?} to {:?}", old, new);
// Update kill at time for starting/stopping state
*self.kill_at.write().unwrap() = match new {
State::Starting if config.time.start_timeout > 0 => {
Some(Instant::now() + Duration::from_secs(config.time.start_timeout as u64))
}
State::Stopping if config.time.stop_timeout > 0 => {
Some(Instant::now() + Duration::from_secs(config.time.stop_timeout as u64))
}
_ => None,
};
// Online/offline messages
match new {
State::Started => info!(target: "lazymc::monitor", "Server is now online"),
State::Stopped => info!(target: "lazymc::monitor", "Server is now sleeping"),
_ => {}
}
// If Starting -> Started, update active time and keep it online for configured time
if old == State::Starting && new == State::Started {
self.update_last_active();
self.keep_online_for(Some(config.time.min_online_time));
}
true
}
/// Update status as polled from the server.
///
/// This updates various other internal things depending on the current state and the given
/// status.
pub fn update_status(&self, config: &Config, status: Option<ServerStatus>) {
// Update state based on curren
match (self.state(), &status) {
(State::Stopped | State::Starting, Some(_)) => {
self.update_state(State::Started, config);
}
(State::Started, None) => {
self.update_state(State::Stopped, config);
}
_ => {}
}
// Update last status if known
if let Some(status) = status {
// Update last active time if there are online players
if status.players.online > 0 {
self.update_last_active();
}
self.status.write().unwrap().replace(status);
}
}
/// Try to start the server.
///
/// Does nothing if currently not in stopped state.
pub fn start(config: Arc<Config>, server: Arc<Server>, username: Option<String>) -> bool {
// Must set state from stopped to starting
if !server.update_state_from(Some(State::Stopped), State::Starting, &config) {
return false;
}
// Log starting message
match username {
Some(username) => info!(target: "lazymc", "Starting server for '{}'...", username),
None => info!(target: "lazymc", "Starting server..."),
}
// Invoke server command in separate task
tokio::spawn(invoke_server_cmd(config, server).map(|_| ()));
true
}
/// Stop running server.
///
/// This requires the server PID to be known.
#[allow(unused_variables)]
pub async fn stop(&self, config: &Config) -> bool {
// We must have a running process
let has_process = self.pid.lock().unwrap().is_some();
if !has_process {
debug!(target: "lazymc", "Tried to stop server, while no PID is known");
return false;
}
// Try to stop through RCON if started
#[cfg(feature = "rcon")]
if self.state() == State::Started && stop_server_rcon(config, self).await {
return true;
}
// Try to stop through signal
#[cfg(unix)]
if stop_server_signal(config, self) {
return true;
}
warn!(target: "lazymc", "Failed to stop server, no more suitable stopping method to use");
false
}
/// Force kill running server.
///
/// This requires the server PID to be known.
pub async fn force_kill(&self) -> bool {
if let Some(pid) = *self.pid.lock().unwrap() {
return os::force_kill(pid);
}
false
}
/// Decide whether the server should sleep.
///
/// Always returns false if it is currently not online.
pub fn should_sleep(&self, config: &Config) -> bool {
// Server must be online
if self.state() != State::Started {
return false;
}
// Never sleep if players are online
let players_online = self
.status
.read()
.unwrap()
.as_ref()
.map(|status| status.players.online > 0)
.unwrap_or(false);
if players_online {
trace!(target: "lazymc", "Not sleeping because players are online");
return false;
}
// Don't sleep when keep online until isn't expired
let keep_online = self
.keep_online_until
.read()
.unwrap()
.map(|i| i >= Instant::now())
.unwrap_or(false);
if keep_online {
trace!(target: "lazymc", "Not sleeping because of keep online");
return false;
}
// Last active time must have passed sleep threshold
if let Some(last_idle) = self.last_active.read().unwrap().as_ref() {
return last_idle.elapsed() >= Duration::from_secs(config.time.sleep_after as u64);
}
false
}
/// Decide whether to force kill the server process.
pub fn should_kill(&self) -> bool {
self.kill_at
.read()
.unwrap()
.map(|t| t <= Instant::now())
.unwrap_or(false)
}
/// Read last known server status.
pub fn status(&self) -> RwLockReadGuard<Option<ServerStatus>> {
self.status.read().unwrap()
}
/// Update the last active time.
fn update_last_active(&self) {
self.last_active.write().unwrap().replace(Instant::now());
}
/// Force the server to be online for the given number of seconds.
fn keep_online_for(&self, duration: Option<u32>) {
*self.keep_online_until.write().unwrap() = duration
.filter(|d| *d > 0)
.map(|d| Instant::now() + Duration::from_secs(d as u64));
}
}
impl Default for Server {
fn default() -> Self {
Self {
state: AtomicU8::new(State::Stopped.to_u8()),
pid: Default::default(),
status: Default::default(),
last_active: Default::default(),
keep_online_until: Default::default(),
kill_at: Default::default(),
}
}
}
/// Invoke server command, store PID and wait for it to quit.
pub async fn invoke_server_cmd(
config: Arc<Config>,
state: Arc<Server>,
) -> Result<(), Box<dyn std::error::Error>> {
// Build command
// Configure command
let args = shlex::split(&config.server.command).expect("invalid server command");
let mut cmd = Command::new(&args[0]);
cmd.args(args.iter().skip(1));
cmd.kill_on_drop(true);
// Set working directory
if let Some(ref dir) = config.server.directory {
if let Some(ref dir) = ConfigServer::server_directory(&config) {
cmd.current_dir(dir);
}
@@ -351,7 +489,7 @@ pub async fn invoke_server_cmd(
state
.pid
.lock()
.unwrap()
.await
.replace(child.id().expect("unknown server PID"));
// Wait for process to exit, handle status
@@ -360,6 +498,15 @@ pub async fn invoke_server_cmd(
debug!(target: "lazymc", "Server process stopped successfully ({})", status);
false
}
Ok(status)
if status
.code()
.map(|ref code| ALLOWED_EXIT_CODES.contains(code))
.unwrap_or(false) =>
{
debug!(target: "lazymc", "Server process stopped successfully by SIGTERM ({})", status);
false
}
Ok(status) => {
warn!(target: "lazymc", "Server process stopped with error code ({})", status);
state.state() == State::Started
@@ -372,18 +519,18 @@ pub async fn invoke_server_cmd(
};
// Forget server PID
state.pid.lock().unwrap().take();
state.pid.lock().await.take();
// Give server a little more time to quit forgotten threads
time::sleep(SERVER_QUIT_COOLDOWN).await;
// Set server state to stopped
state.update_state(State::Stopped, &config);
state.update_state(State::Stopped, &config).await;
// Restart on crash
if crashed && config.server.wake_on_crash {
warn!(target: "lazymc", "Server crashed, restarting...");
Server::start(config, state, None);
Server::start(config, state, None).await;
}
Ok(())
@@ -400,13 +547,23 @@ async fn stop_server_rcon(config: &Config, server: &Server) -> bool {
return false;
}
// RCON address
let mut addr = config.server.address;
addr.set_port(config.rcon.port);
let addr = addr.to_string();
// Grab RCON lock
let rcon_lock = server.rcon_lock.acquire().await.unwrap();
// Ensure RCON has cooled down
let rcon_cooled_down = server
.rcon_last_stop
.lock()
.await
.map(|t| t.elapsed() >= RCON_COOLDOWN)
.unwrap_or(true);
if !rcon_cooled_down {
debug!(target: "lazymc", "Not using RCON to stop server, in cooldown, used too recently");
return false;
}
// Create RCON client
let mut rcon = match Rcon::connect(&addr, &config.rcon.password).await {
let mut rcon = match Rcon::connect_config(config).await {
Ok(rcon) => rcon,
Err(err) => {
error!(target: "lazymc", "Failed to RCON server to sleep: {}", err);
@@ -420,9 +577,14 @@ async fn stop_server_rcon(config: &Config, server: &Server) -> bool {
return false;
}
// Set server to stopping state
// TODO: set before stop command, revert state on failure
server.update_state(State::Stopping, config);
// Set server to stopping state, update last RCON time
server.rcon_last_stop.lock().await.replace(Instant::now());
server.update_state(State::Stopping, config).await;
// Gracefully close connection
rcon.close().await;
drop(rcon_lock);
true
}
@@ -431,9 +593,9 @@ async fn stop_server_rcon(config: &Config, server: &Server) -> bool {
///
/// Only available on Unix.
#[cfg(unix)]
fn stop_server_signal(config: &Config, server: &Server) -> bool {
async fn stop_server_signal(config: &Config, server: &Server) -> bool {
// Grab PID
let pid = match *server.pid.lock().unwrap() {
let pid = match *server.pid.lock().await {
Some(pid) => pid,
None => {
debug!(target: "lazymc", "Could not send stop signal to server process, PID unknown");
@@ -441,15 +603,73 @@ fn stop_server_signal(config: &Config, server: &Server) -> bool {
}
};
// Send kill signal
if !crate::os::kill_gracefully(pid) {
error!(target: "lazymc", "Failed to send stop signal to server process");
return false;
}
// Update from starting/started to stopping
server.update_state_from(Some(State::Starting), State::Stopping, config);
server.update_state_from(Some(State::Started), State::Stopping, config);
server
.update_state_from(Some(State::Starting), State::Stopping, config)
.await;
server
.update_state_from(Some(State::Started), State::Stopping, config)
.await;
true
}
/// Freeze server by sending SIGSTOP signal.
///
/// Only available on Unix.
#[cfg(unix)]
async fn freeze_server_signal(config: &Config, server: &Server) -> bool {
// Grab PID
let pid = match *server.pid.lock().await {
Some(pid) => pid,
None => {
debug!(target: "lazymc", "Could not send freeze signal to server process, PID unknown");
return false;
}
};
if !os::freeze(pid) {
error!(target: "lazymc", "Failed to send freeze signal to server process.");
}
server
.update_state_from(Some(State::Starting), State::Stopped, config)
.await;
server
.update_state_from(Some(State::Started), State::Stopped, config)
.await;
true
}
/// Unfreeze server by sending SIGCONT signal.
///
/// Only available on Unix.
#[cfg(unix)]
async fn unfreeze_server_signal(config: &Config, server: &Server) -> bool {
// Grab PID
let pid = match *server.pid.lock().await {
Some(pid) => pid,
None => {
debug!(target: "lazymc", "Could not send unfreeze signal to server process, PID unknown");
return false;
}
};
if !os::unfreeze(pid) {
error!(target: "lazymc", "Failed to send unfreeze signal to server process.");
}
server
.update_state_from(Some(State::Stopping), State::Starting, config)
.await;
server
.update_state_from(Some(State::Stopped), State::Starting, config)
.await;
true
}

172
src/service/file_watcher.rs Normal file
View File

@@ -0,0 +1,172 @@
use std::path::Path;
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::time::Duration;
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use crate::config::{Config, Server as ConfigServer};
use crate::mc::ban::{self, BannedIps};
use crate::mc::{server_properties, whitelist};
use crate::server::Server;
/// File watcher debounce time.
const WATCH_DEBOUNCE: Duration = Duration::from_secs(2);
/// Service to watch server file changes.
pub fn service(config: Arc<Config>, server: Arc<Server>) {
// Ensure server directory is set, it must exist
let dir = match ConfigServer::server_directory(&config) {
Some(dir) if dir.is_dir() => dir,
_ => {
warn!(target: "lazymc", "Server directory doesn't exist, can't watch file changes to reload whitelist and banned IPs");
return;
}
};
// Keep watching
#[allow(clippy::blocks_in_conditions)]
while {
// Update all files once
reload_bans(&config, &server, &dir.join(ban::FILE));
reload_whitelist(&config, &server, &dir);
// Watch for changes, update accordingly
watch_server(&config, &server, &dir)
} {}
}
/// Watch server directory.
///
/// Returns `true` if we should watch again.
#[must_use]
fn watch_server(config: &Config, server: &Server, dir: &Path) -> bool {
// Directory must exist
if !dir.is_dir() {
error!(target: "lazymc", "Server directory does not exist at {} anymore, not watching changes", dir.display());
return false;
}
// Create watcher for directory
let (tx, rx) = channel();
let mut watcher =
watcher(tx, WATCH_DEBOUNCE).expect("failed to create watcher for banned-ips.json");
if let Err(err) = watcher.watch(dir, RecursiveMode::NonRecursive) {
error!(target: "lazymc", "An error occured while creating watcher for server files: {}", err);
return true;
}
// Handle change events
loop {
match rx.recv().unwrap() {
// Handle file updates
DebouncedEvent::Create(ref path)
| DebouncedEvent::Write(ref path)
| DebouncedEvent::Remove(ref path) => {
update(config, server, dir, path);
}
// Handle file updates on both paths for rename
DebouncedEvent::Rename(ref before_path, ref after_path) => {
update(config, server, dir, before_path);
update(config, server, dir, after_path);
}
// Ignore write/remove notices, will receive write/remove event later
DebouncedEvent::NoticeWrite(_) | DebouncedEvent::NoticeRemove(_) => {}
// Ignore chmod changes
DebouncedEvent::Chmod(_) => {}
// Rewatch on rescan
DebouncedEvent::Rescan => {
debug!(target: "lazymc", "Rescanning server directory files due to file watching problem");
return true;
}
// Rewatch on error
DebouncedEvent::Error(err, _) => {
error!(target: "lazymc", "Error occurred while watching server directory for file changes: {}", err);
return true;
}
}
}
}
/// Process a file change on the given path.
///
/// Should be called both when created, changed or removed.
fn update(config: &Config, server: &Server, dir: &Path, path: &Path) {
// Update bans
if path.ends_with(ban::FILE) {
reload_bans(config, server, path);
}
// Update whitelist
if path.ends_with(whitelist::WHITELIST_FILE)
|| path.ends_with(whitelist::OPS_FILE)
|| path.ends_with(server_properties::FILE)
{
reload_whitelist(config, server, dir);
}
}
/// Reload banned IPs.
fn reload_bans(config: &Config, server: &Server, path: &Path) {
// Bans must be enabled
if !config.server.block_banned_ips && !config.server.drop_banned_ips {
return;
}
trace!(target: "lazymc", "Reloading banned IPs...");
// File must exist, clear file otherwise
if !path.is_file() {
debug!(target: "lazymc", "No banned IPs, {} does not exist", ban::FILE);
// warn!(target: "lazymc", "Not blocking banned IPs, {} file does not exist", ban::FILE);
server.set_banned_ips_blocking(BannedIps::default());
return;
}
// Load and update banned IPs
match ban::load(path) {
Ok(ips) => server.set_banned_ips_blocking(ips),
Err(err) => {
debug!(target: "lazymc", "Failed load banned IPs from {}, ignoring: {}", ban::FILE, err);
}
}
// Show warning if 127.0.0.1 is banned
if server.is_banned_ip_blocking(&("127.0.0.1".parse().unwrap())) {
warn!(target: "lazymc", "Local address 127.0.0.1 IP banned, probably not what you want");
warn!(target: "lazymc", "Use '/pardon-ip 127.0.0.1' on the server to unban");
}
}
/// Reload whitelisted users.
fn reload_whitelist(config: &Config, server: &Server, dir: &Path) {
// Whitelist must be enabled
if !config.server.wake_whitelist {
return;
}
// Must be enabled in server.properties
let enabled = server_properties::read_property(dir.join(server_properties::FILE), "white-list")
.map(|v| v.trim() == "true")
.unwrap_or(false);
if !enabled {
server.set_whitelist_blocking(None);
debug!(target: "lazymc", "Not using whitelist, not enabled in {}", server_properties::FILE);
return;
}
trace!(target: "lazymc", "Reloading whitelisted users...");
// Load and update whitelisted users
match whitelist::load_dir(dir) {
Ok(whitelist) => server.set_whitelist_blocking(Some(whitelist)),
Err(err) => {
debug!(target: "lazymc", "Failed load whitelist from {}, ignoring: {}", dir.display(), err);
}
}
}

View File

@@ -1,3 +1,5 @@
pub mod file_watcher;
pub mod monitor;
pub mod probe;
pub mod server;
pub mod signal;

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

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

View File

@@ -1,3 +1,4 @@
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::BytesMut;
@@ -5,8 +6,8 @@ use futures::FutureExt;
use tokio::net::{TcpListener, TcpStream};
use crate::config::Config;
use crate::proto::Client;
use crate::proxy;
use crate::proto::client::Client;
use crate::proxy::{self, ProxyHeader};
use crate::server::{self, Server};
use crate::service;
use crate::status;
@@ -38,15 +39,29 @@ pub async fn service(config: Arc<Config>) -> Result<(), ()> {
config.public.address, config.server.address,
);
// Spawn server monitor and signal handler services
if config.lockout.enabled {
warn!(
target: "lazymc",
"Lockout mode is enabled, nobody will be able to connect through the proxy",
);
}
// Spawn services: monitor, signal handler
tokio::spawn(service::monitor::service(config.clone(), server.clone()));
tokio::spawn(service::signal::service(config.clone(), server.clone()));
// Initiate server start
if config.server.wake_on_start {
Server::start(config.clone(), server.clone(), None);
Server::start(config.clone(), server.clone(), None).await;
}
// Spawn additional services: probe and ban manager
tokio::spawn(service::probe::service(config.clone(), server.clone()));
tokio::task::spawn_blocking({
let (config, server) = (config.clone(), server.clone());
|| service::file_watcher::service(config, server)
});
// Route all incomming connections
while let Ok((inbound, _)) = listener.accept().await {
route(inbound, config.clone(), server.clone());
@@ -58,18 +73,37 @@ pub async fn service(config: Arc<Config>) -> Result<(), ()> {
/// Route inbound TCP stream to correct service, spawning a new task.
#[inline]
fn route(inbound: TcpStream, config: Arc<Config>, server: Arc<Server>) {
if server.state() == server::State::Started {
// Get user peer address
let peer = match inbound.peer_addr() {
Ok(peer) => peer,
Err(err) => {
warn!(target: "lazymc", "Connection from unknown peer address, disconnecting: {}", err);
return;
}
};
// Check ban state, just drop connection if enabled
let banned = server.is_banned_ip_blocking(&peer.ip());
if banned && config.server.drop_banned_ips {
info!(target: "lazymc", "Connection from banned IP {}, dropping", peer.ip());
return;
}
// Route connection through proper channel
let should_proxy =
!banned && server.state() == server::State::Started && !config.lockout.enabled;
if should_proxy {
route_proxy(inbound, config)
} else {
route_status(inbound, config, server)
route_status(inbound, config, server, peer)
}
}
/// Route inbound TCP stream to status server, spawning a new task.
#[inline]
fn route_status(inbound: TcpStream, config: Arc<Config>, server: Arc<Server>) {
fn route_status(inbound: TcpStream, config: Arc<Config>, server: Arc<Server>, peer: SocketAddr) {
// When server is not online, spawn a status server
let client = Client::default();
let client = Client::new(peer);
let service = status::serve(client, inbound, config, server).map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to serve status: {:?}", err);
@@ -83,7 +117,12 @@ fn route_status(inbound: TcpStream, config: Arc<Config>, server: Arc<Server>) {
#[inline]
fn route_proxy(inbound: TcpStream, config: Arc<Config>) {
// When server is online, proxy all
let service = proxy::proxy(inbound, config.server.address).map(|r| {
let service = proxy::proxy(
inbound,
ProxyHeader::Proxy.not_none(config.server.send_proxy_v2),
config.server.address,
)
.map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to proxy: {}", err);
}
@@ -95,9 +134,25 @@ fn route_proxy(inbound: TcpStream, config: Arc<Config>) {
/// Route inbound TCP stream to proxy with queued data, spawning a new task.
#[inline]
pub fn route_proxy_queue(inbound: TcpStream, config: Arc<Config>, queue: BytesMut) {
route_proxy_address_queue(
inbound,
ProxyHeader::Proxy.not_none(config.server.send_proxy_v2),
config.server.address,
queue,
);
}
/// Route inbound TCP stream to proxy with given address and queued data, spawning a new task.
#[inline]
pub fn route_proxy_address_queue(
inbound: TcpStream,
proxy_header: ProxyHeader,
addr: SocketAddr,
queue: BytesMut,
) {
// When server is online, proxy all
let service = async move {
proxy::proxy_with_queue(inbound, config.server.address, &queue)
proxy::proxy_with_queue(inbound, proxy_header, addr, &queue)
.map(|r| {
if let Err(err) = r {
warn!(target: "lazymc", "Failed to proxy: {}", err);

View File

@@ -1,27 +1,36 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::server::State;
use bytes::BytesMut;
use minecraft_protocol::data::chat::{Message, Payload};
use minecraft_protocol::data::server_status::*;
use minecraft_protocol::data::server_status::{OnlinePlayers, ServerVersion};
use minecraft_protocol::decoder::Decoder;
use minecraft_protocol::encoder::Encoder;
use minecraft_protocol::version::v1_14_4::handshake::Handshake;
use minecraft_protocol::version::v1_14_4::login::{LoginDisconnect, LoginStart};
use minecraft_protocol::version::v1_14_4::status::StatusResponse;
use tokio::io::{self, AsyncWriteExt};
use tokio::net::tcp::WriteHalf;
use minecraft_protocol::version::v1_14_4::login::LoginStart;
use minecraft_protocol::version::v1_20_3::status::{ServerStatus, StatusResponse};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use tokio::time;
use crate::config::*;
use crate::proto::{self, Client, ClientState, RawPacket};
use crate::config::{Config, Server as ConfigServer};
use crate::join;
use crate::mc::favicon;
use crate::proto::action;
use crate::proto::client::{Client, ClientInfo, ClientState};
use crate::proto::packet::{self, RawPacket};
use crate::proto::packets;
use crate::server::{self, Server};
use crate::service;
/// Client holding server state poll interval.
const HOLD_POLL_INTERVAL: Duration = Duration::from_millis(500);
/// The ban message prefix.
const BAN_MESSAGE_PREFIX: &str = "Your IP address is banned from this server.\nReason: ";
/// Default ban reason if unknown.
const DEFAULT_BAN_REASON: &str = "Banned by an operator.";
/// The not-whitelisted kick message.
const WHITELIST_MESSAGE: &str = "You are not white-listed on this server!";
/// Server icon file path.
const SERVER_ICON_FILE: &str = "server-icon.png";
/// Proxy the given inbound stream to a target address.
// TODO: do not drop error here, return Box<dyn Error>
@@ -35,11 +44,14 @@ pub async fn serve(
// Incoming buffer and packet holding queue
let mut buf = BytesMut::new();
let mut hold_queue = BytesMut::new();
// Remember inbound packets, track client info
let mut inbound_history = BytesMut::new();
let mut client_info = ClientInfo::empty();
loop {
// Read packet from stream
let (packet, raw) = match proto::read_packet(&mut buf, &mut reader).await {
let (packet, raw) = match packet::read_packet(&client, &mut buf, &mut reader).await {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(_) => {
@@ -52,192 +64,155 @@ pub async fn serve(
let client_state = client.state();
// Hijack handshake
if client_state == ClientState::Handshake && packet.id == proto::STATUS_PACKET_ID_STATUS {
// Parse handshake, grab new state
let new_state = match Handshake::decode(&mut packet.data.as_slice()) {
Ok(handshake) => match ClientState::from_id(handshake.next_state) {
Some(state) => state,
None => {
error!(target: "lazymc", "Client tried to switch into unknown protcol state ({}), disconnecting", handshake.next_state);
break;
}
},
if client_state == ClientState::Handshake
&& packet.id == packets::handshake::SERVER_HANDSHAKE
{
// Parse handshake
let handshake = match Handshake::decode(&mut packet.data.as_slice()) {
Ok(handshake) => handshake,
Err(_) => {
debug!(target: "lazymc", "Got malformed handshake from client, disconnecting");
break;
}
};
// Update client state
// Parse new state
let new_state = match ClientState::from_id(handshake.next_state) {
Some(state) => state,
None => {
error!(target: "lazymc", "Client tried to switch into unknown protcol state ({}), disconnecting", handshake.next_state);
break;
}
};
// Update client info and client state
client_info
.protocol
.replace(handshake.protocol_version as u32);
client_info.handshake.replace(handshake);
client.set_state(new_state);
// If login handshake and holding is enabled, hold packets
if new_state == ClientState::Login && config.time.hold() {
hold_queue.extend(raw);
// If loggin in with handshake, remember inbound
if new_state == ClientState::Login {
inbound_history.extend(raw);
}
continue;
}
// Hijack server status packet
if client_state == ClientState::Status && packet.id == proto::STATUS_PACKET_ID_STATUS {
let server_status = server_status(&config, &server);
if client_state == ClientState::Status && packet.id == packets::status::SERVER_STATUS {
let server_status = server_status(&client_info, &config, &server).await;
let packet = StatusResponse { server_status };
let mut data = Vec::new();
packet.encode(&mut data).map_err(|_| ())?;
let response = RawPacket::new(0, data).encode()?;
let response = RawPacket::new(0, data).encode_with_len(&client)?;
writer.write_all(&response).await.map_err(|_| ())?;
continue;
}
// Hijack ping packet
if client_state == ClientState::Status && packet.id == proto::STATUS_PACKET_ID_PING {
if client_state == ClientState::Status && packet.id == packets::status::SERVER_PING {
writer.write_all(&raw).await.map_err(|_| ())?;
continue;
}
// Hijack login start
if client_state == ClientState::Login && packet.id == proto::LOGIN_PACKET_ID_LOGIN_START {
// Try to get login username
if client_state == ClientState::Login && packet.id == packets::login::SERVER_LOGIN_START {
// Try to get login username, update client info
// TODO: we should always parse this packet successfully
let username = LoginStart::decode(&mut packet.data.as_slice())
.ok()
.map(|p| p.name);
client_info.username = username.clone();
// Start server if not starting yet
Server::start(config.clone(), server.clone(), username);
// Hold client if enabled and starting
if config.time.hold() && server.state() == State::Starting {
// Hold login packet and remaining read bytes
hold_queue.extend(raw);
hold_queue.extend(buf.split_off(0));
// Start holding
hold(inbound, config, server, hold_queue).await?;
return Ok(());
// Kick if lockout is enabled
if config.lockout.enabled {
match username {
Some(username) => {
info!(target: "lazymc", "Kicked '{}' because lockout is enabled", username)
}
None => info!(target: "lazymc", "Kicked player because lockout is enabled"),
}
action::kick(&client, &config.lockout.message, &mut writer).await?;
break;
}
// Select message and kick
let msg = match server.state() {
server::State::Starting | server::State::Stopped | server::State::Started => {
&config.messages.login_starting
// Kick if client is banned
if let Some(ban) = server.ban_entry(&client.peer.ip()).await {
if ban.is_banned() {
let msg = if let Some(reason) = ban.reason {
info!(target: "lazymc", "Login from banned IP {} ({}), disconnecting", client.peer.ip(), &reason);
reason.to_string()
} else {
info!(target: "lazymc", "Login from banned IP {}, disconnecting", client.peer.ip());
DEFAULT_BAN_REASON.to_string()
};
action::kick(&client, &format!("{BAN_MESSAGE_PREFIX}{msg}"), &mut writer)
.await?;
break;
}
server::State::Stopping => &config.messages.login_stopping,
};
kick(msg, &mut writer).await?;
}
break;
// Kick if client is not whitelisted to wake server
if let Some(ref username) = username {
if !server.is_whitelisted(username).await {
info!(target: "lazymc", "User '{}' tried to wake server but is not whitelisted, disconnecting", username);
action::kick(&client, WHITELIST_MESSAGE, &mut writer).await?;
break;
}
}
// Start server if not starting yet
Server::start(config.clone(), server.clone(), username).await;
// Remember inbound packets
inbound_history.extend(&raw);
inbound_history.extend(&buf);
// Build inbound packet queue with everything from login start (including this)
let mut login_queue = BytesMut::with_capacity(raw.len() + buf.len());
login_queue.extend(&raw);
login_queue.extend(&buf);
// Buf is fully consumed here
buf.clear();
// Start occupying client
join::occupy(
client,
client_info,
config,
server,
inbound,
inbound_history,
login_queue,
)
.await?;
return Ok(());
}
// Show unhandled packet warning
debug!(target: "lazymc", "Received unhandled packet:");
debug!(target: "lazymc", "Got unhandled packet:");
debug!(target: "lazymc", "- State: {:?}", client_state);
debug!(target: "lazymc", "- Packet ID: {}", packet.id);
}
// Gracefully close connection
match writer.shutdown().await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotConnected => {}
Err(_) => return Err(()),
}
Ok(())
}
/// Hold a client while server starts.
///
/// Relays client to proxy once server is ready.
pub async fn hold<'a>(
mut inbound: TcpStream,
config: Arc<Config>,
server: Arc<Server>,
hold_queue: BytesMut,
) -> Result<(), ()> {
trace!(target: "lazymc", "Started holding client");
// Set up polling interval, get timeout
let mut poll_interval = time::interval(HOLD_POLL_INTERVAL);
let since = Instant::now();
let timeout = config.time.hold_client_for as u64;
loop {
// TODO: wait for start signal over channel instead of polling
poll_interval.tick().await;
trace!("Polling server state for holding client...");
match server.state() {
// Still waiting on server start
State::Starting => {
trace!(target: "lazymc", "Server not ready, holding client for longer");
// If hold timeout is reached, kick client
if since.elapsed().as_secs() >= timeout {
warn!(target: "lazymc", "Held client reached timeout of {}s, disconnecting", timeout);
kick(&config.messages.login_starting, &mut inbound.split().1).await?;
return Ok(());
}
continue;
}
// Server started, start relaying and proxy
State::Started => {
// TODO: drop client if already disconnected
// Relay client to proxy
info!(target: "lazymc", "Server ready for held client, relaying to server");
service::server::route_proxy_queue(inbound, config, hold_queue);
return Ok(());
}
// Server stopping, this shouldn't happen, kick
State::Stopping => {
warn!(target: "lazymc", "Server stopping for held client, disconnecting");
kick(&config.messages.login_stopping, &mut inbound.split().1).await?;
break;
}
// Server stopped, this shouldn't happen, disconnect
State::Stopped => {
error!(target: "lazymc", "Server stopped for held client, disconnecting");
break;
}
}
}
// Gracefully close connection
match inbound.shutdown().await {
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotConnected => {}
Err(_) => return Err(()),
}
Ok(())
}
/// Kick client with a message.
///
/// Should close connection afterwards.
async fn kick(msg: &str, writer: &mut WriteHalf<'_>) -> Result<(), ()> {
let packet = LoginDisconnect {
reason: Message::new(Payload::text(msg)),
};
let mut data = Vec::new();
packet.encode(&mut data).map_err(|_| ())?;
let response = RawPacket::new(0, data).encode()?;
writer.write_all(&response).await.map_err(|_| ())
}
/// Build server status object to respond to client with.
fn server_status(config: &Config, server: &Server) -> ServerStatus {
let status = server.status();
async fn server_status(client_info: &ClientInfo, config: &Config, server: &Server) -> ServerStatus {
let status = server.status().await;
let server_state = server.state();
// Respond with real server status if started
if server_state == server::State::Started && status.is_some() {
return status.as_ref().unwrap().clone();
}
// Select version and player max from last known server status
let (version, max) = match status.as_ref() {
@@ -253,17 +228,28 @@ fn server_status(config: &Config, server: &Server) -> ServerStatus {
// Select description, use server MOTD if enabled, or use configured
let description = {
if config.messages.use_server_motd && status.is_some() {
if config.motd.from_server && status.is_some() {
status.as_ref().unwrap().description.clone()
} else {
Message::new(Payload::text(match server.state() {
server::State::Stopped | server::State::Started => &config.messages.motd_sleeping,
server::State::Starting => &config.messages.motd_starting,
server::State::Stopping => &config.messages.motd_stopping,
}))
match server_state {
server::State::Stopped | server::State::Started => config.motd.sleeping.clone(),
server::State::Starting => config.motd.starting.clone(),
server::State::Stopping => config.motd.stopping.clone(),
}
}
};
// Extract favicon from real server status, load from disk, or use default
let mut favicon = None;
if favicon::supports_favicon(client_info) {
if config.motd.from_server && status.is_some() {
favicon = status.as_ref().unwrap().favicon.clone()
}
if favicon.is_none() {
favicon = Some(server_favicon(config).await);
}
}
// Build status resposne
ServerStatus {
version,
@@ -273,5 +259,31 @@ fn server_status(config: &Config, server: &Server) -> ServerStatus {
max,
sample: vec![],
},
favicon,
}
}
/// Get server status favicon.
///
/// This always returns a favicon, returning the default one if none is set.
async fn server_favicon(config: &Config) -> String {
// Get server dir
let dir = match ConfigServer::server_directory(config) {
Some(dir) => dir,
None => return favicon::default_favicon(),
};
// Server icon file, ensure it exists
let path = dir.join(SERVER_ICON_FILE);
if !path.is_file() {
return favicon::default_favicon();
}
// Read icon data
let data = fs::read(path).await.unwrap_or_else(|err| {
error!(target: "lazymc::status", "Failed to read favicon from {}, using default: {err}", SERVER_ICON_FILE);
favicon::default_favicon().into_bytes()
});
favicon::encode_favicon(&data)
}

View File

@@ -10,7 +10,7 @@ use crate::util::error::{quit_error, ErrorHints};
/// excluding the `:` suffix.
pub fn prompt(msg: &str) -> String {
// Show the prompt
eprint!("{}: ", msg);
eprint!("{msg}: ");
let _ = stderr().flush();
// Get the input
@@ -49,7 +49,7 @@ pub fn prompt_yes(msg: &str, def: Option<bool>) -> bool {
);
// Get the user input
let answer = prompt(&format!("{} {}", msg, options));
let answer = prompt(&format!("{msg} {options}"));
// Assume the default if the answer is empty
if answer.is_empty() {

View File

@@ -16,7 +16,7 @@ pub fn print_error(err: anyhow::Error) {
// Report each printable error, count them
let count = err
.chain()
.map(|err| format!("{}", err))
.map(|err| err.to_string())
.filter(|err| !err.is_empty())
.enumerate()
.map(|(i, err)| {
@@ -126,7 +126,7 @@ impl ErrorHints {
if self.config_generate {
eprintln!(
"Use '{}' to generate a new config file",
highlight(&format!("{} config generate", bin))
highlight(&format!("{bin} config generate"))
);
}
if self.config {
@@ -138,7 +138,7 @@ impl ErrorHints {
if self.config_test {
eprintln!(
"Use '{}' to test a config file",
highlight(&format!("{} config test -c FILE", bin))
highlight(&format!("{bin} config test -c FILE"))
);
}
if self.verbose {

View File

@@ -1,5 +1,6 @@
pub mod cli;
pub mod error;
pub mod serde;
pub mod style;
use std::env;

30
src/util/serde.rs Normal file
View File

@@ -0,0 +1,30 @@
use std::net::{SocketAddr, ToSocketAddrs};
use serde::de::{Error, Unexpected};
use serde::{Deserialize, Deserializer};
/// Deserialize a `Vec` into a `HashMap` by key.
pub fn to_socket_addrs<'de, D>(d: D) -> Result<SocketAddr, D::Error>
where
D: Deserializer<'de>,
{
// Deserialize string
let addr = String::deserialize(d)?;
// Try to socket address to resolve
match addr.to_socket_addrs() {
Ok(mut addr) => {
if let Some(addr) = addr.next() {
return Ok(addr);
}
}
Err(err) => {
dbg!(err);
}
}
// Parse raw IP address
addr.parse().map_err(|_| {
Error::invalid_value(Unexpected::Str(&addr), &"IP or resolvable host and port")
})
}