commit f2653e5747375530dfa1263bed582c3badb20cf8 Author: Alexander Medvedev Date: Mon Sep 7 21:16:31 2026 +0200 first commit diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0629e42 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3104 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-compression" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb8" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b823af3723db9d6326eed3102949445a3323378bab7d6ecb9107ff3cb9a08" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4-java-wrc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed1c11dbd317fa5d0508cbb5ce47696bb27771e3c9604428e0e98c7c3d76d0d" +dependencies = [ + "lz4_flex", + "twox-hash", +] + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pumpkin-codecs" +version = "0.1.0+26.2-26.45" +dependencies = [ + "serde_json", + "tracing", +] + +[[package]] +name = "pumpkin-command" +version = "0.1.0+26.2-26.45" +dependencies = [ + "pumpkin-codecs", + "pumpkin-data", + "pumpkin-nbt", + "pumpkin-protocol", + "pumpkin-util", + "rustc-hash", + "serde_json", + "tracing", + "uuid", +] + +[[package]] +name = "pumpkin-config" +version = "0.1.0+26.2-26.45" +dependencies = [ + "pumpkin-util", + "serde", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "pumpkin-data" +version = "0.1.0+26.2-26.45" +dependencies = [ + "crc-fast", + "phf", + "pumpkin-nbt", + "pumpkin-util", + "rand", + "serde", +] + +[[package]] +name = "pumpkin-macros" +version = "0.1.0+26.2-26.45" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "pumpkin-data", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "pumpkin-nbt" +version = "0.1.0+26.2-26.45" +dependencies = [ + "bytes", + "cesu8", + "flate2", + "pumpkin-codecs", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "pumpkin-protocol" +version = "0.1.0+26.2-26.45" +dependencies = [ + "aes", + "async-compression", + "bitflags", + "bytes", + "cesu8", + "cfb8", + "flate2", + "hybrid-array", + "pumpkin-data", + "pumpkin-macros", + "pumpkin-nbt", + "pumpkin-util", + "pumpkin-world", + "serde", + "serde_json", + "thiserror", + "tokio", + "uuid", + "xxhash-rust", +] + +[[package]] +name = "pumpkin-util" +version = "0.1.0+26.2-26.45" +dependencies = [ + "base64 0.23.1", + "bytes", + "colored", + "crypto-bigint", + "dashmap", + "ecdsa", + "md5", + "num-derive", + "num-traits", + "p384", + "pumpkin-codecs", + "pumpkin-nbt", + "reqwest", + "rsa", + "rustls", + "serde", + "serde_json", + "sha2", + "thiserror", + "uuid", + "webpki-root-certs", +] + +[[package]] +name = "pumpkin-world" +version = "0.1.0+26.2-26.45" +dependencies = [ + "arc-swap", + "bitflags", + "bytes", + "crossbeam", + "dashmap", + "flate2", + "futures", + "itertools", + "lz4-java-wrc", + "pumpkin-config", + "pumpkin-data", + "pumpkin-nbt", + "pumpkin-util", + "rand", + "rayon", + "rustc-hash", + "ruzstd", + "serde", + "serde_json", + "sha2", + "slotmap", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "xxhash-rust", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ruzstd" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "md-5", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vine" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.23.1", + "bytes", + "ed25519-dalek", + "hex", + "hmac", + "num-bigint", + "pumpkin-command", + "pumpkin-protocol", + "pumpkin-util", + "rand", + "reqwest", + "rsa", + "serde", + "serde_json", + "sha1", + "sha2", + "socket2", + "sysinfo", + "tempfile", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eb8a67a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "vine" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0" + +[dependencies] +pumpkin-protocol = { path = "../Pumpkin/crates/pumpkin-protocol" } +pumpkin-util = { path = "../Pumpkin/crates/pumpkin-util" } +pumpkin-command = { path = "../Pumpkin/crates/pumpkin-command" } + +tokio = { version = "1.53", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +toml = { version = "1.1", features = ["parse", "display", "serde"] } +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +thiserror = { version = "2.0" } +bytes = { version = "1.12" } +uuid = { version = "1.26", features = ["serde", "v3", "v4"] } +rand = { version = "0.10" } + +# Networking & Sockets +socket2 = { version = "0.6", features = ["all"] } + +# Security & Cryptography +rsa = { version = "=0.10.0-rc.18", features = ["std", "encoding"] } +sha1 = { version = "=0.11.0" } +sha2 = { version = "=0.11.0" } +hmac = { version = "=0.13.0" } +num-bigint = { version = "0.5" } +base64 = { version = "0.23" } +ed25519-dalek = { version = "3.0", default-features = false, features = ["fast"] } +hex = { version = "0.4", default-features = false, features = ["std"] } + +# HTTP client for Mojang auth and telemetry +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "json", "http2"] } + +# Telemetry & System Info +sysinfo = { version = "0.39", default-features = false, features = ["system"] } + +[dev-dependencies] +tempfile = "3" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..dce6aac --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,174 @@ +use num_bigint::BigInt; +use pumpkin_protocol::Property; +use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, pkcs8::EncodePublicKey}; +use serde::Deserialize; +use sha1::{Digest, Sha1}; +use std::{net::IpAddr, time::Duration}; +use thiserror::Error; +use tracing::{debug, error, info}; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum AuthError { + #[error("Failed to decrypt RSA payload: {0}")] + DecryptError(String), + #[error("Verify token mismatch")] + VerifyTokenMismatch, + #[error("Mojang authentication failed (status: {0})")] + MojangAuthFailed(u16), + #[error("Failed to contact Mojang session service: {0}")] + NetworkError(#[from] reqwest::Error), + #[error("Failed to parse Mojang response JSON: {0}")] + JsonError(#[from] serde_json::Error), + #[error("Invalid UUID from authentication server: {0}")] + InvalidUuid(#[from] uuid::Error), +} + +pub struct KeyStore { + private_key: RsaPrivateKey, + public_key_der: Box<[u8]>, +} + +impl KeyStore { + pub fn new() -> Result> { + let mut rng = rand::rng(); + info!("Generating 1024-bit RSA keypair for proxy encryption..."); + let private_key = RsaPrivateKey::new(&mut rng, 1024)?; + let public_key = private_key.to_public_key(); + let public_key_der = public_key + .to_public_key_der() + .map(|der| der.into_vec().into_boxed_slice())?; + + Ok(Self { + private_key, + public_key_der, + }) + } + + pub fn public_key_der(&self) -> &[u8] { + &self.public_key_der + } + + pub fn decrypt(&self, data: &[u8]) -> Result, AuthError> { + self.private_key + .decrypt(Pkcs1v15Encrypt, data) + .map_err(|e| AuthError::DecryptError(e.to_string())) + } + + /// Computes Minecraft's custom hex SHA-1 server hash + pub fn auth_digest(&self, server_id: &str, shared_secret: &[u8]) -> String { + let mut sha1 = Sha1::new(); + sha1.update(server_id.as_bytes()); + sha1.update(shared_secret); + sha1.update(&self.public_key_der); + let digest = sha1.finalize(); + + BigInt::from_signed_bytes_be(&digest).to_str_radix(16) + } +} + +#[derive(Clone, Debug)] +pub struct AuthenticatedPlayer { + pub id: Uuid, + pub username: String, + pub properties: Vec, +} + +impl AuthenticatedPlayer { + pub fn offline(username: &str) -> Self { + let id = Uuid::new_v3(&Uuid::nil(), format!("OfflinePlayer:{username}").as_bytes()); + Self { + id, + username: username.to_string(), + properties: Vec::new(), + } + } +} + +#[derive(Deserialize, Debug)] +struct MojangSessionResponse { + id: String, + name: String, + #[serde(default)] + properties: Vec, +} + +#[derive(Deserialize, Debug)] +struct MojangProperty { + name: String, + value: String, + signature: Option, +} + +pub struct Authenticator { + client: reqwest::Client, +} + +impl Default for Authenticator { + fn default() -> Self { + Self::new() + } +} + +impl Authenticator { + pub fn new() -> Self { + let client = pumpkin_util::client_builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + Self { client } + } + + /// Authenticate a player with Mojang's session servers + pub async fn authenticate( + &self, + username: &str, + server_hash: &str, + client_ip: &IpAddr, + prevent_proxy_connections: bool, + ) -> Result { + let url = if prevent_proxy_connections { + format!( + "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}&ip={client_ip}" + ) + } else { + format!( + "https://sessionserver.mojang.com/session/minecraft/hasJoined?username={username}&serverId={server_hash}" + ) + }; + + debug!("Querying Mojang authentication service for '{}'", username); + let resp = self.client.get(&url).send().await?; + + if !resp.status().is_success() { + error!( + "Mojang authentication failed for '{}': status {}", + username, + resp.status() + ); + return Err(AuthError::MojangAuthFailed(resp.status().as_u16())); + } + + let body = resp.text().await?; + let data: MojangSessionResponse = serde_json::from_str(&body)?; + + let uuid = Uuid::parse_str(&data.id)?; + + let properties = data + .properties + .into_iter() + .map(|p| Property { + name: p.name.into_boxed_str(), + value: p.value.into_boxed_str(), + signature: p.signature.map(String::into_boxed_str), + }) + .collect(); + + Ok(AuthenticatedPlayer { + id: uuid, + username: data.name, + properties, + }) + } +} diff --git a/src/backend/bridge.rs b/src/backend/bridge.rs new file mode 100644 index 0000000..dfea634 --- /dev/null +++ b/src/backend/bridge.rs @@ -0,0 +1,1389 @@ +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bytes::Bytes; +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::java::client::config::CConfigDisconnect; +use pumpkin_protocol::java::client::login::{ + CLoginDisconnect, CLoginPluginRequest, CLoginSuccess, CSetCompression, +}; +use pumpkin_protocol::java::client::play::{ + ArgumentType, CCommandSuggestions, CCommands, CPlayDisconnect, CStartConfiguration, + CSystemChatMessage, CTransfer, ProtoNode, ProtoNodeType, StringProtoArgBehavior, + SuggestionProviders, +}; +use pumpkin_protocol::java::packet_decoder::TCPNetworkDecoder; +use pumpkin_protocol::java::packet_encoder::TCPNetworkEncoder; +use pumpkin_protocol::java::server::handshake::SHandShake; +use pumpkin_protocol::java::server::login::{SLoginPluginResponse, SLoginStart}; +use pumpkin_protocol::java::server::play::{SChatCommand, SCommandSuggestion}; +use pumpkin_protocol::packet::MultiVersionJavaPacket; +use pumpkin_protocol::ser::{NetworkReadExt, NetworkReadSliceExt}; +use pumpkin_protocol::{ClientPacket, ConnectionState, ServerPacket}; +use pumpkin_util::text::TextComponent; +use pumpkin_util::version::JavaMinecraftVersion; +use std::time::Duration; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use crate::auth::AuthenticatedPlayer; +use crate::backend::forwarding::{ + ForwardingHelper, VELOCITY_PLAYER_INFO_CHANNEL, VINE_PLAYER_INFO_CHANNEL, +}; +use crate::command::{CommandDispatcher, CommandSender, ProxyCommandSource}; +use crate::config::{BackendConfig, Config, ForwardingMode, NetworkConfig}; +use crate::network; +use crate::security::PacketRateLimiter; +use crate::session::{PlayerAction, PlayerSession, SessionManager}; + +#[derive(Error, Debug)] +pub enum BridgeError { + #[error("Failed to connect to backend server '{0}': {1}")] + ConnectFailed(String, std::io::Error), + #[error("Backend communication error: {0}")] + IoError(#[from] std::io::Error), + #[error("Packet decode error: {0}")] + DecodeError(String), + #[error("Packet encode error: {0}")] + EncodeError(String), + #[error("Backend server '{0}' login timed out")] + LoginTimeout(String), + #[error("Backend disconnected player during login: {0}")] + LoginDisconnected(String), + #[error("Velocity forwarding error: {0}")] + VelocityError(String), + #[error("Vine forwarding error: {0}")] + VineError(String), +} + +#[derive(Debug)] +pub enum TunnelExit { + /// Client disconnected or connection closed + ClientDisconnected, + /// Player requested switch to another backend server + Connect { server_name: String }, + /// Direct client transfer requested + Transfer { host: String, port: u16 }, + /// Backend server closed connection or sent disconnect/kick + BackendDisconnected { reason: Option }, +} + +pub struct BackendBridge { + server_name: String, + config: BackendConfig, + fallback_server: Option<(String, BackendConfig)>, + client_ip: IpAddr, + client_version: JavaMinecraftVersion, + virtual_host: String, + player: AuthenticatedPlayer, + max_packets_per_sec: u32, + online_players: Arc, + network_config: Arc, + compression_level: i32, + session_manager: Arc, + command_dispatcher: Arc, + proxy_config: Arc, +} + +impl BackendBridge { + #[allow(clippy::too_many_arguments)] + pub fn new( + server_name: String, + config: BackendConfig, + fallback_server: Option<(String, BackendConfig)>, + client_ip: IpAddr, + client_version: JavaMinecraftVersion, + virtual_host: String, + player: AuthenticatedPlayer, + max_packets_per_sec: u32, + online_players: Arc, + network_config: Arc, + compression_level: i32, + session_manager: Arc, + command_dispatcher: Arc, + proxy_config: Arc, + ) -> Self { + Self { + server_name, + config, + fallback_server, + client_ip, + client_version, + virtual_host, + player, + max_packets_per_sec, + online_players, + network_config, + compression_level, + session_manager, + command_dispatcher, + proxy_config, + } + } + + /// Establishes connection to backend with retry attempts and timeout + async fn connect_to_backend( + server_name: &str, + address: &str, + timeout_secs: u64, + retries: u32, + ) -> Result { + let connect_timeout = Duration::from_secs(timeout_secs); + let mut last_err = None; + + for attempt in 1..=retries.max(1) { + match timeout(connect_timeout, TcpStream::connect(address)).await { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(e)) => { + debug!( + "Connection attempt {}/{} to '{}' ({}) failed: {}", + attempt, retries, server_name, address, e + ); + last_err = Some(e); + } + Err(_) => { + debug!( + "Connection attempt {}/{} to '{}' ({}) timed out after {}s", + attempt, retries, server_name, address, timeout_secs + ); + last_err = Some(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Connection timed out after {}s", timeout_secs), + )); + } + } + + if attempt < retries { + tokio::time::sleep(Duration::from_millis(150)).await; + } + } + + Err(BridgeError::ConnectFailed( + server_name.to_string(), + last_err.unwrap_or_else(|| { + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Failed to connect") + }), + )) + } + + /// Connects to a backend server, configures the socket, and executes the login sequence + async fn login_backend( + &self, + server_name: &str, + active_config: &BackendConfig, + ) -> Result< + ( + TCPNetworkDecoder, + TCPNetworkEncoder, + Vec, + ), + BridgeError, + > { + let backend_stream = Self::connect_to_backend( + server_name, + &active_config.address, + self.network_config.backend_connect_timeout_secs, + self.network_config.backend_connect_retry_attempts, + ) + .await?; + + if let Err(e) = network::configure_backend_socket(&backend_stream, &self.network_config) { + debug!("Failed to configure backend socket: {}", e); + } + + let (backend_read, backend_write) = backend_stream.into_split(); + let mut backend_decoder = TCPNetworkDecoder::new(backend_read); + let mut backend_encoder = TCPNetworkEncoder::new(backend_write); + + let backend_host = match active_config.forwarding { + ForwardingMode::BungeeCord => ForwardingHelper::build_bungeecord_host( + &self.virtual_host, + &self.client_ip, + &self.player, + ), + ForwardingMode::Velocity | ForwardingMode::Vine | ForwardingMode::None => { + self.virtual_host.clone() + } + }; + + let handshake_packet = SHandShake { + protocol_version: VarInt(self.client_version.protocol_version()), + server_address: backend_host.into_boxed_str(), + server_port: 25565, + next_state: ConnectionState::Login, + }; + + let handshake_bytes = handshake_packet + .serialize_packet(&self.client_version) + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .write_packet(handshake_bytes) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + + let login_start_packet = SLoginStart { + name: self.player.username.clone().into_boxed_str(), + uuid: self.player.id, + }; + let login_start_bytes = login_start_packet + .serialize_packet(&self.client_version) + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .write_packet(login_start_bytes) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + + let login_timeout_dur = Duration::from_secs(self.network_config.backend_login_timeout_secs); + let login_disconnect_id = CLoginDisconnect::to_id(self.client_version); + let login_plugin_request_id = CLoginPluginRequest::to_id(self.client_version); + let login_compression_id = CSetCompression::to_id(self.client_version); + let login_success_id = CLoginSuccess::to_id(self.client_version); + + loop { + let raw_packet = timeout(login_timeout_dur, backend_decoder.get_raw_packet()) + .await + .map_err(|_| BridgeError::LoginTimeout(server_name.to_string()))? + .map_err(|e| BridgeError::DecodeError(e.to_string()))?; + + if raw_packet.id == login_disconnect_id { + let mut cur = &raw_packet.payload[..]; + let reason = cur + .get_str() + .map(|s| s.to_string()) + .unwrap_or_else(|_| "Disconnected by backend during login".to_string()); + warn!( + "Backend '{}' disconnected '{}' during login: {}", + server_name, self.player.username, reason + ); + return Err(BridgeError::LoginDisconnected(reason)); + } else if raw_packet.id == login_plugin_request_id { + let mut cur = &raw_packet.payload[..]; + let message_id = cur + .get_var_int() + .map_err(|e| BridgeError::DecodeError(e.to_string()))?; + let channel = cur + .get_str() + .map_err(|e| BridgeError::DecodeError(e.to_string()))?; + + debug!( + "Backend requested login plugin message on channel '{}'", + channel + ); + + if channel.as_ref() == VINE_PLAYER_INFO_CHANNEL { + let secret = active_config + .secret + .as_deref() + .ok_or_else(|| BridgeError::VineError("Secret missing".to_string()))?; + + let mut challenge = [0u8; 16]; + if cur.len() >= 17 { + challenge.copy_from_slice(&cur[1..17]); + } else if cur.len() >= 16 { + challenge.copy_from_slice(&cur[0..16]); + } + + let vine_data = ForwardingHelper::build_vine_payload( + secret, + &self.client_ip, + &self.player, + &challenge, + ) + .map_err(|e| BridgeError::VineError(e.to_string()))?; + + let plugin_response = SLoginPluginResponse { + message_id, + data: Some(vine_data.into_boxed_slice()), + }; + + let resp_bytes = plugin_response + .serialize_packet(&self.client_version) + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .write_packet(resp_bytes) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + } else if channel.as_ref() == VELOCITY_PLAYER_INFO_CHANNEL { + let secret = active_config + .secret + .as_deref() + .ok_or_else(|| BridgeError::VelocityError("Secret missing".to_string()))?; + + let velocity_data = ForwardingHelper::build_velocity_payload( + secret, + &self.client_ip, + &self.player, + ) + .map_err(|e| BridgeError::VelocityError(e.to_string()))?; + + let plugin_response = SLoginPluginResponse { + message_id, + data: Some(velocity_data.into_boxed_slice()), + }; + + let resp_bytes = plugin_response + .serialize_packet(&self.client_version) + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .write_packet(resp_bytes) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + } else { + let plugin_response = SLoginPluginResponse { + message_id, + data: None, + }; + let resp_bytes = plugin_response + .serialize_packet(&self.client_version) + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .write_packet(resp_bytes) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + backend_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + } + } else if raw_packet.id == login_compression_id { + let mut cur = &raw_packet.payload[..]; + let threshold = cur + .get_var_int() + .map_err(|e| BridgeError::DecodeError(e.to_string()))? + .0; + debug!("Backend enabled compression threshold: {} bytes", threshold); + if threshold >= 0 { + backend_decoder.set_compression(threshold as usize); + backend_encoder + .set_compression((threshold as usize, self.compression_level as u32)); + } + } else if raw_packet.id == login_success_id { + debug!( + "Login successful on backend for '{}'!", + self.player.username + ); + + let mut full_packet = Vec::with_capacity(5 + raw_packet.payload.len()); + let _ = VarInt(raw_packet.id).encode(&mut full_packet); + full_packet.extend_from_slice(&raw_packet.payload); + return Ok((backend_decoder, backend_encoder, full_packet)); + } else { + debug!( + "Unhandled packet during login (ID: {}), skipping", + raw_packet.id + ); + } + } + } + + /// Establishes connection to backend, handles login handshake, and tunnels packets + pub async fn run( + self, + mut client_decoder: TCPNetworkDecoder, + mut client_encoder: TCPNetworkEncoder, + ) -> Result<(), BridgeError> + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + info!( + "Connecting player '{}' ({}) to backend server '{}' at {}", + self.player.username, self.player.id, self.server_name, self.config.address + ); + + let mut current_server_name = self.server_name.clone(); + + let (mut backend_decoder, mut backend_encoder, success_bytes) = match self + .login_backend(¤t_server_name, &self.config) + .await + { + Ok(res) => res, + Err(e) => { + if let Some((fb_name, fb_cfg)) = &self.fallback_server { + if fb_name != ¤t_server_name { + warn!( + "Primary server '{}' unreachable ({}), attempting failover to fallback server '{}' at {}", + current_server_name, e, fb_name, fb_cfg.address + ); + current_server_name = fb_name.clone(); + self.login_backend(¤t_server_name, fb_cfg).await? + } else { + return Err(e); + } + } else { + return Err(e); + } + } + }; + + // Forward login success packet to the client + client_encoder + .write_packet(success_bytes.into()) + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + client_encoder + .flush() + .await + .map_err(|e| BridgeError::EncodeError(e.to_string()))?; + + self.online_players.fetch_add(1, Ordering::Relaxed); + info!( + "Player '{}' is now connected to backend '{}'! Online players: {}", + self.player.username, + current_server_name, + self.online_players.load(Ordering::Relaxed) + ); + + let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel(); + self.session_manager.register(PlayerSession { + username: self.player.username.clone(), + uuid: self.player.id, + current_server: current_server_name.clone(), + action_tx: action_tx.clone(), + }); + + loop { + let tunnel_res = Self::bridge_tunnel( + &self.player.username, + &mut client_decoder, + &mut client_encoder, + backend_decoder, + backend_encoder, + self.max_packets_per_sec, + self.client_version, + &mut action_rx, + &action_tx, + self.command_dispatcher.clone(), + self.proxy_config.clone(), + self.session_manager.clone(), + ) + .await; + + let exit = match tunnel_res { + Ok(exit) => exit, + Err(e) => { + warn!( + "Bridge tunnel error for player '{}': {}", + self.player.username, e + ); + TunnelExit::BackendDisconnected { + reason: Some(e.to_string()), + } + } + }; + + match exit { + TunnelExit::ClientDisconnected => { + debug!( + "Player '{}' disconnected from client side", + self.player.username + ); + break; + } + TunnelExit::Transfer { host, port } => { + let transfer = CTransfer::new(&host, VarInt(port as i32)); + if let Ok(bytes) = transfer.serialize_packet(&self.client_version) { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + info!( + "Transferring player '{}' to {}:{}", + self.player.username, host, port + ); + break; + } + TunnelExit::Connect { server_name } => { + info!( + "Switching player '{}' to backend server '{}'...", + self.player.username, server_name + ); + + let target_config = match self.proxy_config.servers.get(&server_name) { + Some(cfg) => cfg.clone(), + None => { + let msg = TextComponent::text(format!( + "Server '{}' does not exist.", + server_name + )); + let chat_msg = CSystemChatMessage::new(&msg, false); + if let Ok(bytes) = chat_msg.serialize_packet(&self.client_version) { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + break; + } + }; + + match self.login_backend(&server_name, &target_config).await { + Ok((new_dec, new_enc, _)) => { + if self.client_version >= JavaMinecraftVersion::V_1_20_2 + && let Ok(bytes) = + CStartConfiguration.serialize_packet(&self.client_version) + { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + backend_decoder = new_dec; + backend_encoder = new_enc; + current_server_name = server_name.clone(); + self.session_manager + .update_server(&self.player.username, ¤t_server_name); + info!( + "Player '{}' successfully switched to '{}'", + self.player.username, current_server_name + ); + } + Err(e) => { + warn!( + "Failed to switch player '{}' to '{}': {}", + self.player.username, server_name, e + ); + let msg = TextComponent::text(format!( + "§cFailed to connect to server '{}': {}", + server_name, e + )); + let chat_msg = CSystemChatMessage::new(&msg, false); + if let Ok(bytes) = chat_msg.serialize_packet(&self.client_version) { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + + let can_fallback = self + .fallback_server + .as_ref() + .is_some_and(|(fb_name, _)| fb_name != &server_name); + + if can_fallback { + let (fb_name, fb_cfg) = self.fallback_server.as_ref().unwrap(); + match self.login_backend(fb_name, fb_cfg).await { + Ok((new_dec, new_enc, _)) => { + if self.client_version >= JavaMinecraftVersion::V_1_20_2 + && let Ok(bytes) = CStartConfiguration + .serialize_packet(&self.client_version) + { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + backend_decoder = new_dec; + backend_encoder = new_enc; + current_server_name = fb_name.clone(); + self.session_manager.update_server( + &self.player.username, + ¤t_server_name, + ); + } + Err(_) => { + let kick_reason = format!( + "Lost connection to '{}' and fallback unavailable", + current_server_name + ); + Self::disconnect_client( + &mut client_encoder, + self.client_version, + false, + &kick_reason, + ) + .await; + break; + } + } + } else { + break; + } + } + } + } + TunnelExit::BackendDisconnected { reason } => { + warn!( + "Backend server '{}' disconnected player '{}': {:?}", + current_server_name, self.player.username, reason + ); + + let can_fallback = self + .fallback_server + .as_ref() + .is_some_and(|(fb_name, _)| fb_name != ¤t_server_name); + + if can_fallback { + let (fb_name, fb_cfg) = self.fallback_server.as_ref().unwrap(); + let reason_str = reason.as_deref().unwrap_or("Server closed"); + info!( + "Rerouting player '{}' to fallback server '{}' (kicked from '{}': {})", + self.player.username, fb_name, current_server_name, reason_str + ); + + let notice = TextComponent::text(format!( + "§eConnecting to fallback server '{}'... (§c{}§e)", + fb_name, reason_str + )); + let chat_msg = CSystemChatMessage::new(¬ice, false); + if let Ok(bytes) = chat_msg.serialize_packet(&self.client_version) { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + + match self.login_backend(fb_name, fb_cfg).await { + Ok((new_dec, new_enc, _)) => { + if self.client_version >= JavaMinecraftVersion::V_1_20_2 + && let Ok(bytes) = + CStartConfiguration.serialize_packet(&self.client_version) + { + let _ = client_encoder.write_packet(bytes).await; + let _ = client_encoder.flush().await; + } + backend_decoder = new_dec; + backend_encoder = new_enc; + current_server_name = fb_name.clone(); + self.session_manager + .update_server(&self.player.username, ¤t_server_name); + info!( + "Player '{}' successfully recovered to fallback '{}'", + self.player.username, current_server_name + ); + } + Err(e) => { + warn!( + "Failed to connect player '{}' to fallback server '{}': {}", + self.player.username, fb_name, e + ); + let kick_reason = format!( + "Lost connection to '{}' and fallback unavailable: {}", + current_server_name, e + ); + Self::disconnect_client( + &mut client_encoder, + self.client_version, + false, + &kick_reason, + ) + .await; + break; + } + } + } else { + let reason_str = reason.as_deref().unwrap_or("Lost connection to server"); + Self::disconnect_client( + &mut client_encoder, + self.client_version, + false, + reason_str, + ) + .await; + break; + } + } + } + } + + self.session_manager.unregister(&self.player.username); + self.online_players.fetch_sub(1, Ordering::Relaxed); + info!( + "Player '{}' disconnected from proxy. Online players: {}", + self.player.username, + self.online_players.load(Ordering::Relaxed) + ); + + Ok(()) + } + + /// Helper to send formatted disconnect packet to client in either play or configuration state + async fn disconnect_client( + encoder: &mut TCPNetworkEncoder, + version: JavaMinecraftVersion, + is_config_state: bool, + reason: &str, + ) { + if is_config_state { + let json_reason = format!( + r#"{{"text":"[Vine Proxy] {}","color":"red"}}"#, + reason.replace('"', "\\\"") + ); + let pkt = CConfigDisconnect::new(&json_reason); + if let Ok(bytes) = pkt.serialize_packet(&version) { + let _ = encoder.write_packet(bytes).await; + let _ = encoder.flush().await; + } + } else { + let kick_reason = TextComponent::text(reason.to_string()); + let pkt = CPlayDisconnect::new(&kick_reason); + if let Ok(bytes) = pkt.serialize_packet(&version) { + let _ = encoder.write_packet(bytes).await; + let _ = encoder.flush().await; + } + } + } +} + +#[derive(Clone)] +struct PendingSuggestion { + clean_cmd: String, + offset: usize, + proxy_matches: Vec<(String, Option)>, +} + +fn decode_backend_suggestions( + payload: &mut &[u8], + version: &JavaMinecraftVersion, +) -> Result< + ( + VarInt, + VarInt, + VarInt, + Vec, + ), + (), +> { + if *version >= JavaMinecraftVersion::V_1_13 { + let id = payload.get_var_int().map_err(|_| ())?; + let start = payload.get_var_int().map_err(|_| ())?; + let length = payload.get_var_int().map_err(|_| ())?; + let count = payload.get_var_int().map_err(|_| ())?.0; + if !(0..=20000).contains(&count) { + return Err(()); + } + let mut matches = Vec::with_capacity((count as usize).min(1024)); + for _ in 0..count { + let suggestion = payload.get_str().map_err(|_| ())?.to_string(); + let has_tooltip = payload.get_bool().map_err(|_| ())?; + let tooltip = if has_tooltip { + payload.get_component(version).ok() + } else { + None + }; + matches.push(pumpkin_protocol::java::client::play::CommandSuggestion { + suggestion, + tooltip, + }); + } + Ok((id, start, length, matches)) + } else { + Err(()) + } +} + +fn inject_proxy_commands( + payload: &[u8], + client_version: &JavaMinecraftVersion, + permitted_commands: &[(&str, &str)], +) -> Option> { + if *client_version < JavaMinecraftVersion::V_1_13 || permitted_commands.is_empty() { + return None; + } + + if payload.last() != Some(&0) { + return None; + } + + let mut cur = payload; + let node_count = cur.get_var_int().ok()?.0; + if node_count <= 0 { + return None; + } + + let flags = cur.get_u8().ok()?; + if flags != 0 { + return None; + } + + let children_count = cur.get_var_int().ok()?.0; + if children_count < 0 { + return None; + } + + let mut original_children = Vec::with_capacity(children_count as usize); + for _ in 0..children_count { + let child = cur.get_var_int().ok()?; + original_children.push(child); + } + + if cur.is_empty() { + return None; + } + + let nodes1_to_end = &cur[..cur.len() - 1]; + + let base_index = node_count as usize; + let mut next_node_idx = base_index; + let mut new_root_children = Vec::new(); + let mut extra_nodes_buf = Vec::new(); + + for (cmd_name, _) in permitted_commands { + let takes_args = matches!(*cmd_name, "server" | "send" | "help"); + if takes_args { + let lit_node = ProtoNode { + children: vec![VarInt((next_node_idx + 1) as i32)].into_boxed_slice(), + node_type: ProtoNodeType::Literal { + name: cmd_name, + is_executable: *cmd_name != "send", + redirect_target: None, + restricted: false, + }, + }; + let arg_node = ProtoNode { + children: Vec::new().into_boxed_slice(), + node_type: ProtoNodeType::Argument { + name: "args", + is_executable: true, + redirect_target: None, + parser: ArgumentType::String(StringProtoArgBehavior::GreedyPhrase), + override_suggestion_type: Some(SuggestionProviders::AskServer), + restricted: false, + }, + }; + + lit_node + .write_to(&mut extra_nodes_buf, client_version) + .ok()?; + arg_node + .write_to(&mut extra_nodes_buf, client_version) + .ok()?; + + new_root_children.push(VarInt(next_node_idx as i32)); + next_node_idx += 2; + } else { + let lit_node = ProtoNode { + children: Vec::new().into_boxed_slice(), + node_type: ProtoNodeType::Literal { + name: cmd_name, + is_executable: true, + redirect_target: None, + restricted: false, + }, + }; + + lit_node + .write_to(&mut extra_nodes_buf, client_version) + .ok()?; + + new_root_children.push(VarInt(next_node_idx as i32)); + next_node_idx += 1; + } + } + + let total_nodes = next_node_idx as i32; + let total_root_children = original_children.len() + new_root_children.len(); + + let mut new_payload = Vec::with_capacity(payload.len() + extra_nodes_buf.len() + 64); + let _ = VarInt(total_nodes).encode(&mut new_payload); + new_payload.push(0x00); + let _ = VarInt(total_root_children as i32).encode(&mut new_payload); + for child in original_children { + let _ = child.encode(&mut new_payload); + } + for child in new_root_children { + let _ = child.encode(&mut new_payload); + } + new_payload.extend_from_slice(nodes1_to_end); + new_payload.extend_from_slice(&extra_nodes_buf); + new_payload.push(0x00); + + Some(new_payload) +} + +impl BackendBridge { + /// Tunnels packets back and forth between client and backend + #[allow(clippy::too_many_arguments)] + async fn bridge_tunnel( + username: &str, + client_decoder: &mut TCPNetworkDecoder, + client_encoder: &mut TCPNetworkEncoder, + mut backend_decoder: TCPNetworkDecoder, + mut backend_encoder: TCPNetworkEncoder, + max_packets_per_sec: u32, + client_version: JavaMinecraftVersion, + action_rx: &mut tokio::sync::mpsc::UnboundedReceiver, + action_tx: &tokio::sync::mpsc::UnboundedSender, + command_dispatcher: Arc, + proxy_config: Arc, + session_manager: Arc, + ) -> Result + where + R1: AsyncRead + Unpin, + W1: AsyncWrite + Unpin, + R2: AsyncRead + Unpin, + W2: AsyncWrite + Unpin, + { + let mut client_rate_limiter = PacketRateLimiter::new(max_packets_per_sec); + let chat_command_id = SChatCommand::to_id(client_version); + let command_suggestion_id = SCommandSuggestion::to_id(client_version); + let command_suggestions_clientbound_id = CCommandSuggestions::to_id(client_version); + let commands_packet_id = CCommands::to_id(client_version); + let play_disconnect_id = CPlayDisconnect::to_id(client_version); + + let pending_suggestions: Arc< + tokio::sync::Mutex>, + > = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); + + let (client_tx, mut client_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let client_writer = async { + while let Some(packet_bytes) = client_rx.recv().await { + if let Err(e) = client_encoder.write_packet(packet_bytes).await { + debug!("Client write failed for '{}': {:?}", username, e); + return TunnelExit::ClientDisconnected; + } + if let Err(e) = client_encoder.flush().await { + debug!("Client flush failed for '{}': {:?}", username, e); + return TunnelExit::ClientDisconnected; + } + } + TunnelExit::ClientDisconnected + }; + + let client_to_backend = async { + loop { + match client_decoder.get_raw_packet().await { + Ok(raw_packet) => { + if !client_rate_limiter.record_packet() { + warn!( + "Player '{}' exceeded packet rate limit (flood protection kicked in)", + username + ); + return TunnelExit::ClientDisconnected; + } + + if raw_packet.id == chat_command_id { + let mut payload = &raw_packet.payload[..]; + if let Ok(chat_cmd) = SChatCommand::read(&mut payload, &client_version) + { + let cmd_text = chat_cmd.command; + let first_word = + cmd_text.split_whitespace().next().unwrap_or(cmd_text); + if command_dispatcher.has_command(first_word) { + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: username.to_string(), + action_tx: action_tx.clone(), + }, + config: proxy_config.clone(), + session_manager: session_manager.clone(), + }; + command_dispatcher.handle_command(&source, cmd_text); + continue; + } + } + } + + if raw_packet.id == command_suggestion_id { + let mut payload = &raw_packet.payload[..]; + if let Ok(suggestion_req) = + SCommandSuggestion::read(&mut payload, &client_version) + { + let raw_cmd = &suggestion_req.command; + let clean_cmd = raw_cmd.strip_prefix('/').unwrap_or(raw_cmd); + let first_word = + clean_cmd.split_whitespace().next().unwrap_or(clean_cmd); + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: username.to_string(), + action_tx: action_tx.clone(), + }, + config: proxy_config.clone(), + session_manager: session_manager.clone(), + }; + + // 1. Arguments to a proxy command (contains a space, e.g. "/server ", "/send ") + if clean_cmd.contains(' ') + && command_dispatcher.has_command(first_word) + { + let suggestions = + command_dispatcher.suggest_with_range(clean_cmd, &source); + let offset = if raw_cmd.starts_with('/') { 1 } else { 0 }; + let matches: Box<[pumpkin_protocol::java::client::play::CommandSuggestion]> = suggestions + .suggestions + .into_iter() + .map(|s| pumpkin_protocol::java::client::play::CommandSuggestion { + suggestion: s.text.cached_text().clone(), + tooltip: s.tooltip, + }) + .collect(); + let resp = CCommandSuggestions::new( + suggestion_req.id, + VarInt((suggestions.range.start + offset) as i32), + VarInt(suggestions.range.len() as i32), + matches, + ); + if let Ok(bytes) = resp.serialize_packet(&client_version) { + let _ = client_tx.send(bytes); + } + continue; + } + + // 2. Root command name completion (no space, e.g. "/", "/s", "/ser", "/server") + if !clean_cmd.contains(' ') { + let clean_lower = clean_cmd.to_ascii_lowercase(); + let permitted = command_dispatcher + .raw() + .get_all_permitted_commands(&source); + let proxy_matches: Vec<(String, Option)> = permitted + .into_iter() + .filter(|(cmd_name, _)| cmd_name.starts_with(&clean_lower)) + .map(|(cmd_name, description)| { + (cmd_name.to_string(), Some(description.to_string())) + }) + .collect(); + + if !proxy_matches.is_empty() { + let offset = if raw_cmd.starts_with('/') { 1 } else { 0 }; + let req_id = suggestion_req.id.0; + + pending_suggestions.lock().await.insert( + req_id, + PendingSuggestion { + clean_cmd: clean_cmd.to_string(), + offset, + proxy_matches, + }, + ); + + let pending_ref = pending_suggestions.clone(); + let client_tx_clone = client_tx.clone(); + let cv = client_version; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + if let Some(pending) = + pending_ref.lock().await.remove(&req_id) + { + let suggestions: Vec = pending + .proxy_matches + .into_iter() + .map(|(cmd, tip)| pumpkin_protocol::java::client::play::CommandSuggestion { + suggestion: cmd, + tooltip: tip.map(TextComponent::text), + }) + .collect(); + let resp = CCommandSuggestions::new( + VarInt(req_id), + VarInt(pending.offset as i32), + VarInt(pending.clean_cmd.len() as i32), + suggestions.into_boxed_slice(), + ); + if let Ok(bytes) = resp.serialize_packet(&cv) { + let _ = client_tx_clone.send(bytes); + } + } + }); + } + } + } + } + + let mut full_packet = Vec::with_capacity(5 + raw_packet.payload.len()); + let _ = VarInt(raw_packet.id).encode(&mut full_packet); + full_packet.extend_from_slice(&raw_packet.payload); + + if let Err(e) = backend_encoder.write_packet(full_packet.into()).await { + debug!("Backend write failed for '{}': {:?}", username, e); + return TunnelExit::BackendDisconnected { reason: None }; + } + if let Err(e) = backend_encoder.flush().await { + debug!("Backend flush failed for '{}': {:?}", username, e); + return TunnelExit::BackendDisconnected { reason: None }; + } + } + Err(e) => { + debug!("Client '{}' stream ended: {:?}", username, e); + return TunnelExit::ClientDisconnected; + } + } + } + }; + + let backend_to_client = async { + loop { + match backend_decoder.get_raw_packet().await { + Ok(raw_packet) => { + if raw_packet.id == play_disconnect_id { + let mut cur = &raw_packet.payload[..]; + let reason = cur + .get_component(&client_version) + .map(|c| c.to_pretty_console()) + .ok() + .or_else(|| { + let s = + String::from_utf8_lossy(&raw_packet.payload).to_string(); + if s.is_empty() { None } else { Some(s) } + }); + return TunnelExit::BackendDisconnected { reason }; + } + + if raw_packet.id == command_suggestions_clientbound_id { + let mut cur = &raw_packet.payload[..]; + if let Ok((id, start, length, mut backend_matches)) = + decode_backend_suggestions(&mut cur, &client_version) + && let Some(pending) = + pending_suggestions.lock().await.remove(&id.0) + { + for (cmd_name, tooltip) in pending.proxy_matches { + if !backend_matches + .iter() + .any(|bm| bm.suggestion.eq_ignore_ascii_case(&cmd_name)) + { + backend_matches.push(pumpkin_protocol::java::client::play::CommandSuggestion { + suggestion: cmd_name, + tooltip: tooltip.map(TextComponent::text), + }); + } + } + backend_matches.sort_by(|a, b| a.suggestion.cmp(&b.suggestion)); + + let merged = CCommandSuggestions::new( + id, + start, + length, + backend_matches.into_boxed_slice(), + ); + if let Ok(bytes) = merged.serialize_packet(&client_version) { + if client_tx.send(bytes).is_err() { + return TunnelExit::ClientDisconnected; + } + continue; + } + } + } + + if raw_packet.id == commands_packet_id { + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: username.to_string(), + action_tx: action_tx.clone(), + }, + config: proxy_config.clone(), + session_manager: session_manager.clone(), + }; + let permitted = + command_dispatcher.raw().get_all_permitted_commands(&source); + let permitted_refs: Vec<(&str, &str)> = permitted.into_iter().collect(); + + if let Some(modified_payload) = inject_proxy_commands( + &raw_packet.payload, + &client_version, + &permitted_refs, + ) { + let mut full_packet = + Vec::with_capacity(5 + modified_payload.len()); + let _ = VarInt(raw_packet.id).encode(&mut full_packet); + full_packet.extend_from_slice(&modified_payload); + + if client_tx.send(full_packet.into()).is_err() { + return TunnelExit::ClientDisconnected; + } + continue; + } + } + + let mut full_packet = Vec::with_capacity(5 + raw_packet.payload.len()); + let _ = VarInt(raw_packet.id).encode(&mut full_packet); + full_packet.extend_from_slice(&raw_packet.payload); + + if client_tx.send(full_packet.into()).is_err() { + return TunnelExit::ClientDisconnected; + } + } + Err(e) => { + debug!("Backend stream ended for '{}': {:?}", username, e); + return TunnelExit::BackendDisconnected { + reason: Some(e.to_string()), + }; + } + } + } + }; + + let action_loop = async { + while let Some(action) = action_rx.recv().await { + match action { + PlayerAction::Connect { server_name } => { + return TunnelExit::Connect { server_name }; + } + PlayerAction::Transfer { host, port } => { + return TunnelExit::Transfer { host, port }; + } + PlayerAction::Message(text_comp) => { + let chat_msg = CSystemChatMessage::new(&text_comp, false); + if let Ok(bytes) = chat_msg.serialize_packet(&client_version) { + let _ = client_tx.send(bytes); + } + } + } + } + TunnelExit::ClientDisconnected + }; + + let exit = tokio::select! { + exit = client_to_backend => exit, + exit = backend_to_client => exit, + exit = action_loop => exit, + exit = client_writer => exit, + }; + + Ok(exit) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pumpkin_protocol::java::client::play::CommandSuggestion; + + #[test] + fn test_decode_backend_suggestions() { + let version = JavaMinecraftVersion::V_1_21_4; + let pkt = CCommandSuggestions::new( + VarInt(42), + VarInt(1), + VarInt(3), + vec![ + CommandSuggestion { + suggestion: "say".to_string(), + tooltip: Some(TextComponent::text("broadcast message")), + }, + CommandSuggestion { + suggestion: "spawnpoint".to_string(), + tooltip: None, + }, + ] + .into_boxed_slice(), + ); + + let bytes = pkt + .serialize_packet(&version) + .expect("serialization failed"); + let mut slice = &bytes[..]; + let _pkt_id = slice.get_var_int().expect("packet id read failed"); + + let (id, start, length, matches) = + decode_backend_suggestions(&mut slice, &version).expect("decode failed"); + + assert_eq!(id.0, 42); + assert_eq!(start.0, 1); + assert_eq!(length.0, 3); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].suggestion, "say"); + assert!(matches[0].tooltip.is_some()); + assert_eq!(matches[1].suggestion, "spawnpoint"); + assert!(matches[1].tooltip.is_none()); + } + + #[test] + fn test_merge_pending_proxy_and_backend_suggestions() { + let version = JavaMinecraftVersion::V_1_21_4; + let mut backend_matches = vec![ + CommandSuggestion { + suggestion: "server".to_string(), // duplicate name with proxy + tooltip: None, + }, + CommandSuggestion { + suggestion: "status".to_string(), + tooltip: None, + }, + ]; + + let pending_proxy_matches = vec![ + ( + "server".to_string(), + Some("Switch or view backend server".to_string()), + ), + ( + "send".to_string(), + Some("Send players to a backend".to_string()), + ), + ]; + + // Deduplicate and merge proxy matches into backend matches + for (cmd_name, tooltip) in pending_proxy_matches { + if !backend_matches + .iter() + .any(|bm| bm.suggestion.eq_ignore_ascii_case(&cmd_name)) + { + backend_matches.push(CommandSuggestion { + suggestion: cmd_name, + tooltip: tooltip.map(TextComponent::text), + }); + } + } + backend_matches.sort_by(|a, b| a.suggestion.cmp(&b.suggestion)); + + assert_eq!(backend_matches.len(), 3); + assert_eq!(backend_matches[0].suggestion, "send"); + assert_eq!(backend_matches[1].suggestion, "server"); + assert_eq!(backend_matches[2].suggestion, "status"); + + let merged = CCommandSuggestions::new( + VarInt(10), + VarInt(1), + VarInt(1), + backend_matches.into_boxed_slice(), + ); + let serialized = merged.serialize_packet(&version); + assert!(serialized.is_ok()); + } + + #[test] + fn test_inject_proxy_commands() { + let version = JavaMinecraftVersion::V_1_21_4; + let root_node = ProtoNode { + children: vec![VarInt(1)].into_boxed_slice(), + node_type: ProtoNodeType::Root, + }; + let say_node = ProtoNode { + children: Vec::new().into_boxed_slice(), + node_type: ProtoNodeType::Literal { + name: "say", + is_executable: true, + redirect_target: None, + restricted: false, + }, + }; + let pkt = CCommands::new(vec![root_node, say_node].into_boxed_slice(), VarInt(0)); + let bytes = pkt.serialize_packet(&version).expect("serialize commands"); + let mut slice = &bytes[..]; + let _pkt_id = slice.get_var_int().expect("read pkt id"); + + let permitted = [("server", "desc"), ("list", "desc")]; + let injected = inject_proxy_commands(slice, &version, &permitted) + .expect("inject_proxy_commands failed"); + + let mut cur = &injected[..]; + let total_nodes = cur.get_var_int().unwrap(); + // Original: 2 (root, say). Added: server (literal + args: 2), list (literal: 1). Total: 5. + assert_eq!(total_nodes.0, 5); + + let root_flags = cur.get_u8().unwrap(); + assert_eq!(root_flags, 0); // Root node flag + + let root_children_count = cur.get_var_int().unwrap(); + // Original 1 (say) + 2 new root commands (server, list) = 3 + assert_eq!(root_children_count.0, 3); + + let c1 = cur.get_var_int().unwrap(); + assert_eq!(c1.0, 1); // say + let c2 = cur.get_var_int().unwrap(); + assert_eq!(c2.0, 2); // server + let c3 = cur.get_var_int().unwrap(); + assert_eq!(c3.0, 4); // list (index 2 is server literal, 3 is server args, 4 is list literal) + + // The packet must end with VarInt(0) for root_node_index + assert_eq!(injected.last(), Some(&0)); + } +} diff --git a/src/backend/forwarding.rs b/src/backend/forwarding.rs new file mode 100644 index 0000000..0445cf1 --- /dev/null +++ b/src/backend/forwarding.rs @@ -0,0 +1,261 @@ +use ed25519_dalek::{Signer, SigningKey}; +use hmac::{Hmac, KeyInit, Mac}; +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::ser::{NetworkWriteExt, WritingError}; +use sha2::{Digest, Sha256}; +use std::net::IpAddr; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +use crate::auth::AuthenticatedPlayer; + +type HmacSha256 = Hmac; + +pub const VELOCITY_PLAYER_INFO_CHANNEL: &str = "velocity:player_info"; +pub const VELOCITY_FORWARDING_VERSION: i32 = 4; + +pub const VINE_PLAYER_INFO_CHANNEL: &str = "vine:player_info"; +pub const VINE_FORWARDING_VERSION: i32 = 1; + +#[derive(Error, Debug)] +pub enum ForwardingError { + #[error("Velocity secret is required for Velocity modern forwarding")] + MissingVelocitySecret, + #[error("Vine secret is required for Vine modern forwarding")] + MissingVineSecret, + #[error("HMAC initialization failed: {0}")] + HmacError(String), + #[error("Serialization IO error: {0}")] + IoError(#[from] std::io::Error), + #[error("Writing error: {0}")] + WritingError(#[from] WritingError), +} + +pub struct ForwardingHelper; + +impl ForwardingHelper { + /// Builds the BungeeCord virtual host handshake string for Bukkit / Spigot / PaperMC: + /// Format: `"{host}\0{client_ip}\0{uuid_without_hyphens}\0{json_properties}"` + pub fn build_bungeecord_host( + original_host: &str, + client_ip: &IpAddr, + player: &AuthenticatedPlayer, + ) -> String { + let uuid_str = player.id.simple().to_string(); + + let json_properties = + serde_json::to_string(&player.properties).unwrap_or_else(|_| "[]".to_string()); + + format!( + "{}\0{}\0{}\0{}", + original_host, client_ip, uuid_str, json_properties + ) + } + + /// Builds the Velocity player info plugin response data for PaperMC / Purpur / Pumpkin: + /// Contains: + /// - 32-byte HMAC-SHA256 signature + /// - Forwarding version (VarInt 4) + /// - Client IP address string + /// - Player UUID (16 raw bytes) + /// - Player username string + /// - Properties list (name, value, optional signature) + pub fn build_velocity_payload( + secret: &str, + client_ip: &IpAddr, + player: &AuthenticatedPlayer, + ) -> Result, ForwardingError> { + if secret.is_empty() { + return Err(ForwardingError::MissingVelocitySecret); + } + + let mut payload = Vec::new(); + + VarInt(VELOCITY_FORWARDING_VERSION).encode(&mut payload)?; + + let ip_str = client_ip.to_string(); + payload.write_string(&ip_str)?; + + payload.extend_from_slice(player.id.as_bytes()); + + payload.write_string(&player.username)?; + + VarInt(player.properties.len() as i32).encode(&mut payload)?; + for prop in &player.properties { + payload.write_string(&prop.name)?; + payload.write_string(&prop.value)?; + if let Some(sig) = &prop.signature { + payload.write_bool(true)?; + payload.write_string(sig)?; + } else { + payload.write_bool(false)?; + } + } + + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| ForwardingError::HmacError(e.to_string()))?; + mac.update(&payload); + let signature = mac.finalize().into_bytes(); + + let mut full_data = Vec::with_capacity(signature.len() + payload.len()); + full_data.extend_from_slice(&signature); + full_data.extend_from_slice(&payload); + + Ok(full_data) + } + + /// Derives the 32-byte Ed25519 signing key from secret. + /// If secret is 64 hex chars, decodes hex directly to 32-byte seed. + /// Otherwise hashes the secret with SHA-256 to produce a 32-byte seed. + pub fn get_vine_signing_key(secret: &str) -> Result { + let secret = secret.trim(); + if secret.is_empty() { + return Err(ForwardingError::MissingVineSecret); + } + let seed: [u8; 32] = if secret.len() == 64 && hex::decode(secret).is_ok() { + let mut s = [0u8; 32]; + let decoded = hex::decode(secret).map_err(|_| ForwardingError::MissingVineSecret)?; + s.copy_from_slice(&decoded); + s + } else { + Sha256::digest(secret.as_bytes()).into() + }; + Ok(SigningKey::from_bytes(&seed)) + } + + /// Returns the 64-character hex Ed25519 public key corresponding to the secret. + /// This public key can be configured directly on Pumpkin backend servers. + pub fn get_vine_public_key_hex(secret: &str) -> Result { + let signing_key = Self::get_vine_signing_key(secret)?; + Ok(hex::encode(signing_key.verifying_key().to_bytes())) + } + + /// Builds the Vine player info plugin response data for Pumpkin: + /// Contains: + /// - 64-byte Ed25519 signature + /// - Forwarding version (VarInt 1) + /// - Timestamp (u64 big-endian seconds since UNIX epoch) + /// - Challenge nonce ([u8; 16] echoed from backend request) + /// - Client IP address string + /// - Player UUID (16 raw bytes) + /// - Player username string + /// - Properties list (name, value, optional signature) + pub fn build_vine_payload( + secret: &str, + client_ip: &IpAddr, + player: &AuthenticatedPlayer, + challenge: &[u8; 16], + ) -> Result, ForwardingError> { + let signing_key = Self::get_vine_signing_key(secret)?; + + let mut payload = Vec::new(); + VarInt(VINE_FORWARDING_VERSION).encode(&mut payload)?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + payload.extend_from_slice(&now.to_be_bytes()); + payload.extend_from_slice(challenge); + + let ip_str = client_ip.to_string(); + payload.write_string(&ip_str)?; + + payload.extend_from_slice(player.id.as_bytes()); + payload.write_string(&player.username)?; + + VarInt(player.properties.len() as i32).encode(&mut payload)?; + for prop in &player.properties { + payload.write_string(&prop.name)?; + payload.write_string(&prop.value)?; + if let Some(sig) = &prop.signature { + payload.write_bool(true)?; + payload.write_string(sig)?; + } else { + payload.write_bool(false)?; + } + } + + let signature = signing_key.sign(&payload); + + let mut full_data = Vec::with_capacity(64 + payload.len()); + full_data.extend_from_slice(&signature.to_bytes()); + full_data.extend_from_slice(&payload); + + Ok(full_data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signature, Verifier}; + use uuid::Uuid; + + #[test] + fn test_bungeecord_host_formatting() { + let player = AuthenticatedPlayer { + id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(), + username: "Steve".to_string(), + properties: vec![], + }; + let ip: IpAddr = "192.0.2.1".parse().unwrap(); + let host = ForwardingHelper::build_bungeecord_host("play.example.com", &ip, &player); + + let parts: Vec<&str> = host.split('\0').collect(); + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "play.example.com"); + assert_eq!(parts[1], "192.0.2.1"); + assert_eq!(parts[2], "d8f4a1e00f1b4c3a9f2e1a2b3c4d5e6f"); + assert_eq!(parts[3], "[]"); + } + + #[test] + fn test_velocity_payload_generation() { + let player = AuthenticatedPlayer { + id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(), + username: "Alex".to_string(), + properties: vec![], + }; + let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let secret = "test-secret-key"; + + let result = ForwardingHelper::build_velocity_payload(secret, &ip, &player); + assert!(result.is_ok()); + let data = result.unwrap(); + + assert!(data.len() > 32); + } + + #[test] + fn test_vine_payload_generation_and_verification() { + let player = AuthenticatedPlayer { + id: Uuid::parse_str("d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f").unwrap(), + username: "Alex".to_string(), + properties: vec![], + }; + let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let secret = "test-vine-secret-phrase"; + let challenge = [7u8; 16]; + + let result = ForwardingHelper::build_vine_payload(secret, &ip, &player, &challenge); + assert!(result.is_ok()); + let data = result.unwrap(); + + // 64-byte signature + payload + assert!(data.len() > 64); + let (sig_bytes, payload_bytes) = data.split_at(64); + + let signing_key = ForwardingHelper::get_vine_signing_key(secret).unwrap(); + let verifying_key = signing_key.verifying_key(); + + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(sig_bytes); + let signature = Signature::from_bytes(&sig_arr); + + assert!(verifying_key.verify(payload_bytes, &signature).is_ok()); + + let pub_hex = ForwardingHelper::get_vine_public_key_hex(secret).unwrap(); + assert_eq!(pub_hex.len(), 64); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..889cccb --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,43 @@ +pub mod bridge; +pub mod forwarding; + +pub use bridge::{BackendBridge, BridgeError}; +pub use forwarding::{ForwardingError, ForwardingHelper}; + +use crate::config::{BackendConfig, RoutingConfig}; +use std::collections::HashMap; + +pub struct Router { + routing: RoutingConfig, + servers: HashMap, +} + +impl Router { + pub fn new(routing: RoutingConfig, servers: HashMap) -> Self { + Self { routing, servers } + } + + /// Resolves which backend server to connect to based on virtual host or default + pub fn resolve_server(&self, virtual_host: &str) -> Option<(String, BackendConfig)> { + if let Some(target_name) = self.routing.forced_hosts.get(virtual_host) + && let Some(cfg) = self.servers.get(target_name) + { + return Some((target_name.clone(), cfg.clone())); + } + + if let Some(cfg) = self.servers.get(&self.routing.default_server) { + return Some((self.routing.default_server.clone(), cfg.clone())); + } + + if let Some(fallback_name) = &self.routing.fallback_server + && let Some(cfg) = self.servers.get(fallback_name) + { + return Some((fallback_name.clone(), cfg.clone())); + } + + self.servers + .iter() + .next() + .map(|(name, cfg)| (name.clone(), cfg.clone())) + } +} diff --git a/src/bedrock/config.rs b/src/bedrock/config.rs new file mode 100644 index 0000000..c1de9b9 --- /dev/null +++ b/src/bedrock/config.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration options for Bedrock Edition support. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct BedrockConfig { + /// Whether Bedrock Edition listener and status response are enabled + pub enabled: bool, + /// UDP address and port for the Bedrock listener (default Minecraft Bedrock port is 19132) + pub bind_address: String, + /// Primary MOTD line shown in Bedrock server lists + pub motd: String, + /// Secondary MOTD line / subtitle shown in Bedrock server lists + pub sub_motd: String, + /// Protocol version advertised (2169 corresponds to Bedrock 1.26.45) + pub protocol_version: u32, + /// Bedrock version string advertised (e.g. "1.26.45") + pub version_name: String, + /// Level / world name displayed in Bedrock client ping + pub level_name: String, + /// Game mode displayed in Bedrock client ping ("Survival", "Creative", etc.) + pub game_mode: String, + /// Game mode ID (0 = Survival, 1 = Creative, 2 = Adventure, 3 = Spectator) + pub game_mode_id: u32, + /// Maximum Bedrock players displayed in ping (0 to inherit global server max_players) + pub max_players: u32, + /// Server GUID for RakNet (0 automatically generates a random GUID on startup) + pub server_guid: u64, +} + +impl Default for BedrockConfig { + fn default() -> Self { + Self { + enabled: true, + bind_address: "0.0.0.0:19132".to_string(), + motd: "§a§lVine §7» §fHigh-Performance Minecraft Proxy".to_string(), + sub_motd: "Bedrock Support • 1.26.x".to_string(), + protocol_version: 2169, + version_name: "1.26.45".to_string(), + level_name: "Vine Proxy".to_string(), + game_mode: "Survival".to_string(), + game_mode_id: 0, + max_players: 100, + server_guid: 0, + } + } +} diff --git a/src/bedrock/listener.rs b/src/bedrock/listener.rs new file mode 100644 index 0000000..38f1c7d --- /dev/null +++ b/src/bedrock/listener.rs @@ -0,0 +1,61 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; + +use tokio::net::UdpSocket; +use tokio::sync::broadcast; +use tracing::{debug, error, info, trace}; + +use crate::bedrock::config::BedrockConfig; +use crate::bedrock::status::BedrockStatusHandler; + +/// Runs the Bedrock UDP listener task. +pub async fn run_bedrock_listener( + config: BedrockConfig, + online_players: Arc, + mut shutdown_rx: broadcast::Receiver<()>, +) -> Result<(), Box> { + let bind_addr: SocketAddr = config.bind_address.parse()?; + let socket = UdpSocket::bind(bind_addr).await?; + let local_addr = socket.local_addr()?; + + info!("====================================================="); + info!(" Vine Bedrock Listener is active on UDP {}", local_addr); + info!( + " Bedrock Version: {} (Protocol {})", + config.version_name, config.protocol_version + ); + info!("====================================================="); + + let status_handler = BedrockStatusHandler::new(config, online_players, local_addr.port()); + let mut buffer = [0u8; 2048]; + + loop { + tokio::select! { + result = socket.recv_from(&mut buffer) => { + match result { + Ok((length, peer_addr)) => { + let packet = &buffer[..length]; + if let Some(pong_bytes) = status_handler.handle_packet(packet) { + trace!("[{}] Received Bedrock status ping, sending pong ({} bytes)", peer_addr, pong_bytes.len()); + if let Err(e) = socket.send_to(&pong_bytes, peer_addr).await { + debug!("[{}] Failed to send Bedrock pong: {}", peer_addr, e); + } + } else { + trace!("[{}] Received unrecognized Bedrock datagram ({} bytes)", peer_addr, length); + } + } + Err(e) => { + error!("Bedrock UDP receive error: {}", e); + } + } + } + _ = shutdown_rx.recv() => { + info!("Bedrock listener received shutdown signal, stopping..."); + break; + } + } + } + + Ok(()) +} diff --git a/src/bedrock/mod.rs b/src/bedrock/mod.rs new file mode 100644 index 0000000..ceaa7f7 --- /dev/null +++ b/src/bedrock/mod.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod listener; +pub mod status; + +pub use config::BedrockConfig; +pub use listener::run_bedrock_listener; +pub use status::{ + BedrockStatusHandler, RAKNET_UNCONNECTED_PING_ID, RAKNET_UNCONNECTED_PING_OPEN_ID, + RAKNET_UNCONNECTED_PONG_ID, +}; diff --git a/src/bedrock/status.rs b/src/bedrock/status.rs new file mode 100644 index 0000000..f1b59ae --- /dev/null +++ b/src/bedrock/status.rs @@ -0,0 +1,160 @@ +use std::io::Cursor; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use pumpkin_protocol::bedrock::status::{ + CUnconnectedPong, OFFLINE_MESSAGE_MAGIC, SUnconnectedPing, SUnconnectedPingOpenConnections, +}; +use pumpkin_protocol::serial::{PacketRead, PacketWrite}; + +use crate::bedrock::config::BedrockConfig; + +pub const RAKNET_UNCONNECTED_PING_ID: u8 = 0x01; +pub const RAKNET_UNCONNECTED_PING_OPEN_ID: u8 = 0x02; +pub const RAKNET_UNCONNECTED_PONG_ID: u8 = 0x1c; + +/// Handler for Bedrock RakNet status pings. +pub struct BedrockStatusHandler { + config: BedrockConfig, + online_players: Arc, + server_guid: u64, + ipv4_port: u16, + ipv6_port: u16, +} + +impl BedrockStatusHandler { + pub fn new(config: BedrockConfig, online_players: Arc, ipv4_port: u16) -> Self { + let server_guid = if config.server_guid != 0 { + config.server_guid + } else { + rand::random() + }; + let ipv6_port = ipv4_port.saturating_add(1); + + Self { + config, + online_players, + server_guid, + ipv4_port, + ipv6_port, + } + } + + /// Formats the official Bedrock MCPE advertisement string + pub fn build_advertisement_string(&self) -> String { + let online = self.online_players.load(Ordering::Relaxed) as i32; + let motd = format!("{}\n{}", self.config.motd, self.config.sub_motd); + + format!( + "MCPE;{};{};{};{};{};{};{};{};{};{};{};0;", + motd, + self.config.protocol_version, + self.config.version_name, + online, + self.config.max_players, + self.server_guid, + self.config.level_name, + self.config.game_mode, + self.config.game_mode_id, + self.ipv4_port, + self.ipv6_port, + ) + } + + /// Inspects an incoming UDP datagram and, if it is an unconnected ping, builds the pong response. + pub fn handle_packet(&self, data: &[u8]) -> Option> { + let (&packet_id, payload) = data.split_first()?; + + let (time, magic) = match packet_id { + RAKNET_UNCONNECTED_PING_ID => { + let mut cursor = Cursor::new(payload); + let ping = SUnconnectedPing::read(&mut cursor).ok()?; + (ping.time, ping.magic) + } + RAKNET_UNCONNECTED_PING_OPEN_ID => { + let mut cursor = Cursor::new(payload); + let ping = SUnconnectedPingOpenConnections::read(&mut cursor).ok()?; + (ping.time, ping.magic) + } + _ => return None, + }; + + if magic != OFFLINE_MESSAGE_MAGIC { + return None; + } + + let ad = self.build_advertisement_string(); + let pong = CUnconnectedPong::new(time, self.server_guid, ad); + + let mut response = vec![RAKNET_UNCONNECTED_PONG_ID]; + pong.write(&mut response).ok()?; + Some(response) + } + + #[must_use] + pub const fn server_guid(&self) -> u64 { + self.server_guid + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_advertisement_string() { + let config = BedrockConfig { + enabled: true, + bind_address: "0.0.0.0:19132".to_string(), + motd: "Vine Server".to_string(), + sub_motd: "Bedrock Proxy".to_string(), + protocol_version: 2169, + version_name: "1.26.45".to_string(), + level_name: "Lobby".to_string(), + game_mode: "Survival".to_string(), + game_mode_id: 0, + max_players: 50, + server_guid: 12345, + }; + + let online = Arc::new(AtomicUsize::new(3)); + let handler = BedrockStatusHandler::new(config, online, 19132); + let ad = handler.build_advertisement_string(); + + assert!(ad.starts_with("MCPE;Vine Server\nBedrock Proxy;2169;1.26.45;3;50;12345;Lobby;Survival;0;19132;19133;0;")); + } + + #[test] + fn test_handle_unconnected_ping() { + let config = BedrockConfig::default(); + let online = Arc::new(AtomicUsize::new(0)); + let handler = BedrockStatusHandler::new(config, online, 19132); + + let mut packet = vec![RAKNET_UNCONNECTED_PING_ID]; + packet.extend_from_slice(&1000u64.to_be_bytes()); + packet.extend_from_slice(&OFFLINE_MESSAGE_MAGIC); + packet.extend_from_slice(&9999u64.to_be_bytes()); + + let response = handler.handle_packet(&packet); + assert!(response.is_some()); + let res_bytes = response.unwrap(); + assert_eq!(res_bytes[0], RAKNET_UNCONNECTED_PONG_ID); + assert_eq!(&res_bytes[1..9], &1000u64.to_be_bytes()); + assert_eq!(&res_bytes[17..33], &OFFLINE_MESSAGE_MAGIC); + } + + #[test] + fn test_ignore_invalid_magic() { + let config = BedrockConfig::default(); + let online = Arc::new(AtomicUsize::new(0)); + let handler = BedrockStatusHandler::new(config, online, 19132); + + let mut packet = vec![RAKNET_UNCONNECTED_PING_ID]; + packet.extend_from_slice(&1000u64.to_be_bytes()); + packet.extend_from_slice(&[0u8; 16]); + packet.extend_from_slice(&9999u64.to_be_bytes()); + + let response = handler.handle_packet(&packet); + assert!(response.is_none()); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..62d24de --- /dev/null +++ b/src/client.rs @@ -0,0 +1,443 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::time::Duration; + +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::java::client::login::{ + CEncryptionRequest, CLoginDisconnect, CSetCompression, +}; +use pumpkin_protocol::java::packet_decoder::TCPNetworkDecoder; +use pumpkin_protocol::java::packet_encoder::TCPNetworkEncoder; +use pumpkin_protocol::java::server::handshake::SHandShake; +use pumpkin_protocol::java::server::login::{SEncryptionResponse, SLoginStart}; +use pumpkin_protocol::java::server::status::SStatusPingRequest; +use pumpkin_protocol::{ClientPacket, ConnectionState, ServerPacket}; +use pumpkin_util::version::JavaMinecraftVersion; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use crate::auth::{AuthenticatedPlayer, Authenticator, KeyStore}; +use crate::backend::{BackendBridge, Router}; +use crate::command::CommandDispatcher; +use crate::config::Config; +use crate::network::PrefixedRead; +use crate::security::Sanitizer; +use crate::session::SessionManager; +use crate::status::StatusHandler; + +#[derive(Error, Debug)] +pub enum ClientError { + #[error("Client IO error: {0}")] + IoError(#[from] std::io::Error), + #[error("Protocol decode error: {0}")] + DecodeError(String), + #[error("Protocol encode error: {0}")] + EncodeError(String), + #[error("Authentication failed: {0}")] + AuthError(String), + #[error("Bridge error: {0}")] + BridgeError(#[from] crate::backend::BridgeError), + #[error("Connection timed out")] + Timeout, +} + +pub struct ClientHandler { + peer_addr: SocketAddr, + config: Arc, + keystore: Arc, + authenticator: Arc, + status_handler: Arc, + router: Arc, + online_players: Arc, + session_manager: Arc, + command_dispatcher: Arc, +} + +impl ClientHandler { + #[allow(clippy::too_many_arguments)] + pub fn new( + peer_addr: SocketAddr, + config: Arc, + keystore: Arc, + authenticator: Arc, + status_handler: Arc, + router: Arc, + online_players: Arc, + session_manager: Arc, + command_dispatcher: Arc, + ) -> Self { + Self { + peer_addr, + config, + keystore, + authenticator, + status_handler, + router, + online_players, + session_manager, + command_dispatcher, + } + } + + pub async fn handle( + self, + stream: TcpStream, + leftover_bytes: Vec, + ) -> Result<(), ClientError> { + let (read_half, write_half) = stream.into_split(); + let reader = PrefixedRead::new(read_half, leftover_bytes); + let mut decoder = TCPNetworkDecoder::new(reader); + let mut encoder = TCPNetworkEncoder::new(write_half); + + let handshake_timeout = + Duration::from_secs(self.config.network.client_handshake_timeout_secs); + let raw_handshake = timeout(handshake_timeout, decoder.get_raw_packet()) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let client_version_guess = JavaMinecraftVersion::V_26_2; + let mut handshake_bytes = &raw_handshake.payload[..]; + let handshake = SHandShake::read(&mut handshake_bytes, &client_version_guess) + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let client_protocol = handshake.protocol_version.0 as u32; + let client_version = JavaMinecraftVersion::from_protocol(client_protocol); + let raw_host = &handshake.server_address; + let clean_host = Sanitizer::sanitize_host(raw_host); + + debug!( + "[{}] Handshake: protocol={}, resolved_version={:?}, host='{}', next_state={:?}", + self.peer_addr, client_protocol, client_version, clean_host, handshake.next_state + ); + + match handshake.next_state { + ConnectionState::Status => { + if self.config.network.log_pings { + info!( + "[{}] Status ping: protocol={}, version={:?}, host='{}'", + self.peer_addr, client_protocol, client_version, clean_host + ); + } + self.handle_status(&mut decoder, &mut encoder, client_version, &clean_host) + .await + } + ConnectionState::Login => { + self.handle_login( + decoder, + encoder, + client_version, + clean_host, + self.peer_addr.ip(), + ) + .await + } + other => { + debug!( + "[{}] Unsupported handshake state: {:?}", + self.peer_addr, other + ); + Ok(()) + } + } + } + + async fn handle_status( + &self, + decoder: &mut TCPNetworkDecoder>, + encoder: &mut TCPNetworkEncoder, + client_version: JavaMinecraftVersion, + virtual_host: &str, + ) -> Result<(), ClientError> { + let status_timeout = Duration::from_secs(self.config.network.client_status_timeout_secs); + + let raw_status = timeout(status_timeout, decoder.get_raw_packet()) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + if raw_status.id == 0x00 { + let status_pkt = self + .status_handler + .create_status_packet(client_version, Some(virtual_host)); + let status_bytes = status_pkt + .serialize_packet(&client_version) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .write_packet(status_bytes) + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .flush() + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + } + + if let Ok(Ok(raw_ping)) = timeout(status_timeout, decoder.get_raw_packet()).await + && raw_ping.id == 0x01 + { + let mut ping_bytes = &raw_ping.payload[..]; + if let Ok(ping) = SStatusPingRequest::read(&mut ping_bytes, &client_version) { + let pong_pkt = StatusHandler::create_pong_packet(ping.payload); + let pong_bytes = pong_pkt + .serialize_packet(&client_version) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + let _ = encoder.write_packet(pong_bytes).await; + let _ = encoder.flush().await; + } + } + + Ok(()) + } + + async fn handle_login( + &self, + mut decoder: TCPNetworkDecoder>, + mut encoder: TCPNetworkEncoder, + client_version: JavaMinecraftVersion, + virtual_host: String, + client_ip: IpAddr, + ) -> Result<(), ClientError> { + let login_timeout = Duration::from_secs(self.config.network.client_login_timeout_secs); + + let raw_login_start = timeout(login_timeout, decoder.get_raw_packet()) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let mut login_start_bytes = &raw_login_start.payload[..]; + let login_start = SLoginStart::read(&mut login_start_bytes, &client_version) + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let username = login_start.name.to_string(); + + if self.config.security.validate_usernames && !Sanitizer::is_valid_username(&username) { + warn!( + "[{}] Rejecting invalid username '{}' (failed sanitization)", + self.peer_addr, username + ); + Self::kick( + &mut encoder, + client_version, + "Invalid username characters or length (must be 2-16 alphanumeric or _)", + ) + .await?; + return Ok(()); + } + + info!( + "[{}] Player '{}' initiating login (version: {:?})", + self.peer_addr, username, client_version + ); + + let authenticated_player = if self.config.server.online_mode { + let verify_token: [u8; 4] = rand::random(); + let encryption_request = + CEncryptionRequest::new("", self.keystore.public_key_der(), &verify_token, true); + + let enc_req_bytes = encryption_request + .serialize_packet(&client_version) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .write_packet(enc_req_bytes) + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .flush() + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + + let raw_enc_resp = timeout(login_timeout, decoder.get_raw_packet()) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let mut enc_resp_bytes = &raw_enc_resp.payload[..]; + let enc_resp = SEncryptionResponse::read(&mut enc_resp_bytes, &client_version) + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + + let decrypted_token = self + .keystore + .decrypt(&enc_resp.verify_token) + .map_err(|e| ClientError::AuthError(e.to_string()))?; + + if decrypted_token != verify_token { + warn!( + "[{}] Verify token mismatch for '{}'", + self.peer_addr, username + ); + Self::kick( + &mut encoder, + client_version, + "Failed to verify encryption token", + ) + .await?; + return Err(ClientError::AuthError("Token mismatch".to_string())); + } + + let shared_secret = self + .keystore + .decrypt(&enc_resp.shared_secret) + .map_err(|e| ClientError::AuthError(e.to_string()))?; + + if shared_secret.len() != 16 { + warn!( + "[{}] Shared secret has invalid length ({})", + self.peer_addr, + shared_secret.len() + ); + Self::kick( + &mut encoder, + client_version, + "Invalid encryption key length", + ) + .await?; + return Err(ClientError::AuthError("Invalid key length".to_string())); + } + + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&shared_secret); + + let server_hash = self.keystore.auth_digest("", &shared_secret); + + let auth_player = match self + .authenticator + .authenticate( + &username, + &server_hash, + &client_ip, + self.config.server.prevent_proxy_connections, + ) + .await + { + Ok(player) => player, + Err(e) => { + warn!( + "[{}] Mojang authentication failed for '{}': {}", + self.peer_addr, username, e + ); + Self::kick( + &mut encoder, + client_version, + "Failed to verify username with Mojang session servers! Please try again later.", + ) + .await?; + return Err(ClientError::AuthError(e.to_string())); + } + }; + + decoder + .set_encryption(&aes_key) + .map_err(|e| ClientError::DecodeError(e.to_string()))?; + encoder + .set_encryption(&aes_key) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + + debug!( + "[{}] Stream encryption enabled for player '{}' ({})", + self.peer_addr, auth_player.username, auth_player.id + ); + + auth_player + } else { + AuthenticatedPlayer::offline(&username) + }; + + let comp_threshold = self.config.server.compression_threshold; + if comp_threshold > 0 { + let set_comp = CSetCompression::new(VarInt(comp_threshold)); + let comp_bytes = set_comp + .serialize_packet(&client_version) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .write_packet(comp_bytes) + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + encoder + .flush() + .await + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + + decoder.set_compression(comp_threshold as usize); + encoder.set_compression(( + comp_threshold as usize, + self.config.server.compression_level as u32, + )); + } + + let (server_name, backend_cfg) = match self.router.resolve_server(&virtual_host) { + Some(res) => res, + None => { + warn!( + "[{}] No backend server found for host '{}'", + self.peer_addr, virtual_host + ); + Self::kick( + &mut encoder, + client_version, + "No default or matching backend Minecraft server is configured on the proxy.", + ) + .await?; + return Ok(()); + } + }; + + let fallback_server = if self.config.routing.try_fallback_on_connect_failure { + self.config + .routing + .fallback_server + .as_ref() + .and_then(|fb_name| { + self.config + .servers + .get(fb_name) + .map(|cfg| (fb_name.clone(), cfg.clone())) + }) + } else { + None + }; + + let bridge = BackendBridge::new( + server_name, + backend_cfg, + fallback_server, + client_ip, + client_version, + virtual_host, + authenticated_player, + self.config.security.max_packets_per_sec, + self.online_players.clone(), + Arc::new(self.config.network.clone()), + self.config.server.compression_level, + self.session_manager.clone(), + self.command_dispatcher.clone(), + self.config.clone(), + ); + + bridge.run(decoder, encoder).await?; + Ok(()) + } + + /// Helper to send formatted CLoginDisconnect packet to client + async fn kick( + encoder: &mut TCPNetworkEncoder, + version: JavaMinecraftVersion, + reason: &str, + ) -> Result<(), ClientError> { + let json_reason = format!( + r#"{{"text":"[Vine Proxy] {}","color":"red"}}"#, + reason.replace('"', "\\\"") + ); + let disconnect = CLoginDisconnect::new(json_reason); + let bytes = disconnect + .serialize_packet(&version) + .map_err(|e| ClientError::EncodeError(e.to_string()))?; + let _ = encoder.write_packet(bytes).await; + let _ = encoder.flush().await; + Ok(()) + } +} diff --git a/src/command/glist.rs b/src/command/glist.rs new file mode 100644 index 0000000..10dd84c --- /dev/null +++ b/src/command/glist.rs @@ -0,0 +1,194 @@ +use std::collections::BTreeMap; + +use pumpkin_command::CommandSource; +use pumpkin_command::argument_builder::{ArgumentBuilder, command}; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher; +use pumpkin_command::node::{CommandExecutor, CommandExecutorResult}; +use pumpkin_util::text::TextComponent; + +use super::{ProxyCommandSource, command_error}; + +/// Command to list players grouped by server across the entire proxy +pub struct GlistCommandExecutor; + +impl CommandExecutor for GlistCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + if !context.source.has_permission("vine.command.glist") { + return Err(command_error( + "You do not have permission to execute this command (required: vine.command.glist).", + )); + } + + let sessions = context.source.session_manager.list_sessions(); + let total_players = sessions.len(); + + let mut server_players: BTreeMap> = BTreeMap::new(); + for server_name in context.source.config.servers.keys() { + server_players.entry(server_name.clone()).or_default(); + } + + for session in sessions { + server_players + .entry(session.current_server.clone()) + .or_default() + .push(session.username); + } + + let mut lines = Vec::new(); + for (server_name, mut players) in server_players { + players.sort_unstable(); + if players.is_empty() { + lines.push(format!("[{}] (0):", server_name)); + } else { + lines.push(format!( + "[{}] ({}): {}", + server_name, + players.len(), + players.join(", ") + )); + } + } + + lines.push(format!("Total players online: {}", total_players)); + context + .source + .send_message(TextComponent::text(lines.join("\n"))); + Ok(total_players as i32) + } +} + +use pumpkin_command::Requirement; + +pub fn register(dispatcher: &mut RawCommandDispatcher) { + dispatcher.register( + command( + "glist", + "Lists online players grouped by server across the proxy", + ) + .requires(Requirement::from("vine.command.glist")) + .executes(GlistCommandExecutor), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::mpsc; + use uuid::Uuid; + + use crate::command::{CommandDispatcher, CommandSender}; + use crate::config::{BackendConfig, Config, ForwardingMode}; + use crate::session::{PlayerAction, PlayerSession, SessionManager}; + + #[tokio::test] + async fn test_glist_empty() { + let mut config = Config::default(); + config.servers.insert( + "lobby".to_string(), + BackendConfig { + address: "127.0.0.1:25565".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + + let session_manager = Arc::new(SessionManager::new()); + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: "TestPlayer".to_string(), + action_tx, + }, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let res = dispatcher.execute_input("glist", &source); + assert!(res.is_ok()); + + let msg = action_rx.recv().await.unwrap(); + match msg { + PlayerAction::Message(text) => { + let s = text.to_pretty_console(); + assert!(s.contains("[lobby] (0):")); + assert!(s.contains("Total players online: 0")); + } + _ => panic!("Expected Message action"), + } + } + + #[tokio::test] + async fn test_glist_with_players() { + let mut config = Config::default(); + config.servers.insert( + "lobby".to_string(), + BackendConfig { + address: "127.0.0.1:25565".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + config.servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25566".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + + let session_manager = Arc::new(SessionManager::new()); + let (tx1, _) = mpsc::unbounded_channel(); + let (tx2, _) = mpsc::unbounded_channel(); + + session_manager.register(PlayerSession { + username: "Steve".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: tx1, + }); + session_manager.register(PlayerSession { + username: "Alex".to_string(), + uuid: Uuid::new_v4(), + current_server: "survival".to_string(), + action_tx: tx2, + }); + + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: "Admin".to_string(), + action_tx, + }, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let res = dispatcher.execute_input("glist", &source); + assert!(res.is_ok()); + + let msg = action_rx.recv().await.unwrap(); + match msg { + PlayerAction::Message(text) => { + let s = text.to_pretty_console(); + assert!(s.contains("[lobby] (1): Steve")); + assert!(s.contains("[survival] (1): Alex")); + assert!(s.contains("Total players online: 2")); + } + _ => panic!("Expected Message action"), + } + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..45bfa94 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,431 @@ +pub mod glist; +pub mod send; +pub mod server; + +use std::sync::Arc; +use tokio::sync::mpsc; +use tracing::info; + +use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command}; +use pumpkin_command::argument_types::core::string::StringArgumentType; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::context::string_range::StringRange; +pub use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher; +pub use pumpkin_command::errors::command_syntax_error::CommandSyntaxError; +pub use pumpkin_command::errors::error_types::LiteralCommandErrorType; +pub use pumpkin_command::node::{CommandExecutor, CommandExecutorResult}; +pub use pumpkin_command::source::CommandSource; +use pumpkin_command::suggestion::Suggestion; +use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; +pub use pumpkin_command::suggestion::suggestions::Suggestions; +use pumpkin_command::suggestion::suggestions::SuggestionsBuilder; +use pumpkin_protocol::java::client::play::CommandSuggestion; +use pumpkin_util::text::TextComponent; + +use crate::config::Config; +use crate::session::{PlayerAction, SessionManager}; + +pub static PROXY_COMMAND_ERROR: LiteralCommandErrorType = + LiteralCommandErrorType::new("Proxy command execution failed"); + +pub fn command_error(msg: impl Into) -> CommandSyntaxError { + CommandSyntaxError::create_without_context( + &PROXY_COMMAND_ERROR, + TextComponent::text(msg.into()), + ) +} + +/// Represents the origin that invoked a command +#[derive(Clone)] +pub enum CommandSender { + /// Command invoked via proxy console (stdin) + Console, + /// Command invoked by an in-game player + Player { + username: String, + action_tx: mpsc::UnboundedSender, + }, +} + +impl CommandSender { + /// Sends response feedback to the command sender + pub fn send_message(&self, message: TextComponent) { + match self { + Self::Console => { + info!("{}", message.to_pretty_console()); + } + Self::Player { action_tx, .. } => { + let _ = action_tx.send(PlayerAction::Message(message)); + } + } + } + + /// Sends a plain text message to the command sender + pub fn send_text(&self, text: impl Into) { + self.send_message(TextComponent::text(text.into())); + } + + /// Returns the name of the sender + pub fn name(&self) -> &str { + match self { + Self::Console => "CONSOLE", + Self::Player { username, .. } => username, + } + } +} + +/// Execution context passed to commands +#[derive(Clone)] +pub struct ProxyCommandSource { + pub sender: CommandSender, + pub config: Arc, + pub session_manager: Arc, +} + +impl ProxyCommandSource { + /// Evaluates if the command source has the specified permission node. + /// Console always returns `true`. + pub fn has_permission(&self, node: &str) -> bool { + match &self.sender { + CommandSender::Console => true, + CommandSender::Player { username, .. } => { + self.config.permissions.has_permission(username, node) + } + } + } + + /// Returns true if the command sender has administrative privileges (node `vine.admin` or `*`) + pub fn is_admin(&self) -> bool { + self.has_permission("vine.admin") + } +} + +impl CommandSource for ProxyCommandSource { + fn send_message(&self, message: TextComponent) { + self.sender.send_message(message); + } + + fn send_error(&self, error: TextComponent) { + self.send_message(error); + } + + fn has_permission(&self, permission: &str) -> bool { + self.has_permission(permission) + } +} + +/// Help command listing registered commands +pub struct HelpCommandExecutor; + +impl CommandExecutor for HelpCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + let text = "Available commands:\n - /server [server] : View current server or switch to another backend\n - /send : Send players to another backend (Admin)\n - /list : List connected players\n - /glist : List players grouped by server\n - /help [command] : Show this help or info about a command"; + context.source.send_message(TextComponent::text(text)); + Ok(1) + } +} + +/// Suggestion provider for `/help [command]` +struct HelpSuggestionProvider; + +impl SuggestionProvider for HelpSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + let mut builder = builder; + let remaining = builder.remaining_lowercase().to_string(); + let is_admin = context.source.is_admin(); + + for (cmd_name, required_perm) in [ + ("server", Some("vine.command.server")), + ("send", Some("vine.command.send")), + ("list", Some("vine.command.list")), + ("glist", Some("vine.command.glist")), + ("help", Some("vine.command.help")), + ] { + if cmd_name.starts_with(&remaining) { + let allowed = is_admin + || required_perm + .map(|p| context.source.has_permission(p)) + .unwrap_or(true); + if allowed { + builder = builder.suggest(cmd_name); + } + } + } + builder.build() + } +} + +/// Detailed help executor for `/help ` +struct CommandSpecificHelpExecutor; + +impl CommandExecutor for CommandSpecificHelpExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + let cmd = StringArgumentType::get(context, "command")?; + let msg = match cmd.to_ascii_lowercase().as_str() { + "server" => { + "Command /server:\n Usage: /server [server]\n Description: View your current server or switch to another backend server." + } + "send" => { + "Command /send:\n Usage: /send \n Description: Send players to another backend server (Requires vine.command.send / admin)." + } + "list" => { + "Command /list:\n Usage: /list\n Description: List all online players connected to the proxy." + } + "glist" => { + "Command /glist:\n Usage: /glist\n Description: List online players grouped by backend server." + } + "help" => { + "Command /help:\n Usage: /help [command]\n Description: Show help information for available proxy commands." + } + _ => "Unknown command. Use /help to see all available proxy commands.", + }; + context.source.send_message(TextComponent::text(msg)); + Ok(1) + } +} + +/// List command displaying online players +pub struct ListCommandExecutor; + +impl CommandExecutor for ListCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + let sessions = context.source.session_manager.list_sessions(); + if sessions.is_empty() { + context + .source + .send_message(TextComponent::text("There are 0 players online.")); + return Ok(0); + } + + let players_info: Vec = sessions + .into_iter() + .map(|s| format!("{} ({})", s.username, s.current_server)) + .collect(); + + context.source.send_message(TextComponent::text(format!( + "Online players ({}): {}", + players_info.len(), + players_info.join(", ") + ))); + Ok(players_info.len() as i32) + } +} + +/// Central registry and dispatcher for commands using `pumpkin-command` +pub struct CommandDispatcher { + inner: RawCommandDispatcher, +} + +impl Default for CommandDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl CommandDispatcher { + /// Creates a new CommandDispatcher with default built-in commands + pub fn new() -> Self { + let mut dispatcher = RawCommandDispatcher::new(); + server::register(&mut dispatcher); + send::register(&mut dispatcher); + glist::register(&mut dispatcher); + dispatcher.register( + command("list", "Lists online players connected to the proxy") + .executes(ListCommandExecutor), + ); + dispatcher.register( + command("help", "Displays list of available proxy commands") + .executes(HelpCommandExecutor) + .then( + argument("command", StringArgumentType::SingleWord) + .suggests(HelpSuggestionProvider) + .executes(CommandSpecificHelpExecutor), + ), + ); + Self { inner: dispatcher } + } + + /// Returns true if a command with the given name is registered + pub fn has_command(&self, name: &str) -> bool { + let clean = name.trim_start_matches('/').to_ascii_lowercase(); + self.inner.has_command(&clean) + } + + /// Dispatches a command for execution, reporting any error messages back to the source + pub fn handle_command(&self, source: &ProxyCommandSource, input: &str) { + self.inner.handle_command(source, input); + } + + /// Executes raw input directly, returning the execution result + pub fn execute_input( + &self, + input: &str, + source: &ProxyCommandSource, + ) -> Result { + let trimmed = input.trim(); + let clean = trimmed.strip_prefix('/').unwrap_or(trimmed); + self.inner.execute_input(clean, source) + } + + /// Returns suggestions with range information for any command input. + /// If input contains arguments (i.e. has a space), it evaluates argument suggestions. + /// If input is typing the root command name, it suggests permitted root commands starting with the prefix. + pub fn suggest_command(&self, input: &str, source: &ProxyCommandSource) -> Suggestions { + let clean = input.strip_prefix('/').unwrap_or(input); + if clean.contains(' ') { + let first_word = clean.split_whitespace().next().unwrap_or(clean); + if self.has_command(first_word) { + return self.inner.suggest_with_range(clean, source); + } + return Suggestions::empty(); + } + + let clean_lower = clean.to_ascii_lowercase(); + let permitted = self.inner.get_all_permitted_commands(source); + let mut suggestions = Vec::new(); + for (cmd_name, description) in permitted { + if cmd_name.starts_with(&clean_lower) { + suggestions.push(Suggestion::with_tooltip( + StringRange::between(0, clean.len()), + cmd_name.to_string(), + TextComponent::text(description.to_string()), + )); + } + } + Suggestions::new(StringRange::between(0, clean.len()), suggestions) + } + + /// Returns suggestions for the command string + pub fn suggest(&self, input: &str, source: &ProxyCommandSource) -> Vec { + let suggestions = self.suggest_command(input, source); + suggestions + .suggestions + .into_iter() + .map(|s| CommandSuggestion { + suggestion: s.text.cached_text().clone(), + tooltip: s.tooltip, + }) + .collect() + } + + /// Returns suggestions with range information + pub fn suggest_with_range(&self, input: &str, source: &ProxyCommandSource) -> Suggestions { + self.suggest_command(input, source) + } + + /// Exposes the inner pumpkin command dispatcher + pub fn raw(&self) -> &RawCommandDispatcher { + &self.inner + } + + /// Exposes a mutable reference to the inner pumpkin command dispatcher + pub fn raw_mut(&mut self) -> &mut RawCommandDispatcher { + &mut self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dispatcher_registration_and_dispatch() { + let dispatcher = CommandDispatcher::new(); + assert!(dispatcher.has_command("send")); + assert!(dispatcher.has_command("/send")); + assert!(dispatcher.has_command("help")); + assert!(dispatcher.has_command("list")); + assert!(dispatcher.has_command("glist")); + assert!(dispatcher.has_command("/glist")); + assert!(!dispatcher.has_command("unknown")); + + let config = Arc::new(Config::default()); + let session_manager = Arc::new(SessionManager::new()); + let source = ProxyCommandSource { + sender: CommandSender::Console, + config, + session_manager, + }; + + let res = dispatcher.execute_input("help", &source); + assert!(res.is_ok()); + + let res_slash = dispatcher.execute_input("/help", &source); + assert!(res_slash.is_ok()); + + let res_unknown = dispatcher.execute_input("unknown_cmd", &source); + assert!(res_unknown.is_err()); + } + + #[test] + fn test_suggest_various_inputs() { + let dispatcher = CommandDispatcher::new(); + let config = Arc::new(Config::default()); + let session_manager = Arc::new(SessionManager::new()); + let source = ProxyCommandSource { + sender: CommandSender::Console, + config, + session_manager, + }; + + let s_empty = dispatcher.suggest_with_range("", &source); + let names_empty: Vec<&str> = s_empty + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert!(names_empty.contains(&"server")); + assert!(names_empty.contains(&"send")); + assert!(names_empty.contains(&"glist")); + assert!(names_empty.contains(&"list")); + assert!(names_empty.contains(&"help")); + + let s_s = dispatcher.suggest_with_range("s", &source); + let names_s: Vec<&str> = s_s + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert_eq!(names_s, vec!["send", "server"]); + + let s_ser = dispatcher.suggest_with_range("ser", &source); + let names_ser: Vec<&str> = s_ser + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert_eq!(names_ser, vec!["server"]); + + let s_server = dispatcher.suggest_with_range("server", &source); + let names_server: Vec<&str> = s_server + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert_eq!(names_server, vec!["server"]); + + let s_server_space = dispatcher.suggest_with_range("server ", &source); + let names_server_space: Vec<&str> = s_server_space + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert!(names_server_space.contains(&"lobby")); + + let s_help_space = dispatcher.suggest_with_range("help ", &source); + let names_help_space: Vec<&str> = s_help_space + .suggestions + .iter() + .map(|s| s.text.cached_text().as_str()) + .collect(); + assert!(names_help_space.contains(&"server")); + assert!(names_help_space.contains(&"send")); + assert!(names_help_space.contains(&"glist")); + assert!(names_help_space.contains(&"list")); + assert!(names_help_space.contains(&"help")); + } +} diff --git a/src/command/send.rs b/src/command/send.rs new file mode 100644 index 0000000..ef86694 --- /dev/null +++ b/src/command/send.rs @@ -0,0 +1,361 @@ +use std::net::SocketAddr; + +use pumpkin_command::CommandSource; +use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command}; +use pumpkin_command::argument_types::core::string::StringArgumentType; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher; +use pumpkin_command::node::{CommandExecutor, CommandExecutorResult}; +use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; +use pumpkin_command::suggestion::suggestions::SuggestionsBuilder; +use pumpkin_util::text::TextComponent; + +use super::{ProxyCommandSource, command_error}; +use crate::session::PlayerAction; + +/// Parses a host:port address string +pub fn parse_address(addr: &str) -> Option<(String, u16)> { + if let Ok(socket_addr) = addr.parse::() { + return Some((socket_addr.ip().to_string(), socket_addr.port())); + } + if let Some((host, port_str)) = addr.rsplit_once(':') + && let Ok(port) = port_str.parse::() + { + return Some((host.to_string(), port)); + } + None +} + +struct TargetSuggestionProvider; + +impl SuggestionProvider for TargetSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + let mut builder = builder; + let remaining = builder.remaining_lowercase().to_string(); + if "all".starts_with(&remaining) { + builder = builder.suggest("all"); + } + for session in context.source.session_manager.list_sessions() { + if session + .username + .to_ascii_lowercase() + .starts_with(&remaining) + { + builder = builder.suggest(session.username); + } + } + builder.build() + } +} + +struct ServerSuggestionProvider; + +impl SuggestionProvider for ServerSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + let mut builder = builder; + let remaining = builder.remaining_lowercase().to_string(); + for server in context.source.config.servers.keys() { + if server.to_ascii_lowercase().starts_with(&remaining) { + builder = builder.suggest(server.as_str()); + } + } + builder.build() + } +} + +struct SendCommandExecutor; + +impl CommandExecutor for SendCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + if !context.source.has_permission("vine.command.send") { + return Err(command_error( + "You do not have permission to execute this command (required: vine.command.send).", + )); + } + + let target = StringArgumentType::get(context, "target")?; + let server_name = StringArgumentType::get(context, "server")?; + + let canonical_server = match context + .source + .config + .servers + .keys() + .find(|k| k.eq_ignore_ascii_case(server_name)) + { + Some(name) => name.clone(), + None => { + let mut available: Vec<&String> = context.source.config.servers.keys().collect(); + available.sort(); + return Err(command_error(format!( + "Unknown backend server '{}'. Available servers: [{}]", + server_name, + available + .into_iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ))); + } + }; + + if target.eq_ignore_ascii_case("all") { + let sessions = context.source.session_manager.list_sessions(); + if sessions.is_empty() { + return Err(command_error("No players currently online to send.")); + } + + let count = sessions.len(); + for session in &sessions { + let _ = session.action_tx.send(PlayerAction::Connect { + server_name: canonical_server.clone(), + }); + } + + let msg = format!("Sent {} player(s) to '{}'", count, canonical_server); + context.source.send_message(TextComponent::text(msg)); + Ok(count as i32) + } else { + let session = context + .source + .session_manager + .get_session(target) + .ok_or_else(|| command_error(format!("Player '{}' is not online.", target)))?; + + let _ = session.action_tx.send(PlayerAction::Connect { + server_name: canonical_server.clone(), + }); + + let msg = format!("Sent '{}' to '{}'", session.username, canonical_server); + context.source.send_message(TextComponent::text(msg)); + Ok(1) + } + } +} + +pub fn register(dispatcher: &mut RawCommandDispatcher) { + dispatcher.register( + command( + "send", + "Sends a player or all players to another backend server", + ) + .then( + argument("target", StringArgumentType::SingleWord) + .suggests(TargetSuggestionProvider) + .then( + argument("server", StringArgumentType::SingleWord) + .suggests(ServerSuggestionProvider) + .executes(SendCommandExecutor), + ), + ), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::mpsc; + use uuid::Uuid; + + use crate::command::{CommandDispatcher, CommandSender}; + use crate::config::{BackendConfig, Config, ForwardingMode}; + use crate::session::{PlayerSession, SessionManager}; + + #[test] + fn test_parse_address() { + assert_eq!( + parse_address("127.0.0.1:25565"), + Some(("127.0.0.1".to_string(), 25565)) + ); + assert_eq!( + parse_address("mc.example.com:25577"), + Some(("mc.example.com".to_string(), 25577)) + ); + assert_eq!(parse_address("invalid"), None); + } + + #[tokio::test] + async fn test_send_command_execution() { + let mut config = Config::default(); + config.servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25567".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + + let session_manager = Arc::new(SessionManager::new()); + let (tx, mut rx) = mpsc::unbounded_channel(); + session_manager.register(PlayerSession { + username: "Steve".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: tx, + }); + + let source = ProxyCommandSource { + sender: CommandSender::Console, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let res = dispatcher.execute_input("send Steve survival", &source); + assert!(res.is_ok()); + + let action = rx.recv().await; + assert_eq!( + action, + Some(PlayerAction::Connect { + server_name: "survival".to_string(), + }) + ); + + // Player not in admin list is rejected + let (non_admin_tx, _) = mpsc::unbounded_channel(); + let non_admin_source = ProxyCommandSource { + sender: CommandSender::Player { + username: "RegularPlayer".to_string(), + action_tx: non_admin_tx, + }, + config: Arc::new(Config::default()), + session_manager: Arc::new(SessionManager::new()), + }; + let res_denied = dispatcher.execute_input("send Steve survival", &non_admin_source); + assert!(res_denied.is_err()); + assert!( + res_denied + .unwrap_err() + .message + .to_pretty_console() + .contains("permission") + ); + } + + #[test] + fn test_send_command_unknown_server() { + let config = Config::default(); + let session_manager = Arc::new(SessionManager::new()); + let source = ProxyCommandSource { + sender: CommandSender::Console, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let res = dispatcher.execute_input("send Steve nonexistent", &source); + assert!(res.is_err()); + assert!( + res.unwrap_err() + .message + .to_pretty_console() + .contains("Unknown backend server") + ); + } + + #[test] + fn test_send_command_offline_player() { + let mut config = Config::default(); + config.servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25567".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + + let session_manager = Arc::new(SessionManager::new()); + let source = ProxyCommandSource { + sender: CommandSender::Console, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let res = dispatcher.execute_input("send Alex survival", &source); + assert!(res.is_err()); + assert!( + res.unwrap_err() + .message + .to_pretty_console() + .contains("Player 'Alex' is not online") + ); + } + + #[test] + fn test_send_command_suggestions() { + let mut config = Config::default(); + config.servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25567".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + config.servers.insert( + "lobby".to_string(), + BackendConfig { + address: "127.0.0.1:25565".to_string(), + forwarding: ForwardingMode::None, + secret: None, + motd_line1: None, + motd_line2: None, + icon_path: None, + }, + ); + + let session_manager = Arc::new(SessionManager::new()); + let (tx, _rx) = mpsc::unbounded_channel(); + session_manager.register(PlayerSession { + username: "Steve".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: tx, + }); + + let source = ProxyCommandSource { + sender: CommandSender::Console, + config: Arc::new(config), + session_manager, + }; + + let dispatcher = CommandDispatcher::new(); + let target_suggestions = dispatcher.suggest("send ", &source); + let names: Vec<&str> = target_suggestions + .iter() + .map(|s| s.suggestion.as_str()) + .collect(); + assert!(names.contains(&"all")); + assert!(names.contains(&"Steve")); + + let server_suggestions = dispatcher.suggest("send Steve ", &source); + let servers: Vec<&str> = server_suggestions + .iter() + .map(|s| s.suggestion.as_str()) + .collect(); + assert!(servers.contains(&"survival")); + assert!(servers.contains(&"lobby")); + } +} diff --git a/src/command/server.rs b/src/command/server.rs new file mode 100644 index 0000000..4de06c0 --- /dev/null +++ b/src/command/server.rs @@ -0,0 +1,224 @@ +use pumpkin_command::CommandSource; +use pumpkin_command::Requirement; +use pumpkin_command::argument_builder::{ArgumentBuilder, argument, command}; +use pumpkin_command::argument_types::core::string::StringArgumentType; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::dispatcher::CommandDispatcher as RawCommandDispatcher; +use pumpkin_command::node::{CommandExecutor, CommandExecutorResult}; +use pumpkin_command::suggestion::provider::{SuggestionProvider, SuggestionProviderResult}; +use pumpkin_command::suggestion::suggestions::SuggestionsBuilder; +use pumpkin_util::text::TextComponent; + +use super::{CommandSender, ProxyCommandSource, command_error}; +use crate::session::PlayerAction; + +struct ServerSuggestionProvider; + +impl SuggestionProvider for ServerSuggestionProvider { + fn suggest( + &self, + context: &CommandContext, + builder: SuggestionsBuilder, + ) -> SuggestionProviderResult { + let mut builder = builder; + let remaining = builder.remaining_lowercase().to_string(); + for server in context.source.config.servers.keys() { + if server.to_ascii_lowercase().starts_with(&remaining) { + builder = builder.suggest(server.as_str()); + } + } + builder.build() + } +} + +/// /server (no args): shows current server (if player) and lists all available servers +struct ServerInfoCommandExecutor; + +impl CommandExecutor for ServerInfoCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + if !context.source.has_permission("vine.command.server") { + return Err(command_error( + "You do not have permission to execute this command (required: vine.command.server).", + )); + } + + let mut available: Vec<&String> = context.source.config.servers.keys().collect(); + available.sort(); + let servers_str = available + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + match &context.source.sender { + CommandSender::Player { username, .. } => { + let current = context + .source + .session_manager + .get_session(username) + .map(|s| s.current_server) + .unwrap_or_else(|| "unknown".to_string()); + + context.source.send_message(TextComponent::text(format!( + "§6[Vine] §fYou are currently connected to server '§a{}§f'.\n§6[Vine] §7Available servers: §e{}", + current, servers_str + ))); + } + CommandSender::Console => { + context.source.send_message(TextComponent::text(format!( + "Available backend servers: {}", + servers_str + ))); + } + } + + Ok(1) + } +} + +/// /server : connects the player to that backend server +struct ServerSwitchCommandExecutor; + +impl CommandExecutor for ServerSwitchCommandExecutor { + fn execute(&self, context: &CommandContext) -> CommandExecutorResult { + if !context.source.has_permission("vine.command.server") { + return Err(command_error( + "You do not have permission to execute this command (required: vine.command.server).", + )); + } + + let server_name = StringArgumentType::get(context, "server")?; + + let (username, action_tx) = match &context.source.sender { + CommandSender::Player { + username, + action_tx, + } => (username.clone(), action_tx.clone()), + CommandSender::Console => { + return Err(command_error( + "Console cannot switch servers. Use '/send ' instead.", + )); + } + }; + + let target_server = match context + .source + .config + .servers + .keys() + .find(|k| k.eq_ignore_ascii_case(server_name)) + { + Some(name) => name.clone(), + None => { + let mut available: Vec<&String> = context.source.config.servers.keys().collect(); + available.sort(); + return Err(command_error(format!( + "Unknown server '{}'. Available servers: [{}]", + server_name, + available + .into_iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ))); + } + }; + + if let Some(session) = context.source.session_manager.get_session(&username) + && session.current_server.eq_ignore_ascii_case(&target_server) + { + return Err(command_error(format!( + "You are already connected to '{}'!", + target_server + ))); + } + + context.source.send_message(TextComponent::text(format!( + "§6[Vine] §eConnecting you to '§a{}§e'...", + target_server + ))); + + let _ = action_tx.send(PlayerAction::Connect { + server_name: target_server, + }); + + Ok(1) + } +} + +pub fn register(dispatcher: &mut RawCommandDispatcher) { + dispatcher.register( + command("server", "View or connect to a backend server") + .requires(Requirement::from("vine.command.server")) + .executes(ServerInfoCommandExecutor) + .then( + argument("server", StringArgumentType::SingleWord) + .suggests(ServerSuggestionProvider) + .executes(ServerSwitchCommandExecutor), + ), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::session::{PlayerSession, SessionManager}; + use std::sync::Arc; + use tokio::sync::mpsc; + use uuid::Uuid; + + #[test] + fn test_server_command_execution() { + let mut dispatcher = RawCommandDispatcher::new(); + register(&mut dispatcher); + + let config = Arc::new(Config::default()); + let session_manager = Arc::new(SessionManager::new()); + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let session = PlayerSession { + username: "Steve".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: action_tx.clone(), + }; + session_manager.register(session); + + let source = ProxyCommandSource { + sender: CommandSender::Player { + username: "Steve".to_string(), + action_tx, + }, + config: config.clone(), + session_manager: session_manager.clone(), + }; + + // 1. /server (no args) succeeds + let res = dispatcher.execute_input("server", &source); + assert!(res.is_ok()); + let msg_action = action_rx.try_recv(); + assert!(matches!(msg_action, Ok(PlayerAction::Message(_)))); + + // 2. /server survival requests connection to survival + let res_switch = dispatcher.execute_input("server survival", &source); + assert!(res_switch.is_ok()); + let switch_msg = action_rx.try_recv(); + assert!(matches!(switch_msg, Ok(PlayerAction::Message(_)))); + let action = action_rx.try_recv(); + assert_eq!( + action.unwrap(), + PlayerAction::Connect { + server_name: "survival".to_string() + } + ); + + // 3. /server lobby fails because player is already on lobby + let res_already = dispatcher.execute_input("server lobby", &source); + assert!(res_already.is_err()); + + // 4. /server unknown fails + let res_unknown = dispatcher.execute_input("server not_a_server", &source); + assert!(res_unknown.is_err()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e23fc12 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,423 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, path::Path}; + +fn default_true() -> bool { + true +} + +fn default_compression_level() -> i32 { + 6 +} + +fn default_shutdown_timeout() -> u64 { + 10 +} + +fn default_tcp_keepalive() -> u64 { + 60 +} + +fn default_handshake_timeout() -> u64 { + 10 +} + +fn default_status_timeout() -> u64 { + 5 +} + +fn default_login_timeout() -> u64 { + 15 +} + +fn default_backend_connect_timeout() -> u64 { + 5 +} + +fn default_backend_login_timeout() -> u64 { + 15 +} + +fn default_backend_retries() -> u32 { + 2 +} + +fn default_listen_backlog() -> u32 { + 1024 +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + pub server: ServerConfig, + #[serde(default)] + pub network: NetworkConfig, + pub motd: MotdConfig, + pub security: SecurityConfig, + pub routing: RoutingConfig, + pub servers: HashMap, + #[serde(default)] + pub telemetry: TelemetryConfig, + #[serde(default)] + pub bedrock: crate::bedrock::BedrockConfig, + #[serde(default)] + pub permissions: PermissionsConfig, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ServerConfig { + /// Address and port for the proxy to listen on + pub bind_address: String, + /// Whether to verify player accounts with Mojang session servers + pub online_mode: bool, + /// Send player IP to Mojang during session authentication to prevent proxy hijacking + pub prevent_proxy_connections: bool, + /// Packet compression threshold (-1 to disable, e.g. 256 for standard compression) + pub compression_threshold: i32, + /// zlib / flate2 compression level (1 = fastest, 6 = default balance, 9 = best compression) + #[serde(default = "default_compression_level")] + pub compression_level: i32, + /// Graceful shutdown timeout in seconds to wait for active connections to close + #[serde(default = "default_shutdown_timeout")] + pub shutdown_timeout_secs: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NetworkConfig { + /// Enable TCP_NODELAY (disable Nagle's algorithm) on client and backend sockets. + /// Crucial for eliminating latency spikes in Minecraft packet transmission. + #[serde(default = "default_true")] + pub tcp_nodelay: bool, + + /// TCP keepalive interval in seconds on client and backend sockets (0 to disable). + #[serde(default = "default_tcp_keepalive")] + pub tcp_keepalive_secs: u64, + + /// Timeout in seconds to wait for initial client Handshake packet + #[serde(default = "default_handshake_timeout")] + pub client_handshake_timeout_secs: u64, + + /// Timeout in seconds to wait for client Status/Ping packets + #[serde(default = "default_status_timeout")] + pub client_status_timeout_secs: u64, + + /// Timeout in seconds to wait for client LoginStart and Encryption packets + #[serde(default = "default_login_timeout")] + pub client_login_timeout_secs: u64, + + /// Timeout in seconds when connecting to a backend server + #[serde(default = "default_backend_connect_timeout")] + pub backend_connect_timeout_secs: u64, + + /// Timeout in seconds for backend socket reads during the login handshake phase + #[serde(default = "default_backend_login_timeout")] + pub backend_login_timeout_secs: u64, + + /// Number of connection retry attempts when connecting to a backend server before failing + #[serde(default = "default_backend_retries")] + pub backend_connect_retry_attempts: u32, + + /// Optional TCP receive buffer size (SO_RCVBUF) in bytes + #[serde(default)] + pub recv_buffer_size: Option, + + /// Optional TCP send buffer size (SO_SNDBUF) in bytes + #[serde(default)] + pub send_buffer_size: Option, + + /// TCP listen backlog queue size + #[serde(default = "default_listen_backlog")] + pub listen_backlog: u32, + + /// Support HAProxy PROXY Protocol (v1 and v2) from upstream reverse proxies (e.g. Cloudflare Spectrum, HAProxy) + #[serde(default)] + pub proxy_protocol: bool, + + /// Log incoming server list status pings in the console + #[serde(default)] + pub log_pings: bool, +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + tcp_nodelay: default_true(), + tcp_keepalive_secs: default_tcp_keepalive(), + client_handshake_timeout_secs: default_handshake_timeout(), + client_status_timeout_secs: default_status_timeout(), + client_login_timeout_secs: default_login_timeout(), + backend_connect_timeout_secs: default_backend_connect_timeout(), + backend_login_timeout_secs: default_backend_login_timeout(), + backend_connect_retry_attempts: default_backend_retries(), + recv_buffer_size: None, + send_buffer_size: None, + listen_backlog: default_listen_backlog(), + proxy_protocol: false, + log_pings: false, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MotdConfig { + /// Line 1 of the server list ping description + pub line1: String, + /// Line 2 of the server list ping description + pub line2: String, + /// Maximum players shown in the server list ping + pub max_players: u32, + /// Custom version display string (e.g. "Vine 1.7.x - 26.x") + pub version_name: String, + /// Protocol version reported to clients (776 is 26.2) + pub protocol_version: i32, + /// Optional sample player list shown on hover in server list + pub sample_players: Vec, + /// Optional path to a 64x64 PNG file for the server list icon (favicons) + #[serde(default)] + pub icon_path: Option, + /// Whether to show the player sample list on hover in the multiplayer menu + #[serde(default = "default_true")] + pub show_sample_players: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SecurityConfig { + /// Enable connection rate limiting per IP + pub rate_limit_enabled: bool, + /// Maximum new TCP connections per second per IP + pub max_connections_per_sec: u32, + /// Burst allowance for new connections + pub connection_burst: u32, + /// Maximum packets per second from a single client before being kicked for flooding + pub max_packets_per_sec: u32, + /// Maximum raw packet size allowed in bytes (default 2MB) + pub max_packet_size: usize, + /// Enforce strict Minecraft username validation (^[a-zA-Z0-9_]{2,16}$) + pub validate_usernames: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RoutingConfig { + /// Default backend server name to connect to + pub default_server: String, + /// Fallback server if default server is offline + pub fallback_server: Option, + /// Virtual host routing: maps domain/hostname to backend server name + pub forced_hosts: HashMap, + /// Automatically try fallback server if connection to target server fails + #[serde(default = "default_true")] + pub try_fallback_on_connect_failure: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ForwardingMode { + /// Plain connection without forwarding info (for Vanilla servers in offline-mode) + None, + /// BungeeCord IP forwarding (for Bukkit, Spigot, PaperMC with bungeecord: true) + BungeeCord, + /// Velocity modern forwarding with HMAC-SHA256 secret (for PaperMC, Purpur, Pumpkin) + Velocity, + /// Vine modern forwarding with Ed25519 asymmetric signatures, timestamp, and challenge nonce (for Pumpkin) + Vine, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BackendConfig { + /// Address of backend server (e.g. "127.0.0.1:25566") + pub address: String, + /// Forwarding mode to use when connecting to this backend + pub forwarding: ForwardingMode, + /// Secret key required if forwarding mode is Velocity + pub secret: Option, + /// Optional custom 64x64 PNG status icon path for this backend / virtual host + #[serde(default)] + pub icon_path: Option, + /// Optional custom MOTD Line 1 for this backend / virtual host + #[serde(default)] + pub motd_line1: Option, + /// Optional custom MOTD Line 2 for this backend / virtual host + #[serde(default)] + pub motd_line2: Option, +} + +fn default_telemetry_interval() -> u64 { + 300 +} + +fn default_telemetry_endpoint() -> String { + "https://market.pumpkinmc.org/api/v1/rest/telemetry/heartbeat".to_string() +} + +/// Telemetry configuration options. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TelemetryConfig { + /// Whether anonymous telemetry is enabled (default: true) + #[serde(default = "default_true")] + pub enabled: bool, + /// Custom telemetry backend ingestion endpoint + #[serde(default = "default_telemetry_endpoint")] + pub endpoint: String, + /// Heartbeat interval in seconds (default: 300 seconds / 5 minutes). Minimum 60s. + #[serde(default = "default_telemetry_interval")] + pub interval_secs: u64, + /// Whether to opt-in to displaying this proxy in the public community directory. + #[serde(default)] + pub public: bool, + /// Public server/proxy name displayed on the analytics dashboard if public is true. + #[serde(default)] + pub server_name: Option, +} + +impl Default for TelemetryConfig { + fn default() -> Self { + Self { + enabled: true, + endpoint: default_telemetry_endpoint(), + interval_secs: 300, + public: false, + server_name: None, + } + } +} + +impl Default for Config { + fn default() -> Self { + let mut servers = HashMap::new(); + servers.insert( + "lobby".to_string(), + BackendConfig { + address: "127.0.0.1:25566".to_string(), + forwarding: ForwardingMode::Velocity, + secret: Some("vine-velocity-secret-change-me".to_string()), + icon_path: None, + motd_line1: None, + motd_line2: None, + }, + ); + servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25567".to_string(), + forwarding: ForwardingMode::BungeeCord, + secret: None, + icon_path: None, + motd_line1: Some("§b§lVine Survival §7» §fWelcome back!".to_string()), + motd_line2: Some("§7Custom world • Economy • Land Claims".to_string()), + }, + ); + servers.insert( + "vanilla".to_string(), + BackendConfig { + address: "127.0.0.1:25568".to_string(), + forwarding: ForwardingMode::None, + secret: None, + icon_path: None, + motd_line1: None, + motd_line2: None, + }, + ); + + let mut forced_hosts = HashMap::new(); + forced_hosts.insert("survival.example.com".to_string(), "survival".to_string()); + forced_hosts.insert("vanilla.example.com".to_string(), "vanilla".to_string()); + + Self { + server: ServerConfig { + bind_address: "0.0.0.0:25565".to_string(), + online_mode: true, + prevent_proxy_connections: true, + compression_threshold: 256, + compression_level: 6, + shutdown_timeout_secs: 10, + }, + network: NetworkConfig::default(), + motd: MotdConfig { + line1: "§a§lVine §7» §fHigh-Performance Minecraft Proxy".to_string(), + line2: "§7Multi-version support §8• §e1.7.x - 26.x".to_string(), + max_players: 100, + version_name: "Vine 1.7.x - 26.x".to_string(), + protocol_version: 776, + sample_players: vec![ + "§aVine Multi-Version Proxy".to_string(), + "§7Secure • Fast • Reliable".to_string(), + ], + icon_path: None, + show_sample_players: true, + }, + security: SecurityConfig { + rate_limit_enabled: true, + max_connections_per_sec: 5, + connection_burst: 10, + max_packets_per_sec: 500, + max_packet_size: 2 * 1024 * 1024, + validate_usernames: true, + }, + routing: RoutingConfig { + default_server: "lobby".to_string(), + fallback_server: Some("survival".to_string()), + forced_hosts, + try_fallback_on_connect_failure: true, + }, + servers, + telemetry: TelemetryConfig::default(), + bedrock: crate::bedrock::BedrockConfig::default(), + permissions: PermissionsConfig::default(), + } + } +} + +pub use crate::permissions::{GroupConfig, PermissionsConfig, UserPermissionConfig}; + +impl Config { + pub fn load_or_create>(path: P) -> Result> { + let path = path.as_ref(); + if path.exists() { + let content = std::fs::read_to_string(path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } else { + let default_config = Config::default(); + let toml_string = toml::to_string_pretty(&default_config)?; + std::fs::write(path, toml_string)?; + Ok(default_config) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_telemetry_config_defaults() { + let default_config = TelemetryConfig::default(); + assert!(default_config.enabled); + assert_eq!( + default_config.endpoint, + "https://market.pumpkinmc.org/api/v1/rest/telemetry/heartbeat" + ); + assert_eq!(default_config.interval_secs, 300); + assert!(!default_config.public); + assert_eq!(default_config.server_name, None); + } + + #[test] + fn test_telemetry_toml_deserialization() { + let toml_str = r#" + enabled = false + endpoint = "http://localhost:5000/api/v1/rest/telemetry/heartbeat" + interval_secs = 120 + public = true + server_name = "Vine Proxy Hub" + "#; + + let config: TelemetryConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.enabled); + assert_eq!( + config.endpoint, + "http://localhost:5000/api/v1/rest/telemetry/heartbeat" + ); + assert_eq!(config.interval_secs, 120); + assert!(config.public); + assert_eq!(config.server_name.as_deref(), Some("Vine Proxy Hub")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c55a0bd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +pub mod auth; +pub mod backend; +pub mod bedrock; +pub mod client; +pub mod command; +pub mod config; +pub mod network; +pub mod permissions; +pub mod proxy; +pub mod security; +pub mod session; +pub mod status; +pub mod telemetry; + +pub use config::Config; +pub use proxy::ProxyServer; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1d99293 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,56 @@ +use std::path::PathBuf; +use tokio::sync::broadcast; +use tracing::{error, info}; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +use vine::config::Config; +use vine::proxy::ProxyServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,vine=debug")), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config_path = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("vine.toml")); + + info!("Loading configuration from '{}'...", config_path.display()); + let config = match Config::load_or_create(&config_path) { + Ok(cfg) => cfg, + Err(e) => { + error!( + "Failed to load configuration file '{}': {}", + config_path.display(), + e + ); + std::process::exit(1); + } + }; + + let server = ProxyServer::new(config)?; + + let (shutdown_tx, shutdown_rx) = broadcast::channel(1); + + tokio::spawn(async move { + if let Err(err) = tokio::signal::ctrl_c().await { + error!("Failed to listen for shutdown signal: {}", err); + } else { + info!("Received shutdown signal (Ctrl+C). Initiating graceful shutdown..."); + let _ = shutdown_tx.send(()); + } + }); + + if let Err(e) = server.run(shutdown_rx).await { + error!("Fatal proxy error: {}", e); + std::process::exit(1); + } + + info!("Vine proxy shut down successfully."); + std::process::exit(0); +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..b214aa3 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,111 @@ +pub mod prefixed_stream; +pub mod proxy_protocol; + +use std::io; +use std::net::SocketAddr; +use std::time::Duration; + +use tokio::net::{TcpListener, TcpSocket, TcpStream}; +use tracing::{debug, warn}; + +use crate::config::NetworkConfig; +pub use prefixed_stream::PrefixedRead; +pub use proxy_protocol::{ProxyHeaderResult, parse_proxy_protocol}; + +/// Creates and tunes a Tokio `TcpListener` based on `NetworkConfig` +pub fn create_tcp_listener( + bind_addr_str: &str, + network: &NetworkConfig, +) -> io::Result { + let addr: SocketAddr = bind_addr_str + .parse() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + let socket = if addr.is_ipv6() { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + + socket.set_reuseaddr(true)?; + + #[cfg(all(unix, not(target_os = "solaris"), not(target_os = "illumos")))] + { + let _ = socket.set_reuseport(true); + } + + if let Some(buf) = network.recv_buffer_size { + let _ = socket.set_recv_buffer_size(buf as u32); + } + if let Some(buf) = network.send_buffer_size { + let _ = socket.set_send_buffer_size(buf as u32); + } + + socket.bind(addr)?; + let listener = socket.listen(network.listen_backlog)?; + Ok(listener) +} + +/// Applies socket performance options (TCP_NODELAY, KeepAlive, Buffer sizes) to a client `TcpStream` +pub fn configure_client_socket(stream: &TcpStream, config: &NetworkConfig) -> io::Result<()> { + if let Err(e) = stream.set_nodelay(config.tcp_nodelay) { + warn!("Failed to set TCP_NODELAY on client socket: {}", e); + } + + let sock = socket2::SockRef::from(stream); + + if config.tcp_keepalive_secs > 0 { + let mut keepalive = + socket2::TcpKeepalive::new().with_time(Duration::from_secs(config.tcp_keepalive_secs)); + + #[cfg(any(target_os = "linux", target_os = "android"))] + { + keepalive = keepalive.with_interval(Duration::from_secs(10)); + } + + if let Err(e) = sock.set_tcp_keepalive(&keepalive) { + debug!("Failed to set TCP keepalive on client socket: {}", e); + } + } + + if let Some(buf) = config.recv_buffer_size { + let _ = sock.set_recv_buffer_size(buf); + } + if let Some(buf) = config.send_buffer_size { + let _ = sock.set_send_buffer_size(buf); + } + + Ok(()) +} + +/// Applies socket performance options to a backend server `TcpStream` +pub fn configure_backend_socket(stream: &TcpStream, config: &NetworkConfig) -> io::Result<()> { + if let Err(e) = stream.set_nodelay(config.tcp_nodelay) { + warn!("Failed to set TCP_NODELAY on backend socket: {}", e); + } + + let sock = socket2::SockRef::from(stream); + + if config.tcp_keepalive_secs > 0 { + let mut keepalive = + socket2::TcpKeepalive::new().with_time(Duration::from_secs(config.tcp_keepalive_secs)); + + #[cfg(any(target_os = "linux", target_os = "android"))] + { + keepalive = keepalive.with_interval(Duration::from_secs(10)); + } + + if let Err(e) = sock.set_tcp_keepalive(&keepalive) { + debug!("Failed to set TCP keepalive on backend socket: {}", e); + } + } + + if let Some(buf) = config.recv_buffer_size { + let _ = sock.set_recv_buffer_size(buf); + } + if let Some(buf) = config.send_buffer_size { + let _ = sock.set_send_buffer_size(buf); + } + + Ok(()) +} diff --git a/src/network/prefixed_stream.rs b/src/network/prefixed_stream.rs new file mode 100644 index 0000000..f18988f --- /dev/null +++ b/src/network/prefixed_stream.rs @@ -0,0 +1,81 @@ +use std::io::{self, Cursor}; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, ReadBuf}; + +/// An `AsyncRead` wrapper that yields a prefix of bytes before delegating to an underlying stream. +pub struct PrefixedRead { + prefix: Option>>, + inner: R, +} + +impl PrefixedRead { + pub fn new(inner: R, prefix_bytes: Vec) -> Self { + let prefix = if prefix_bytes.is_empty() { + None + } else { + Some(Cursor::new(prefix_bytes)) + }; + Self { prefix, inner } + } + + pub fn without_prefix(inner: R) -> Self { + Self { + prefix: None, + inner, + } + } +} + +impl AsyncRead for PrefixedRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if let Some(prefix) = &mut self.prefix { + let initial_len = buf.filled().len(); + let n = std::io::Read::read(prefix, buf.initialize_unfilled())?; + buf.advance(n); + + if prefix.position() >= prefix.get_ref().len() as u64 { + self.prefix = None; + } + + if buf.filled().len() > initial_len { + return Poll::Ready(Ok(())); + } + } + + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + + #[tokio::test] + async fn test_prefixed_read() { + let prefix = b"PROXY HEADER ".to_vec(); + let inner = Cursor::new(b"MINECRAFT DATA"); + let mut stream = PrefixedRead::new(inner, prefix); + + let mut output = Vec::new(); + stream.read_to_end(&mut output).await.unwrap(); + + assert_eq!(output, b"PROXY HEADER MINECRAFT DATA"); + } + + #[tokio::test] + async fn test_empty_prefix() { + let inner = Cursor::new(b"ONLY DATA"); + let mut stream = PrefixedRead::without_prefix(inner); + + let mut output = Vec::new(); + stream.read_to_end(&mut output).await.unwrap(); + + assert_eq!(output, b"ONLY DATA"); + } +} diff --git a/src/network/proxy_protocol.rs b/src/network/proxy_protocol.rs new file mode 100644 index 0000000..52fa63b --- /dev/null +++ b/src/network/proxy_protocol.rs @@ -0,0 +1,298 @@ +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use tokio::io::{AsyncRead, AsyncReadExt}; + +pub const PROXY_V2_MAGIC: [u8; 12] = [ + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, +]; + +#[derive(Debug, PartialEq, Eq)] +pub struct ProxyHeaderResult { + pub client_addr: SocketAddr, + pub leftover_bytes: Vec, +} + +/// Reads and parses HAProxy PROXY protocol (v1 or v2) from an incoming stream. +/// Returns the client address and any leftover bytes read past the header. +pub async fn parse_proxy_protocol( + reader: &mut R, + fallback_addr: SocketAddr, +) -> Result { + let mut initial_buf = [0u8; 512]; + let n = reader.read(&mut initial_buf).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Stream closed before reading PROXY header", + )); + } + + if n >= 12 && initial_buf[..12] == PROXY_V2_MAGIC { + return parse_proxy_v2(reader, fallback_addr, &initial_buf[..n]).await; + } + + if n >= 6 && &initial_buf[..6] == b"PROXY " { + return parse_proxy_v1(reader, fallback_addr, &initial_buf[..n]).await; + } + + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Unknown or invalid PROXY protocol header", + )) +} + +/// Parse PROXY protocol version 1 (text format) +async fn parse_proxy_v1( + reader: &mut R, + fallback_addr: SocketAddr, + initial: &[u8], +) -> Result { + let mut buffer = initial.to_vec(); + + while !buffer.windows(2).any(|w| w == b"\r\n") { + if buffer.len() >= 108 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "PROXY v1 header exceeded maximum length (107 bytes)", + )); + } + let mut byte = [0u8; 1]; + let n = reader.read(&mut byte).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "EOF reached while reading PROXY v1 header", + )); + } + buffer.push(byte[0]); + } + + let crlf_pos = buffer + .windows(2) + .position(|w| w == b"\r\n") + .expect("CRLf checked in loop"); + + let line = std::str::from_utf8(&buffer[..crlf_pos]).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "PROXY v1 header is not valid UTF-8", + ) + })?; + + let leftover_bytes = buffer[crlf_pos + 2..].to_vec(); + + let parts: Vec<&str> = line.split(' ').collect(); + if parts.is_empty() || parts[0] != "PROXY" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid PROXY v1 prefix", + )); + } + + if parts.len() == 2 && parts[1] == "UNKNOWN" { + return Ok(ProxyHeaderResult { + client_addr: fallback_addr, + leftover_bytes, + }); + } + + if parts.len() < 6 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Incomplete PROXY v1 header: '{}'", line), + )); + } + + let proto = parts[1]; + let src_ip_str = parts[2]; + let src_port_str = parts[4]; + + match proto { + "TCP4" | "TCP6" => { + let ip: IpAddr = src_ip_str.parse().map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid PROXY v1 IP '{}': {}", src_ip_str, e), + ) + })?; + let port: u16 = src_port_str.parse().map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid PROXY v1 port '{}': {}", src_port_str, e), + ) + })?; + Ok(ProxyHeaderResult { + client_addr: SocketAddr::new(ip, port), + leftover_bytes, + }) + } + "UNKNOWN" => Ok(ProxyHeaderResult { + client_addr: fallback_addr, + leftover_bytes, + }), + other => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Unsupported PROXY v1 protocol: '{}'", other), + )), + } +} + +/// Parse PROXY protocol version 2 (binary format) +async fn parse_proxy_v2( + reader: &mut R, + fallback_addr: SocketAddr, + initial: &[u8], +) -> Result { + let mut header = initial.to_vec(); + + while header.len() < 16 { + let mut buf = [0u8; 16]; + let needed = 16 - header.len(); + let n = reader.read(&mut buf[..needed]).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "EOF reached while reading PROXY v2 fixed header", + )); + } + header.extend_from_slice(&buf[..n]); + } + + let ver_cmd = header[12]; + let fam_proto = header[13]; + let addr_len = u16::from_be_bytes([header[14], header[15]]) as usize; + + let version = ver_cmd >> 4; + let command = ver_cmd & 0x0F; + + if version != 2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Unsupported PROXY v2 version: {}", version), + )); + } + + let total_required = 16 + addr_len; + while header.len() < total_required { + let mut buf = [0u8; 512]; + let needed = (total_required - header.len()).min(buf.len()); + let n = reader.read(&mut buf[..needed]).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "EOF reached while reading PROXY v2 address body", + )); + } + header.extend_from_slice(&buf[..n]); + } + + let leftover_bytes = header[total_required..].to_vec(); + let body = &header[16..total_required]; + + if command == 0x00 { + return Ok(ProxyHeaderResult { + client_addr: fallback_addr, + leftover_bytes, + }); + } + + if command != 0x01 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Unsupported PROXY v2 command: {}", command), + )); + } + + let family = fam_proto >> 4; + let protocol = fam_proto & 0x0F; + + if family == 0x1 && protocol == 0x1 { + if body.len() < 12 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Truncated PROXY v2 IPv4 address block", + )); + } + let src_ip = Ipv4Addr::new(body[0], body[1], body[2], body[3]); + let src_port = u16::from_be_bytes([body[8], body[9]]); + return Ok(ProxyHeaderResult { + client_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port), + leftover_bytes, + }); + } + + if family == 0x2 && protocol == 0x1 { + if body.len() < 36 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Truncated PROXY v2 IPv6 address block", + )); + } + let mut ip_bytes = [0u8; 16]; + ip_bytes.copy_from_slice(&body[0..16]); + let src_ip = Ipv6Addr::from(ip_bytes); + let src_port = u16::from_be_bytes([body[32], body[33]]); + return Ok(ProxyHeaderResult { + client_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port), + leftover_bytes, + }); + } + + Ok(ProxyHeaderResult { + client_addr: fallback_addr, + leftover_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[tokio::test] + async fn test_proxy_v1_ipv4() { + let mut data = Cursor::new(b"PROXY TCP4 198.51.100.22 192.0.2.1 54321 25565\r\nHello"); + let fallback = "127.0.0.1:1234".parse().unwrap(); + let res = parse_proxy_protocol(&mut data, fallback).await.unwrap(); + + assert_eq!( + res.client_addr, + "198.51.100.22:54321".parse::().unwrap() + ); + assert_eq!(res.leftover_bytes, b"Hello"); + } + + #[tokio::test] + async fn test_proxy_v1_unknown() { + let mut data = Cursor::new(b"PROXY UNKNOWN\r\nMinecraftPayload"); + let fallback = "127.0.0.1:1234".parse().unwrap(); + let res = parse_proxy_protocol(&mut data, fallback).await.unwrap(); + + assert_eq!(res.client_addr, fallback); + assert_eq!(res.leftover_bytes, b"MinecraftPayload"); + } + + #[tokio::test] + async fn test_proxy_v2_ipv4() { + let mut payload = Vec::new(); + payload.extend_from_slice(&PROXY_V2_MAGIC); + payload.push(0x21); + payload.push(0x11); + payload.extend_from_slice(&12u16.to_be_bytes()); + payload.extend_from_slice(&[10, 0, 0, 99]); + payload.extend_from_slice(&[192, 168, 1, 1]); + payload.extend_from_slice(&45678u16.to_be_bytes()); + payload.extend_from_slice(&25565u16.to_be_bytes()); + payload.extend_from_slice(b"MC_PACKET"); + + let mut cursor = Cursor::new(payload); + let fallback = "127.0.0.1:1234".parse().unwrap(); + let res = parse_proxy_protocol(&mut cursor, fallback).await.unwrap(); + + assert_eq!( + res.client_addr, + "10.0.0.99:45678".parse::().unwrap() + ); + assert_eq!(res.leftover_bytes, b"MC_PACKET"); + } +} diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 0000000..b0f1176 --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,322 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// Pattern matching for permission nodes. +/// +/// Supports: +/// - `*` matches everything +/// - Exact match (`vine.command.send` matches `vine.command.send`) +/// - Wildcard suffix (`vine.command.*` matches `vine.command.send` and `vine.command.server`) +/// - Case-insensitive matching +pub fn matches_node(pattern: &str, target: &str) -> bool { + let p = pattern.to_ascii_lowercase(); + let t = target.to_ascii_lowercase(); + + if p == "*" || p == t { + return true; + } + + if let Some(prefix) = p.strip_suffix(".*") + && t.starts_with(prefix) + { + // Must match boundary e.g. "vine.command.*" matches "vine.command.send" but not "vine.commander" + if t.len() > prefix.len() && t.as_bytes()[prefix.len()] == b'.' { + return true; + } + } + + false +} + +/// Configuration for a permission group +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupConfig { + /// Groups inherited by this group + #[serde(default)] + pub inherits: Vec, + /// List of permission nodes (e.g. `["vine.command.server", "*", "-vine.command.send"]`) + #[serde(default)] + pub permissions: Vec, +} + +/// User assignment configuration (either a simple list of groups or detailed with direct permissions) +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum UserPermissionConfig { + /// Simple group list: e.g. `"Alex" = ["admin"]` + Groups(Vec), + /// Detailed configuration with both groups and direct permissions + Detailed { + #[serde(default)] + groups: Vec, + #[serde(default)] + permissions: Vec, + }, +} + +impl UserPermissionConfig { + pub fn groups(&self) -> &[String] { + match self { + Self::Groups(groups) => groups, + Self::Detailed { groups, .. } => groups, + } + } + + pub fn direct_permissions(&self) -> &[String] { + match self { + Self::Groups(_) => &[], + Self::Detailed { permissions, .. } => permissions, + } + } +} + +fn default_group_name() -> String { + "default".to_string() +} + +fn default_permission_groups() -> HashMap { + let mut groups = HashMap::new(); + groups.insert( + "default".to_string(), + GroupConfig { + inherits: Vec::new(), + permissions: vec![ + "vine.command.server".to_string(), + "vine.command.glist".to_string(), + "vine.command.list".to_string(), + "vine.command.help".to_string(), + ], + }, + ); + groups.insert( + "admin".to_string(), + GroupConfig { + inherits: vec!["default".to_string()], + permissions: vec![ + "vine.command.send".to_string(), + "vine.admin".to_string(), + "*".to_string(), + ], + }, + ); + groups +} + +/// Configuration for the proxy permission system +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermissionsConfig { + /// Default group applied to every player (default: "default") + #[serde(default = "default_group_name")] + pub default_group: String, + /// Permission groups with inheritance and node lists + #[serde(default = "default_permission_groups")] + pub groups: HashMap, + /// User assignments mapping username to groups or direct permissions + #[serde(default)] + pub users: HashMap, +} + +impl Default for PermissionsConfig { + fn default() -> Self { + let mut users = HashMap::new(); + users.insert( + "Alex".to_string(), + UserPermissionConfig::Groups(vec!["admin".to_string()]), + ); + Self { + default_group: default_group_name(), + groups: default_permission_groups(), + users, + } + } +} + +impl PermissionsConfig { + /// Collects all effective permission strings for a given username, + /// resolving direct permissions, group inheritance, and default group. + pub fn collect_permissions(&self, username: &str) -> Vec { + let mut result = Vec::new(); + let mut user_groups = Vec::new(); + + // 1. Direct user configuration (case-insensitive username lookup) + if let Some((_, user_cfg)) = self + .users + .iter() + .find(|(u, _)| u.eq_ignore_ascii_case(username)) + { + for perm in user_cfg.direct_permissions() { + result.push(perm.clone()); + } + for grp in user_cfg.groups() { + user_groups.push(grp.as_str()); + } + } + + // Always include default_group unless explicitly overridden + if !user_groups + .iter() + .any(|g| g.eq_ignore_ascii_case(&self.default_group)) + { + user_groups.push(&self.default_group); + } + + // 3. Resolve groups with inheritance + let mut visited_groups = HashSet::new(); + let mut queue = VecDeque::from(user_groups); + + while let Some(grp_name) = queue.pop_front() { + let grp_lower = grp_name.to_ascii_lowercase(); + if !visited_groups.insert(grp_lower.clone()) { + continue; + } + + if let Some((_, grp)) = self + .groups + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&grp_lower)) + { + for perm in &grp.permissions { + result.push(perm.clone()); + } + for inherit in &grp.inherits { + queue.push_back(inherit.as_str()); + } + } + } + + result + } + + /// Evaluates whether a user has the specified permission node. + /// + /// Evaluation rules: + /// 1. If any effective permission starts with `-` and matches `node`, access is explicitly denied (returns `false`). + /// 2. If any positive effective permission matches `node` (exact, wildcard `*`, or prefix `foo.*`), returns `true`. + /// 3. Otherwise returns `false`. + pub fn has_permission(&self, username: &str, node: &str) -> bool { + let perms = self.collect_permissions(username); + + // 1. Check explicit negations first + for perm in &perms { + if let Some(negated) = perm.strip_prefix('-') + && matches_node(negated, node) + { + return false; + } + } + + // 2. Check positive permissions + for perm in &perms { + if !perm.starts_with('-') && matches_node(perm, node) { + return true; + } + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_matches_node() { + assert!(matches_node("*", "vine.command.send")); + assert!(matches_node("vine.command.send", "vine.command.send")); + assert!(matches_node("VINE.COMMAND.SEND", "vine.command.send")); + assert!(matches_node("vine.command.*", "vine.command.send")); + assert!(matches_node("vine.command.*", "vine.command.server")); + assert!(!matches_node("vine.command.*", "vine.commander")); + assert!(!matches_node("vine.command.*", "other.command.send")); + } + + #[test] + fn test_default_permissions() { + let config = PermissionsConfig::default(); + + // Regular player in default group + assert!(config.has_permission("Steve", "vine.command.server")); + assert!(config.has_permission("Steve", "vine.command.glist")); + assert!(config.has_permission("Steve", "vine.command.list")); + assert!(config.has_permission("Steve", "vine.command.help")); + assert!(!config.has_permission("Steve", "vine.command.send")); + assert!(!config.has_permission("Steve", "vine.admin")); + + // Alex in admin group + assert!(config.has_permission("Alex", "vine.command.send")); + assert!(config.has_permission("alex", "vine.command.send")); // case-insensitive + assert!(config.has_permission("Alex", "vine.command.server")); + assert!(config.has_permission("Alex", "any.random.node")); // * wildcard + } + + #[test] + fn test_negated_permissions() { + let mut config = PermissionsConfig::default(); + let mut groups = HashMap::new(); + groups.insert( + "moderator".to_string(), + GroupConfig { + inherits: vec!["default".to_string()], + permissions: vec![ + "vine.command.*".to_string(), + "-vine.command.send".to_string(), + ], + }, + ); + config.groups = groups; + config.users.insert( + "ModUser".to_string(), + UserPermissionConfig::Groups(vec!["moderator".to_string()]), + ); + + assert!(config.has_permission("ModUser", "vine.command.server")); + assert!(config.has_permission("ModUser", "vine.command.glist")); + // Explicitly negated + assert!(!config.has_permission("ModUser", "vine.command.send")); + } + + #[test] + fn test_circular_group_inheritance_does_not_loop() { + let mut config = PermissionsConfig::default(); + let mut groups = HashMap::new(); + groups.insert( + "group_a".to_string(), + GroupConfig { + inherits: vec!["group_b".to_string()], + permissions: vec!["node.a".to_string()], + }, + ); + groups.insert( + "group_b".to_string(), + GroupConfig { + inherits: vec!["group_a".to_string()], + permissions: vec!["node.b".to_string()], + }, + ); + config.groups = groups; + config.users.insert( + "User1".to_string(), + UserPermissionConfig::Groups(vec!["group_a".to_string()]), + ); + + assert!(config.has_permission("User1", "node.a")); + assert!(config.has_permission("User1", "node.b")); + } + + #[test] + fn test_detailed_user_config() { + let mut config = PermissionsConfig::default(); + config.users.insert( + "CustomUser".to_string(), + UserPermissionConfig::Detailed { + groups: vec!["default".to_string()], + permissions: vec!["custom.perm".to_string()], + }, + ); + + assert!(config.has_permission("CustomUser", "custom.perm")); + assert!(config.has_permission("CustomUser", "vine.command.server")); + assert!(!config.has_permission("CustomUser", "vine.command.send")); + } +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..18d1ab2 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::time::Duration; + +use tokio::sync::broadcast; +use tracing::{debug, error, info, warn}; + +use crate::auth::{Authenticator, KeyStore}; +use crate::backend::Router; +use crate::client::ClientHandler; +use crate::command::{CommandDispatcher, CommandSender, ProxyCommandSource}; +use crate::config::Config; +use crate::network; +use crate::security::ConnectionRateLimiter; +use crate::session::SessionManager; +use crate::status::{StatusHandler, StatusIconManager}; +use crate::telemetry; + +pub struct ProxyServer { + config: Arc, + keystore: Arc, + authenticator: Arc, + rate_limiter: Arc, + status_handler: Arc, + router: Arc, + online_players: Arc, + session_manager: Arc, + command_dispatcher: Arc, +} + +impl ProxyServer { + pub fn new(config: Config) -> Result> { + let config = Arc::new(config); + let keystore = Arc::new(KeyStore::new()?); + let authenticator = Arc::new(Authenticator::new()); + + let rate_limiter = Arc::new(ConnectionRateLimiter::new( + config.security.rate_limit_enabled, + config.security.max_connections_per_sec, + config.security.connection_burst, + )); + + let online_players = Arc::new(AtomicUsize::new(0)); + let session_manager = Arc::new(SessionManager::new()); + let command_dispatcher = Arc::new(CommandDispatcher::new()); + + let server_icons: HashMap = config + .servers + .iter() + .filter_map(|(name, s)| s.icon_path.as_ref().map(|p| (name.clone(), p.clone()))) + .collect(); + + let icon_manager = Arc::new(StatusIconManager::new( + config.motd.icon_path.clone(), + server_icons, + config.routing.forced_hosts.clone(), + config.routing.default_server.clone(), + )); + + let status_handler = Arc::new(StatusHandler::new( + config.motd.clone(), + online_players.clone(), + icon_manager, + config.servers.clone(), + config.routing.forced_hosts.clone(), + )); + + let router = Arc::new(Router::new(config.routing.clone(), config.servers.clone())); + + Ok(Self { + config, + keystore, + authenticator, + rate_limiter, + status_handler, + router, + online_players, + session_manager, + command_dispatcher, + }) + } + + /// Run the proxy listener loop + pub async fn run( + &self, + mut shutdown_rx: broadcast::Receiver<()>, + ) -> Result<(), Box> { + let bind_addr = &self.config.server.bind_address; + let listener = network::create_tcp_listener(bind_addr, &self.config.network)?; + + info!("====================================================="); + info!(" Vine Minecraft Proxy is listening on {}", bind_addr); + info!(" Online mode: {}", self.config.server.online_mode); + info!(" Multi-version support: 1.7.2 through 1.21.x / 26.x"); + info!(" Networking options:"); + info!(" - TCP_NODELAY: {}", self.config.network.tcp_nodelay); + info!( + " - TCP KeepAlive: {}s", + self.config.network.tcp_keepalive_secs + ); + info!( + " - PROXY Protocol v1/v2: {}", + self.config.network.proxy_protocol + ); + info!( + " - Handshake timeout: {}s", + self.config.network.client_handshake_timeout_secs + ); + info!( + " - Login timeout: {}s", + self.config.network.client_login_timeout_secs + ); + info!( + " - Backend connect timeout: {}s", + self.config.network.backend_connect_timeout_secs + ); + info!( + " - Compression level: {} (threshold: {} bytes)", + self.config.server.compression_level, self.config.server.compression_threshold + ); + info!( + " Configured backend servers: {}", + self.config.servers.len() + ); + for (name, server) in &self.config.servers { + let icon_info = server.icon_path.as_deref().unwrap_or("none"); + info!( + " - [{}] {} (Forwarding: {:?}, Icon: {})", + name, server.address, server.forwarding, icon_info + ); + } + info!(" Default route: '{}'", self.config.routing.default_server); + if self.config.telemetry.enabled { + info!( + " Telemetry: enabled (endpoint: {}, interval: {}s)", + self.config.telemetry.endpoint, self.config.telemetry.interval_secs + ); + } else { + info!(" Telemetry: disabled"); + } + if self.config.bedrock.enabled { + info!( + " Bedrock Edition: enabled (listening on UDP {}, v{} / proto {})", + self.config.bedrock.bind_address, + self.config.bedrock.version_name, + self.config.bedrock.protocol_version + ); + } else { + info!(" Bedrock Edition: disabled"); + } + info!("====================================================="); + + let telemetry_handle = telemetry::start_telemetry( + self.config.clone(), + self.online_players.clone(), + shutdown_rx.resubscribe(), + ); + + if self.config.bedrock.enabled { + let bedrock_cfg = self.config.bedrock.clone(); + let online_players = self.online_players.clone(); + let bedrock_shutdown = shutdown_rx.resubscribe(); + + tokio::spawn(async move { + if let Err(e) = crate::bedrock::run_bedrock_listener( + bedrock_cfg, + online_players, + bedrock_shutdown, + ) + .await + { + error!("Bedrock listener terminated with error: {}", e); + } + }); + } + + let console_dispatcher = self.command_dispatcher.clone(); + let console_config = self.config.clone(); + let console_session_manager = self.session_manager.clone(); + let (stdin_tx, mut stdin_rx) = tokio::sync::mpsc::unbounded_channel(); + + std::thread::Builder::new() + .name("console-stdin".to_string()) + .spawn(move || { + let stdin = std::io::stdin(); + let mut line = String::new(); + while let Ok(n) = stdin.read_line(&mut line) { + if n == 0 { + break; + } + if stdin_tx.send(line.trim().to_string()).is_err() { + break; + } + line.clear(); + } + }) + .ok(); + + let mut stdin_shutdown = shutdown_rx.resubscribe(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = stdin_shutdown.recv() => break, + cmd_opt = stdin_rx.recv() => { + match cmd_opt { + Some(trimmed) => { + if !trimmed.is_empty() { + let source = ProxyCommandSource { + sender: CommandSender::Console, + config: console_config.clone(), + session_manager: console_session_manager.clone(), + }; + console_dispatcher.handle_command(&source, &trimmed); + } + } + None => break, + } + } + } + } + }); + + loop { + tokio::select! { + accept_res = listener.accept() => { + match accept_res { + Ok((stream, peer_addr)) => { + let config = self.config.clone(); + let keystore = self.keystore.clone(); + let authenticator = self.authenticator.clone(); + let status_handler = self.status_handler.clone(); + let router = self.router.clone(); + let online_players = self.online_players.clone(); + let rate_limiter = self.rate_limiter.clone(); + let session_manager = self.session_manager.clone(); + let command_dispatcher = self.command_dispatcher.clone(); + + tokio::spawn(async move { + if let Err(e) = network::configure_client_socket(&stream, &config.network) { + debug!("[{}] Failed to configure client socket: {}", peer_addr, e); + } + + let mut stream = stream; + let mut effective_addr = peer_addr; + let mut leftover_bytes = Vec::new(); + + if config.network.proxy_protocol { + match network::parse_proxy_protocol(&mut stream, peer_addr).await { + Ok(res) => { + effective_addr = res.client_addr; + leftover_bytes = res.leftover_bytes; + debug!("[{}] PROXY protocol resolved client IP: {}", peer_addr, effective_addr); + } + Err(err) => { + warn!("[{}] Dropped connection: PROXY protocol error: {}", peer_addr, err); + return; + } + } + } + + if !rate_limiter.check_connection(&effective_addr.ip()) { + warn!("[{}] Dropped connection: Rate limit exceeded", effective_addr); + return; + } + + let handler = ClientHandler::new( + effective_addr, + config, + keystore, + authenticator, + status_handler, + router, + online_players, + session_manager, + command_dispatcher, + ); + + if let Err(err) = handler.handle(stream, leftover_bytes).await { + debug!("[{}] Connection closed: {}", effective_addr, err); + } + }); + } + Err(e) => { + error!("TCP listener accept error: {}", e); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } + _ = shutdown_rx.recv() => { + info!("Shutting down Vine proxy listener..."); + break; + } + } + } + + info!("Vine proxy has stopped accepting connections."); + + if let Some(handle) = telemetry_handle { + let timeout_secs = self.config.server.shutdown_timeout_secs.max(1); + let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), handle).await; + } + + Ok(()) + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..1438056 --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,5 @@ +pub mod rate_limiter; +pub mod sanitizer; + +pub use rate_limiter::{ConnectionRateLimiter, PacketRateLimiter}; +pub use sanitizer::Sanitizer; diff --git a/src/security/rate_limiter.rs b/src/security/rate_limiter.rs new file mode 100644 index 0000000..fdc4cc8 --- /dev/null +++ b/src/security/rate_limiter.rs @@ -0,0 +1,130 @@ +use std::{ + collections::HashMap, + net::IpAddr, + sync::RwLock, + time::{Duration, Instant}, +}; + +struct TokenBucket { + tokens: f64, + last_update: Instant, +} + +impl TokenBucket { + fn new(burst: f64) -> Self { + Self { + tokens: burst, + last_update: Instant::now(), + } + } + + fn try_consume(&mut self, rate_per_sec: f64, burst: f64) -> bool { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update).as_secs_f64(); + self.last_update = now; + + self.tokens = (self.tokens + elapsed * rate_per_sec).min(burst); + + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + +pub struct ConnectionRateLimiter { + enabled: bool, + rate_per_sec: f64, + burst: f64, + buckets: RwLock>, +} + +impl ConnectionRateLimiter { + pub fn new(enabled: bool, rate_per_sec: u32, burst: u32) -> Self { + Self { + enabled, + rate_per_sec: rate_per_sec as f64, + burst: burst as f64, + buckets: RwLock::new(HashMap::new()), + } + } + + /// Returns true if connection is allowed, false if rate limited + pub fn check_connection(&self, ip: &IpAddr) -> bool { + if !self.enabled { + return true; + } + + let mut buckets = match self.buckets.write() { + Ok(b) => b, + Err(_) => return true, + }; + + if buckets.len() > 10_000 { + let now = Instant::now(); + buckets.retain(|_, b| now.duration_since(b.last_update) < Duration::from_secs(60)); + } + + let bucket = buckets + .entry(*ip) + .or_insert_with(|| TokenBucket::new(self.burst)); + + bucket.try_consume(self.rate_per_sec, self.burst) + } +} + +/// Per-connection packet rate limiter to detect and prevent packet flood attacks +pub struct PacketRateLimiter { + max_packets_per_sec: u32, + packet_count: u32, + window_start: Instant, +} + +impl PacketRateLimiter { + pub fn new(max_packets_per_sec: u32) -> Self { + Self { + max_packets_per_sec, + packet_count: 0, + window_start: Instant::now(), + } + } + + /// Record a packet and return false if limit exceeded (flood detected) + pub fn record_packet(&mut self) -> bool { + let now = Instant::now(); + if now.duration_since(self.window_start) >= Duration::from_secs(1) { + self.window_start = now; + self.packet_count = 1; + return true; + } + + self.packet_count += 1; + self.packet_count <= self.max_packets_per_sec + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_packet_rate_limiter() { + let mut limiter = PacketRateLimiter::new(3); + assert!(limiter.record_packet()); + assert!(limiter.record_packet()); + assert!(limiter.record_packet()); + assert!(!limiter.record_packet()); + } + + #[test] + fn test_connection_rate_limiter() { + let limiter = ConnectionRateLimiter::new(true, 2, 2); + let ip: IpAddr = "192.168.1.100".parse().unwrap(); + + assert!(limiter.check_connection(&ip)); + assert!(limiter.check_connection(&ip)); + assert!(!limiter.check_connection(&ip)); + } +} diff --git a/src/security/sanitizer.rs b/src/security/sanitizer.rs new file mode 100644 index 0000000..8e469f9 --- /dev/null +++ b/src/security/sanitizer.rs @@ -0,0 +1,65 @@ +pub struct Sanitizer; + +impl Sanitizer { + /// Validates Minecraft username according to Mojang specifications: + /// - Length: between 2 and 16 characters + /// - Characters: ASCII alphanumeric (a-z, A-Z, 0-9) and underscore (_) + pub fn is_valid_username(name: &str) -> bool { + let len = name.len(); + if !(2..=16).contains(&len) { + return false; + } + + name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + } + + /// Sanitizes virtual host string: + /// - Strips null bytes (`\0`) which could break BungeeCord packet format + /// - Truncates to max 255 characters + /// - Strips control characters + pub fn sanitize_host(host: &str) -> String { + let mut clean = String::with_capacity(host.len().min(255)); + for ch in host.chars() { + if clean.len() >= 255 { + break; + } + if ch != '\0' && !ch.is_control() { + clean.push(ch); + } + } + clean + } + + /// Validates packet size against maximum limit + pub fn is_valid_packet_size(size: usize, max_allowed: usize) -> bool { + size <= max_allowed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_username_validation() { + assert!(Sanitizer::is_valid_username("Alex")); + assert!(Sanitizer::is_valid_username("Steve_99")); + assert!(Sanitizer::is_valid_username("a_")); + + assert!(!Sanitizer::is_valid_username("")); + assert!(!Sanitizer::is_valid_username("a")); + assert!(!Sanitizer::is_valid_username("A_very_long_username_here")); + assert!(!Sanitizer::is_valid_username("Player!@#")); + assert!(!Sanitizer::is_valid_username("Player with space")); + assert!(!Sanitizer::is_valid_username("Player\0Null")); + } + + #[test] + fn test_host_sanitizer() { + assert_eq!(Sanitizer::sanitize_host("mc.example.com"), "mc.example.com"); + assert_eq!( + Sanitizer::sanitize_host("mc.example.com\0malicious_ip\0uuid"), + "mc.example.commalicious_ipuuid" + ); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..7aa06e7 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use tokio::sync::mpsc; +use uuid::Uuid; + +use pumpkin_util::text::TextComponent; + +/// Actions that can be dispatched to an active player's connection +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlayerAction { + /// Request connecting the player to a target backend server in-proxy + Connect { server_name: String }, + /// Request transfer of the player to a target host and port + Transfer { host: String, port: u16 }, + /// Send a system chat message to the player + Message(TextComponent), +} + +/// Represents an active connected player session on the proxy +#[derive(Clone)] +pub struct PlayerSession { + pub username: String, + pub uuid: Uuid, + pub current_server: String, + pub action_tx: mpsc::UnboundedSender, +} + +/// Thread-safe registry of connected player sessions +pub struct SessionManager { + sessions: RwLock>, +} + +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +} + +impl SessionManager { + pub fn new() -> Self { + Self { + sessions: RwLock::new(HashMap::new()), + } + } + + /// Registers a new active player session + pub fn register(&self, session: PlayerSession) { + if let Ok(mut lock) = self.sessions.write() { + lock.insert(session.username.to_ascii_lowercase(), session); + } + } + + /// Unregisters an active session by username + pub fn unregister(&self, username: &str) -> Option { + if let Ok(mut lock) = self.sessions.write() { + lock.remove(&username.to_ascii_lowercase()) + } else { + None + } + } + + /// Updates the current server for an active session + pub fn update_server(&self, username: &str, new_server: &str) { + if let Ok(mut lock) = self.sessions.write() + && let Some(session) = lock.get_mut(&username.to_ascii_lowercase()) + { + session.current_server = new_server.to_string(); + } + } + + /// Looks up an active session by username (case-insensitive) + pub fn get_session(&self, username: &str) -> Option { + if let Ok(lock) = self.sessions.read() { + lock.get(&username.to_ascii_lowercase()).cloned() + } else { + None + } + } + + /// Returns a list of all active sessions + pub fn list_sessions(&self) -> Vec { + if let Ok(lock) = self.sessions.read() { + lock.values().cloned().collect() + } else { + Vec::new() + } + } + + /// Returns the number of currently registered sessions + pub fn count(&self) -> usize { + if let Ok(lock) = self.sessions.read() { + lock.len() + } else { + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_registration_and_lookup() { + let manager = SessionManager::new(); + assert_eq!(manager.count(), 0); + + let (tx, _rx) = mpsc::unbounded_channel(); + let session = PlayerSession { + username: "Steve".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: tx, + }; + + manager.register(session); + assert_eq!(manager.count(), 1); + + let found = manager.get_session("steve"); + assert!(found.is_some()); + assert_eq!(found.unwrap().username, "Steve"); + + let removed = manager.unregister("STEVE"); + assert!(removed.is_some()); + assert_eq!(manager.count(), 0); + assert!(manager.get_session("Steve").is_none()); + } + + #[test] + fn test_session_update_server() { + let manager = SessionManager::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + let session = PlayerSession { + username: "Alex".to_string(), + uuid: Uuid::new_v4(), + current_server: "lobby".to_string(), + action_tx: tx, + }; + manager.register(session); + assert_eq!(manager.get_session("Alex").unwrap().current_server, "lobby"); + + manager.update_server("alex", "survival"); + assert_eq!( + manager.get_session("Alex").unwrap().current_server, + "survival" + ); + } +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..a4ab0ee --- /dev/null +++ b/src/status.rs @@ -0,0 +1,265 @@ +pub mod icon; + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use pumpkin_protocol::java::client::status::{CPingResponse, CStatusResponse}; +use pumpkin_util::version::JavaMinecraftVersion; +use serde::Serialize; +use uuid::Uuid; + +use crate::config::{BackendConfig, MotdConfig}; +pub use icon::{StatusIcon, StatusIconManager}; + +#[derive(Serialize)] +struct StatusJson<'a> { + version: StatusVersion<'a>, + players: StatusPlayers<'a>, + description: StatusDescription, + #[serde(rename = "enforceSecureChat")] + enforce_secure_chat: bool, + #[serde(skip_serializing_if = "Option::is_none")] + favicon: Option<&'a str>, +} + +#[derive(Serialize)] +struct StatusVersion<'a> { + name: &'a str, + protocol: i32, +} + +#[derive(Serialize)] +struct StatusPlayers<'a> { + max: u32, + online: usize, + sample: Vec>, +} + +#[derive(Serialize)] +struct PlayerSample<'a> { + name: &'a str, + id: String, +} + +#[derive(Serialize)] +struct StatusDescription { + text: String, +} + +pub struct StatusHandler { + motd: MotdConfig, + online_players: Arc, + icon_manager: Arc, + servers: HashMap, + forced_hosts: HashMap, +} + +impl StatusHandler { + pub fn new( + motd: MotdConfig, + online_players: Arc, + icon_manager: Arc, + servers: HashMap, + forced_hosts: HashMap, + ) -> Self { + Self { + motd, + online_players, + icon_manager, + servers, + forced_hosts, + } + } + + /// Build the JSON status response for the given client version and optional virtual host + pub fn build_status_response( + &self, + client_version: JavaMinecraftVersion, + virtual_host: Option<&str>, + ) -> String { + let online = self.online_players.load(Ordering::Relaxed); + + let motd_text = self.resolve_motd_text(virtual_host); + + let icon_uri = self.icon_manager.get_icon_for_host(virtual_host); + + let protocol = if client_version == JavaMinecraftVersion::Unknown { + self.motd.protocol_version + } else { + client_version.protocol_version() + }; + + let sample: Vec = if self.motd.show_sample_players { + self.motd + .sample_players + .iter() + .map(|name| PlayerSample { + name, + id: Uuid::nil().to_string(), + }) + .collect() + } else { + Vec::new() + }; + + let status = StatusJson { + version: StatusVersion { + name: &self.motd.version_name, + protocol, + }, + players: StatusPlayers { + max: self.motd.max_players, + online, + sample, + }, + description: StatusDescription { text: motd_text }, + enforce_secure_chat: false, + favicon: icon_uri.as_deref(), + }; + + serde_json::to_string(&status).unwrap_or_else(|_| "{}".to_string()) + } + + /// Resolves MOTD description for the virtual host or falls back to global default + fn resolve_motd_text(&self, host: Option<&str>) -> String { + if let Some(h) = host { + let clean_host = h.split(':').next().unwrap_or(h).trim(); + + let target_server: Option<&str> = self + .forced_hosts + .get(clean_host) + .map(|s| s.as_str()) + .or_else(|| { + if self.servers.contains_key(clean_host) { + Some(clean_host) + } else { + None + } + }); + + if let Some(server_name) = target_server + && let Some(cfg) = self.servers.get(server_name) + && (cfg.motd_line1.is_some() || cfg.motd_line2.is_some()) + { + let l1 = cfg.motd_line1.as_deref().unwrap_or(&self.motd.line1); + let l2 = cfg.motd_line2.as_deref().unwrap_or(&self.motd.line2); + return format!("{}\n{}", l1, l2); + } + } + + format!("{}\n{}", self.motd.line1, self.motd.line2) + } + + /// Create CStatusResponse packet + pub fn create_status_packet( + &self, + client_version: JavaMinecraftVersion, + virtual_host: Option<&str>, + ) -> CStatusResponse { + let json = self.build_status_response(client_version, virtual_host); + CStatusResponse::new(json) + } + + /// Create CPingResponse packet + pub fn create_pong_packet(payload: i64) -> CPingResponse { + CPingResponse::new(payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ForwardingMode; + + #[test] + fn test_status_response_with_icon_and_virtual_hosts() { + let temp_icon_path = std::env::temp_dir().join("vine_test_status_icon.png"); + let mut png_bytes = Vec::new(); + png_bytes.extend_from_slice(&crate::status::icon::PNG_SIGNATURE); + png_bytes.extend_from_slice(&13u32.to_be_bytes()); + png_bytes.extend_from_slice(b"IHDR"); + png_bytes.extend_from_slice(&64u32.to_be_bytes()); + png_bytes.extend_from_slice(&64u32.to_be_bytes()); + png_bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + png_bytes.extend_from_slice(&[0, 0, 0, 0]); + let _ = std::fs::write(&temp_icon_path, png_bytes); + + let icon_path_str = temp_icon_path.to_string_lossy().to_string(); + + let mut servers = HashMap::new(); + servers.insert( + "survival".to_string(), + BackendConfig { + address: "127.0.0.1:25567".to_string(), + forwarding: ForwardingMode::None, + secret: None, + icon_path: None, + motd_line1: Some("Survival Line 1".to_string()), + motd_line2: Some("Survival Line 2".to_string()), + }, + ); + + let mut forced_hosts = HashMap::new(); + forced_hosts.insert("mc.survival.com".to_string(), "survival".to_string()); + + let icon_mgr = Arc::new(StatusIconManager::new( + Some(icon_path_str.clone()), + HashMap::new(), + forced_hosts.clone(), + "lobby".to_string(), + )); + + let motd = MotdConfig { + line1: "Global Line 1".to_string(), + line2: "Global Line 2".to_string(), + max_players: 50, + version_name: "Vine Proxy".to_string(), + protocol_version: 776, + sample_players: vec!["SamplePlayer".to_string()], + icon_path: Some(icon_path_str), + show_sample_players: true, + }; + + let online = Arc::new(AtomicUsize::new(5)); + let handler = StatusHandler::new(motd, online, icon_mgr, servers, forced_hosts); + + let global_json = handler.build_status_response(JavaMinecraftVersion::V_26_2, None); + assert!(global_json.contains("Global Line 1\\nGlobal Line 2")); + assert!(global_json.contains("\"online\":5")); + assert!(global_json.contains("\"favicon\":\"data:image/png;base64,")); + + let vhost_json = + handler.build_status_response(JavaMinecraftVersion::V_26_2, Some("mc.survival.com")); + assert!(vhost_json.contains("Survival Line 1\\nSurvival Line 2")); + + let _ = std::fs::remove_file(&temp_icon_path); + } + + #[test] + fn test_status_response_default_without_icon() { + let icon_mgr = Arc::new(StatusIconManager::new( + None, + HashMap::new(), + HashMap::new(), + "lobby".to_string(), + )); + + let motd = MotdConfig { + line1: "Vine Proxy".to_string(), + line2: "No Icon".to_string(), + max_players: 20, + version_name: "Vine".to_string(), + protocol_version: 776, + sample_players: Vec::new(), + icon_path: None, + show_sample_players: false, + }; + + let online = Arc::new(AtomicUsize::new(0)); + let handler = StatusHandler::new(motd, online, icon_mgr, HashMap::new(), HashMap::new()); + + let json = handler.build_status_response(JavaMinecraftVersion::V_26_2, None); + assert!(!json.contains("favicon")); + } +} diff --git a/src/status/icon.rs b/src/status/icon.rs new file mode 100644 index 0000000..24bdee1 --- /dev/null +++ b/src/status/icon.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; + +use base64::prelude::*; +use tracing::{debug, info, warn}; + +pub const PNG_SIGNATURE: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + +#[derive(Clone, Debug)] +pub struct StatusIcon { + pub path: PathBuf, + pub data_uri: Arc, + pub width: u32, + pub height: u32, + pub last_modified: Option, +} + +impl StatusIcon { + /// Loads, validates, and encodes a PNG file into a Minecraft status favicon data URI + pub fn load_from_file>(path: P) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .map_err(|e| format!("Failed to read icon file '{}': {}", path.display(), e))?; + Self::from_bytes(bytes, path.to_path_buf()) + } + + /// Validates PNG bytes and builds a StatusIcon + pub fn from_bytes(bytes: Vec, path: PathBuf) -> Result { + if bytes.len() < 24 { + return Err(format!( + "Icon file '{}' is too small ({} bytes)", + path.display(), + bytes.len() + )); + } + + if bytes[..8] != PNG_SIGNATURE { + return Err(format!( + "Icon file '{}' does not have a valid PNG signature (expected PNG format)", + path.display() + )); + } + + let chunk_type = &bytes[12..16]; + if chunk_type != b"IHDR" { + return Err(format!( + "Icon file '{}' is missing valid IHDR chunk", + path.display() + )); + } + + let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + + if width != 64 || height != 64 { + warn!( + "Status icon '{}' dimensions are {}x{} pixels. Minecraft requires exactly 64x64 PNG images for server list icons.", + path.display(), + width, + height + ); + } + + let b64 = BASE64_STANDARD.encode(&bytes); + let data_uri: Arc = format!("data:image/png;base64,{}", b64).into(); + + let last_modified = fs::metadata(&path).and_then(|m| m.modified()).ok(); + + info!( + "Loaded status icon from '{}' ({}x{} pixels, {} bytes base64)", + path.display(), + width, + height, + data_uri.len() + ); + + Ok(Self { + path, + data_uri, + width, + height, + last_modified, + }) + } +} + +/// Manages status images for the proxy, supporting global icons, per-server icons, +/// and automatic hot-reloading when image files on disk change. +pub struct StatusIconManager { + default_icon_path: Option, + server_icons: HashMap, + forced_hosts: HashMap, + default_server: String, + cache: RwLock>, +} + +impl StatusIconManager { + pub fn new( + configured_default_path: Option, + server_icons: HashMap, + forced_hosts: HashMap, + default_server: String, + ) -> Self { + let default_icon_path = match configured_default_path { + Some(p) if !p.trim().is_empty() => Some(PathBuf::from(p)), + _ => None, + }; + + let server_icon_paths: HashMap = server_icons + .into_iter() + .map(|(k, v)| (k, PathBuf::from(v))) + .collect(); + + let manager = Self { + default_icon_path, + server_icons: server_icon_paths, + forced_hosts, + default_server, + cache: RwLock::new(HashMap::new()), + }; + + manager.preload_all(); + manager + } + + fn preload_all(&self) { + if let Some(path) = &self.default_icon_path { + self.load_or_reload(path); + } + for path in self.server_icons.values() { + self.load_or_reload(path); + } + } + + /// Looks up and returns the cached base64 data URI for the given virtual host or default + pub fn get_icon_for_host(&self, host: Option<&str>) -> Option> { + let target_path = self.resolve_icon_path(host)?; + self.load_or_reload(&target_path) + } + + /// Resolves which icon path applies based on the virtual host or default + fn resolve_icon_path(&self, host: Option<&str>) -> Option { + if let Some(h) = host { + let clean_host = h.split(':').next().unwrap_or(h).trim(); + + if let Some(target_server) = self.forced_hosts.get(clean_host) + && let Some(path) = self.server_icons.get(target_server) + { + return Some(path.clone()); + } + + if let Some(path) = self.server_icons.get(clean_host) { + return Some(path.clone()); + } + } + + if let Some(path) = self.server_icons.get(&self.default_server) { + return Some(path.clone()); + } + + self.default_icon_path.clone() + } + + /// Retrieves cached icon or reloads from disk if file was modified + fn load_or_reload(&self, path: &Path) -> Option> { + if let Ok(cache) = self.cache.read() + && let Some(cached) = cache.get(path) + { + if let Ok(meta) = fs::metadata(path) { + if let Ok(mtime) = meta.modified() + && cached.last_modified == Some(mtime) + { + return Some(cached.data_uri.clone()); + } + } else { + return Some(cached.data_uri.clone()); + } + } + + match StatusIcon::load_from_file(path) { + Ok(icon) => { + let uri = icon.data_uri.clone(); + if let Ok(mut cache) = self.cache.write() { + cache.insert(path.to_path_buf(), icon); + } + Some(uri) + } + Err(e) => { + debug!("Could not load status icon '{}': {}", path.display(), e); + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_png(w: u32, h: u32) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&PNG_SIGNATURE); + bytes.extend_from_slice(&13u32.to_be_bytes()); + bytes.extend_from_slice(b"IHDR"); + bytes.extend_from_slice(&w.to_be_bytes()); + bytes.extend_from_slice(&h.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0, 0]); + bytes + } + + #[test] + fn test_valid_png_parsing() { + let png = create_test_png(64, 64); + let icon = StatusIcon::from_bytes(png, PathBuf::from("test.png")).unwrap(); + assert_eq!(icon.width, 64); + assert_eq!(icon.height, 64); + assert!(icon.data_uri.starts_with("data:image/png;base64,")); + } + + #[test] + fn test_invalid_signature() { + let bad_bytes = vec![0u8; 32]; + let res = StatusIcon::from_bytes(bad_bytes, PathBuf::from("test.png")); + assert!(res.is_err()); + } + + #[test] + fn test_icon_manager_resolution() { + let mut server_icons = HashMap::new(); + server_icons.insert("survival".to_string(), "survival.png".to_string()); + let mut forced_hosts = HashMap::new(); + forced_hosts.insert("mc.survival.com".to_string(), "survival".to_string()); + + let manager = StatusIconManager::new( + Some("default.png".to_string()), + server_icons, + forced_hosts, + "lobby".to_string(), + ); + + let path = manager.resolve_icon_path(Some("mc.survival.com")).unwrap(); + assert_eq!(path, PathBuf::from("survival.png")); + + let def_path = manager.resolve_icon_path(Some("other.com")).unwrap(); + assert_eq!(def_path, PathBuf::from("default.png")); + } + + #[test] + fn test_icon_manager_default_none() { + let manager = + StatusIconManager::new(None, HashMap::new(), HashMap::new(), "lobby".to_string()); + + assert!(manager.resolve_icon_path(None).is_none()); + assert!( + manager + .resolve_icon_path(Some("play.example.com")) + .is_none() + ); + assert!(manager.get_icon_for_host(None).is_none()); + } +} diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..9c1aa22 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,862 @@ +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use pumpkin_util::version::JavaMinecraftVersion; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +use crate::config::Config; + +/// Filename used to persist the telemetry identity key across restarts. +pub const IDENTITY_KEY_PATH: &str = ".pumpkin/identity.key"; + +/// Fallback filename used to persist the telemetry identity key across restarts. +pub const FALLBACK_IDENTITY_KEY_PATH: &str = "telemetry_key.bin"; + +/// Secondary fallback filename for proxy identity key. +pub const VINE_IDENTITY_KEY_PATH: &str = ".vine/identity.key"; + +/// HTTP header name for the Ed25519 public key in lowercase hex format. +pub const HEADER_PUBLIC_KEY: &str = "X-Telemetry-Public-Key"; + +/// HTTP header name for the Ed25519 signature in lowercase hex format. +pub const HEADER_SIGNATURE: &str = "X-Telemetry-Signature"; + +/// HTTP header name for the request timestamp in Unix seconds. +pub const HEADER_TIMESTAMP: &str = "X-Telemetry-Timestamp"; + +/// Maximum allowable clock drift between telemetry client and server (300 seconds / 5 minutes). +pub const MAX_CLOCK_DRIFT_SECS: u64 = 300; + +/// Information about an active plugin on the proxy / server. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct PluginInfo { + /// The name of the plugin. + pub name: String, + /// The version of the plugin. + pub version: String, +} + +/// Type alias for plugin telemetry information. +pub type PluginTelemetryInfo = PluginInfo; + +/// The telemetry heartbeat payload transmitted to the backend. +/// Matches the Pumpkin Market API schema: https://market.pumpkinmc.org/api/docs/#/ +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HeartbeatPayload { + /// The server software implementation type ("vine"). + pub server_type: String, + /// The Vine proxy software version. + pub server_version: String, + /// The Minecraft game version string. + pub minecraft_version: String, + /// The network protocol version number. + pub protocol_version: i32, + /// Current number of connected players. + pub online_players: usize, + /// Maximum number of players allowed. + pub max_players: u32, + /// Host operating system name. + pub os: String, + /// Host CPU architecture. + pub arch: String, + /// Number of available CPU cores. + pub cpu_cores: usize, + /// CPU model name or brand. + pub cpu_model: String, + /// RAM allocated/used by the system in MiB. + pub ram_allocated_mb: u64, + /// Total physical RAM available to the system in MiB. + pub total_ram_mb: u64, + /// List of active plugins. + pub plugins: Vec, + /// Whether the server has opted into public directory listing. + pub is_public: bool, + /// Public name of the server if opted into public directory listing. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_name: Option, +} + +/// The response payload received from the telemetry backend. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HeartbeatResponse { + /// Response status ("ok", "throttled", etc.). + pub status: String, + /// Status or error message from the backend. + #[serde(default)] + pub message: String, + /// Confirmed server public key acknowledged by the backend. + #[serde(default)] + pub server_public_key: String, + /// Recommended seconds to wait before the next heartbeat. + #[serde(default)] + pub next_ping_seconds: u32, +} + +/// Computes the signed data byte layout: `[timestamp_ascii_bytes] + [b'.'] + [raw_json_body_bytes]`. +#[must_use] +pub fn compute_signed_data(timestamp_str: &str, body_bytes: &[u8]) -> Vec { + let mut signed_data = Vec::with_capacity(timestamp_str.len() + 1 + body_bytes.len()); + signed_data.extend_from_slice(timestamp_str.as_bytes()); + signed_data.push(b'.'); + signed_data.extend_from_slice(body_bytes); + signed_data +} + +/// Signs telemetry message data using an Ed25519 signing key and returns `(public_key_hex, signature_hex)`. +#[must_use] +pub fn sign_telemetry_payload( + signing_key: &SigningKey, + timestamp_str: &str, + body_bytes: &[u8], +) -> (String, String) { + let signed_data = compute_signed_data(timestamp_str, body_bytes); + let signature = signing_key.sign(&signed_data); + let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes()); + let sig_hex = hex::encode(signature.to_bytes()); + (pubkey_hex, sig_hex) +} + +/// Errors that can occur during telemetry request signature verification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TelemetryVerificationError { + #[error("Invalid timestamp string format")] + InvalidTimestamp, + #[error( + "Timestamp drift exceeded: drift was {drift}s (max allowed is {MAX_CLOCK_DRIFT_SECS}s)" + )] + ClockDriftExceeded { drift: u64 }, + #[error("Invalid public key hex encoding")] + InvalidPublicKeyHex, + #[error("Invalid public key bytes: {0}")] + InvalidPublicKey(String), + #[error("Invalid signature hex encoding")] + InvalidSignatureHex, + #[error("Invalid signature bytes: {0}")] + InvalidSignature(String), + #[error("Signature verification failed: {0}")] + VerificationFailed(String), +} + +/// Verifies a signed telemetry request against an Ed25519 public key and timestamp. +/// +/// Ensures clock drift between `current_time_secs` and `timestamp_str` does not exceed `±300` seconds. +pub fn verify_telemetry_request( + pubkey_hex: &str, + sig_hex: &str, + timestamp_str: &str, + body_bytes: &[u8], + current_time_secs: u64, +) -> Result<(), TelemetryVerificationError> { + let ts: u64 = timestamp_str + .parse() + .map_err(|_| TelemetryVerificationError::InvalidTimestamp)?; + + let drift = current_time_secs.abs_diff(ts); + if drift > MAX_CLOCK_DRIFT_SECS { + return Err(TelemetryVerificationError::ClockDriftExceeded { drift }); + } + + let pubkey_bytes = + hex::decode(pubkey_hex).map_err(|_| TelemetryVerificationError::InvalidPublicKeyHex)?; + let vk = VerifyingKey::try_from(pubkey_bytes.as_slice()) + .map_err(|e| TelemetryVerificationError::InvalidPublicKey(e.to_string()))?; + + let sig_bytes = + hex::decode(sig_hex).map_err(|_| TelemetryVerificationError::InvalidSignatureHex)?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| TelemetryVerificationError::InvalidSignature(e.to_string()))?; + + let signed_data = compute_signed_data(timestamp_str, body_bytes); + vk.verify(&signed_data, &sig) + .map_err(|e| TelemetryVerificationError::VerificationFailed(e.to_string()))?; + + Ok(()) +} + +/// Client for communicating with the Pumpkin Marketplace telemetry backend. +pub struct TelemetryClient { + pub signing_key: SigningKey, + pub http_client: reqwest::Client, + pub endpoint: String, + pub api_base_url: String, +} + +impl TelemetryClient { + /// Creates a new `TelemetryClient` instance. + #[must_use] + pub fn new(signing_key: SigningKey, http_client: reqwest::Client, endpoint: String) -> Self { + let api_base_url = endpoint + .strip_suffix("/api/v1/rest/telemetry/heartbeat") + .unwrap_or_else(|| { + endpoint + .strip_suffix("/heartbeat") + .unwrap_or_else(|| endpoint.trim_end_matches('/')) + }) + .to_string(); + + Self { + signing_key, + http_client, + endpoint, + api_base_url, + } + } + + /// Returns the 64-character lowercase hexadecimal representation of the client's Ed25519 public key. + #[must_use] + pub fn public_key_hex(&self) -> String { + hex::encode(self.signing_key.verifying_key().to_bytes()) + } + + /// Loads an existing Ed25519 private key/seed from the given path or generates and persists a new one. + pub fn load_or_generate_key(key_path: &Path) -> Result { + if key_path.exists() { + let bytes = std::fs::read(key_path)?; + if bytes.len() == 32 { + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes); + return Ok(SigningKey::from_bytes(&seed)); + } + tracing::warn!( + "Failed to read valid 32-byte identity key from {}, generating a new one", + key_path.display() + ); + } + + // Generate new key + let seed: [u8; 32] = rand::random(); + let key = SigningKey::from_bytes(&seed); + + if let Some(parent) = key_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + std::fs::write(key_path, seed)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)); + } + + Ok(key) + } + + /// Sends a signed heartbeat request to the telemetry backend. + pub async fn send_heartbeat( + &self, + payload: &HeartbeatPayload, + ) -> Result { + let body_bytes = serde_json::to_vec(payload).expect("Serialization failed"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp_str = timestamp.to_string(); + + let (pubkey_hex, sig_hex) = + sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes); + + let response = self + .http_client + .post(&self.endpoint) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, pubkey_hex) + .header(HEADER_SIGNATURE, sig_hex) + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(response) + } + + /// Sends a signed server shutdown notice to the telemetry backend. + pub async fn send_shutdown(&self, reason: Option<&str>) -> Result<(), reqwest::Error> { + let payload = serde_json::json!({ + "reason": reason.unwrap_or("server shutdown") + }); + let body_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp_str = timestamp.to_string(); + + let (pubkey_hex, sig_hex) = + sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes); + + self.http_client + .post(format!( + "{}/api/v1/rest/telemetry/shutdown", + self.api_base_url + )) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, pubkey_hex) + .header(HEADER_SIGNATURE, sig_hex) + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await? + .error_for_status()?; + + Ok(()) + } +} + +/// Resolves the server identity key by checking default locations or generating a new one. +#[must_use] +pub fn resolve_identity_key() -> SigningKey { + let primary = Path::new(IDENTITY_KEY_PATH); + if primary.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(primary) + { + return key; + } + + let vine_key = Path::new(VINE_IDENTITY_KEY_PATH); + if vine_key.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(vine_key) + { + return key; + } + + let fallback = Path::new(FALLBACK_IDENTITY_KEY_PATH); + if fallback.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(fallback) + { + return key; + } + + let data_primary = Path::new("data").join(IDENTITY_KEY_PATH); + if data_primary.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_primary) + { + return key; + } + + let data_vine = Path::new("data").join(VINE_IDENTITY_KEY_PATH); + if data_vine.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_vine) + { + return key; + } + + let data_fallback = Path::new("data").join(FALLBACK_IDENTITY_KEY_PATH); + if data_fallback.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_fallback) + { + return key; + } + + if Path::new("data").is_dir() { + let path = Path::new("data").join(IDENTITY_KEY_PATH); + if let Ok(key) = TelemetryClient::load_or_generate_key(&path) { + return key; + } + } + + TelemetryClient::load_or_generate_key(primary).unwrap_or_else(|err| { + tracing::warn!( + "Failed to persist telemetry identity key at {}: {err}, using ephemeral key", + primary.display() + ); + let seed: [u8; 32] = rand::random(); + SigningKey::from_bytes(&seed) + }) +} + +/// Helper function to load or create an identity key at a specified path. +#[must_use] +pub fn get_or_create_identity_key(path: &Path) -> SigningKey { + TelemetryClient::load_or_generate_key(path).unwrap_or_else(|err| { + tracing::warn!( + "Failed to save identity key to {}: {err}, using ephemeral key", + path.display() + ); + let seed: [u8; 32] = rand::random(); + SigningKey::from_bytes(&seed) + }) +} + +/// Builds the telemetry heartbeat payload from current proxy runtime state. +#[must_use] +pub fn build_heartbeat_payload(config: &Config, online_players: usize) -> HeartbeatPayload { + let os = sysinfo::System::long_os_version().unwrap_or_else(|| { + let name = sysinfo::System::name().unwrap_or_else(|| std::env::consts::OS.to_string()); + sysinfo::System::os_version().map_or(name.clone(), |ver| format!("{name} {ver}")) + }); + let arch = std::env::consts::ARCH.to_string(); + let cpu_cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); + + let (ram_allocated_mb, total_ram_mb, cpu_model) = if sysinfo::IS_SUPPORTED_SYSTEM { + let mut sys = sysinfo::System::new(); + sys.refresh_memory(); + sys.refresh_cpu_all(); + let ram_allocated = sys.used_memory() / (1024 * 1024); + let total_ram = sys.total_memory() / (1024 * 1024); + let cpu_model = sys.cpus().first().map_or_else( + || "Unknown".to_string(), + |c| { + let brand = c.brand().trim(); + if brand.is_empty() { + "Unknown".to_string() + } else { + brand.to_string() + } + }, + ); + (ram_allocated, total_ram, cpu_model) + } else { + (0, 0, "Unknown".to_string()) + }; + + let mc_ver = JavaMinecraftVersion::from_protocol(config.motd.protocol_version as u32); + let minecraft_version = if mc_ver == JavaMinecraftVersion::Unknown { + "26.2".to_string() + } else { + mc_ver.to_string() + }; + + let is_public = config.telemetry.public; + let public_name = if is_public { + config.telemetry.server_name.clone() + } else { + None + }; + + HeartbeatPayload { + server_type: "vine".to_string(), + server_version: env!("CARGO_PKG_VERSION").to_string(), + minecraft_version, + protocol_version: config.motd.protocol_version, + online_players, + max_players: config.motd.max_players, + os, + arch, + cpu_cores, + cpu_model, + ram_allocated_mb, + total_ram_mb, + plugins: Vec::new(), + is_public, + public_name, + } +} + +pub fn start_telemetry( + config: Arc, + online_players: Arc, + mut shutdown_rx: broadcast::Receiver<()>, +) -> Option> { + if !config.telemetry.enabled { + tracing::trace!("Telemetry is disabled in configuration."); + return None; + } + + tracing::info!( + "Anonymous server telemetry is enabled. Sending periodic heartbeats to {}", + config.telemetry.endpoint + ); + + let user_agent = format!("Vine-Proxy/{}", env!("CARGO_PKG_VERSION")); + let client = match pumpkin_util::client_builder() + .timeout(Duration::from_secs(10)) + .user_agent(&user_agent) + .build() + { + Ok(c) => c, + Err(err) => { + tracing::debug!("Failed to create telemetry HTTP client: {err}"); + return None; + } + }; + + let signing_key = resolve_identity_key(); + let telemetry_client = Arc::new(TelemetryClient::new( + signing_key, + client, + config.telemetry.endpoint.clone(), + )); + + let interval_secs = config.telemetry.interval_secs.max(60); + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); + loop { + tokio::select! { + _ = interval.tick() => { + let online = online_players.load(Ordering::Relaxed); + let payload = build_heartbeat_payload(&config, online); + + match telemetry_client.send_heartbeat(&payload).await { + Ok(resp) => { + tracing::trace!( + "Telemetry heartbeat successfully transmitted (status: {}, public_key: {})", + resp.status, + resp.server_public_key + ); + } + Err(err) => { + tracing::debug!("Telemetry heartbeat failed to send: {err}"); + } + } + } + _ = shutdown_rx.recv() => { + tracing::trace!("Telemetry task received shutdown signal."); + break; + } + } + } + + // Trigger shutdown telemetry on server termination / SIGTERM + tracing::info!("Sending server shutdown notice to telemetry backend..."); + match telemetry_client + .send_shutdown(Some("server shutdown")) + .await + { + Ok(()) => { + tracing::info!( + "Telemetry shutdown notification successfully acknowledged by backend." + ); + } + Err(err) => { + tracing::warn!("Telemetry shutdown message failed to send: {err}"); + } + } + }); + + Some(handle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_key_generation_and_persistence() { + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join(".pumpkin/identity.key"); + + assert!(!key_path.exists()); + let key1 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert!(key_path.exists()); + + // Check key file has 32 bytes + let file_bytes = std::fs::read(&key_path).unwrap(); + assert_eq!(file_bytes.len(), 32); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&key_path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + } + + // Reload existing key + let key2 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert_eq!( + key1.verifying_key().to_bytes(), + key2.verifying_key().to_bytes() + ); + + // Corrupted file test (not 32 bytes) + std::fs::write(&key_path, b"corrupted-key-data").unwrap(); + let key3 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert_ne!( + key1.verifying_key().to_bytes(), + key3.verifying_key().to_bytes() + ); + let new_file_bytes = std::fs::read(&key_path).unwrap(); + assert_eq!(new_file_bytes.len(), 32); + } + + #[test] + fn heartbeat_payload_serialization() { + let payload = HeartbeatPayload { + server_type: "vine".to_string(), + server_version: "0.1.0".to_string(), + minecraft_version: "26.2".to_string(), + protocol_version: 776, + online_players: 15, + max_players: 100, + os: "Linux 6.8.0".to_string(), + arch: "x86_64".to_string(), + cpu_cores: 8, + cpu_model: "AMD Ryzen 7".to_string(), + ram_allocated_mb: 2048, + total_ram_mb: 16384, + plugins: vec![], + is_public: true, + public_name: Some("My Vine Proxy".to_string()), + }; + + let json_str = serde_json::to_string_pretty(&payload).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + // server_id must be completely removed + assert!(parsed.get("server_id").is_none()); + + assert_eq!(parsed["server_type"], "vine"); + assert_eq!(parsed["server_version"], "0.1.0"); + assert_eq!(parsed["minecraft_version"], "26.2"); + assert_eq!(parsed["protocol_version"], 776); + assert_eq!(parsed["online_players"], 15); + assert_eq!(parsed["max_players"], 100); + assert_eq!(parsed["os"], "Linux 6.8.0"); + assert_eq!(parsed["arch"], "x86_64"); + assert_eq!(parsed["cpu_cores"], 8); + assert_eq!(parsed["cpu_model"], "AMD Ryzen 7"); + assert_eq!(parsed["ram_allocated_mb"], 2048); + assert_eq!(parsed["total_ram_mb"], 16384); + assert_eq!(parsed["is_public"], true); + assert_eq!(parsed["public_name"], "My Vine Proxy"); + assert_eq!(parsed["plugins"].as_array().unwrap().len(), 0); + } + + #[test] + fn heartbeat_response_deserialization() { + let json_data = r#"{ + "status": "ok", + "message": "Heartbeat accepted", + "server_public_key": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "next_ping_seconds": 300 + }"#; + + let resp: HeartbeatResponse = serde_json::from_str(json_data).unwrap(); + assert_eq!(resp.status, "ok"); + assert_eq!(resp.message, "Heartbeat accepted"); + assert_eq!( + resp.server_public_key, + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" + ); + assert_eq!(resp.next_ping_seconds, 300); + } + + #[test] + fn message_signing_and_verification() { + let seed = [42u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let timestamp_str = "1757185000"; + let body = br#"{"server_type":"vine","online_players":5}"#; + + // Verify signed data format: "{timestamp}.{body}" + let signed_data = compute_signed_data(timestamp_str, body); + let expected_prefix = format!("{timestamp_str}."); + assert!(signed_data.starts_with(expected_prefix.as_bytes())); + assert_eq!(&signed_data[expected_prefix.len()..], body); + + // Sign payload + let (pubkey_hex, sig_hex) = sign_telemetry_payload(&signing_key, timestamp_str, body); + assert_eq!(pubkey_hex.len(), 64); + assert_eq!(sig_hex.len(), 128); + + // All hex chars must be lowercase + assert_eq!(pubkey_hex, pubkey_hex.to_lowercase()); + assert_eq!(sig_hex, sig_hex.to_lowercase()); + + // Verify with matching timestamp + let current_time: u64 = 1757185100; // 100s drift (within 300s) + let verify_result = + verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, current_time); + assert!(verify_result.is_ok()); + + // Drift exceeded (> 300s) + let expired_time: u64 = 1757185000 + 301; + let expired_result = + verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, expired_time); + assert!(matches!( + expired_result, + Err(TelemetryVerificationError::ClockDriftExceeded { .. }) + )); + + // Corrupted signature + let mut corrupted_sig = sig_hex.clone(); + let flipped_char = if corrupted_sig.starts_with('0') { + '1' + } else { + '0' + }; + corrupted_sig.replace_range(0..1, &flipped_char.to_string()); + let corrupted_result = verify_telemetry_request( + &pubkey_hex, + &corrupted_sig, + timestamp_str, + body, + current_time, + ); + assert!(matches!( + corrupted_result, + Err(TelemetryVerificationError::VerificationFailed(_)) + )); + + // Corrupted body + let tampered_body = br#"{"server_type":"vine","online_players":6}"#; + let tampered_result = verify_telemetry_request( + &pubkey_hex, + &sig_hex, + timestamp_str, + tampered_body, + current_time, + ); + assert!(matches!( + tampered_result, + Err(TelemetryVerificationError::VerificationFailed(_)) + )); + } + + #[tokio::test] + async fn telemetry_client_send_heartbeat_and_shutdown() { + use axum::Router; + use axum::extract::Request; + use axum::http::StatusCode; + use axum::http::header::CONTENT_TYPE; + use axum::response::IntoResponse; + use axum::routing::post; + + let seed = [7u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let expected_pubkey = hex::encode(signing_key.verifying_key().to_bytes()); + + // Set up mock axum server + let app = Router::new() + .route( + "/api/v1/rest/telemetry/heartbeat", + post(move |req: Request| async move { + let headers = req.headers().clone(); + let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok()); + let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok()); + let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok()); + + let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else { + return (StatusCode::UNAUTHORIZED, "Missing required headers") + .into_response(); + }; + + let body = axum::body::to_bytes(req.into_body(), 1024 * 1024) + .await + .unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() { + return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response(); + } + + let response = HeartbeatResponse { + status: "ok".to_string(), + message: "Heartbeat accepted".to_string(), + server_public_key: pubkey.to_string(), + next_ping_seconds: 300, + }; + let json_bytes = serde_json::to_vec(&response).unwrap(); + ( + StatusCode::OK, + [(CONTENT_TYPE, "application/json")], + json_bytes, + ) + .into_response() + }), + ) + .route( + "/api/v1/rest/telemetry/shutdown", + post(move |req: Request| async move { + let headers = req.headers().clone(); + let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok()); + let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok()); + let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok()); + + let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else { + return (StatusCode::UNAUTHORIZED, "Missing headers").into_response(); + }; + + let body = axum::body::to_bytes(req.into_body(), 1024 * 1024) + .await + .unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() { + return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response(); + } + + (StatusCode::OK, "Shutdown received").into_response() + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let endpoint = format!("http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat"); + let http_client = pumpkin_util::client(); + let client = TelemetryClient::new(signing_key, http_client, endpoint); + + let payload = HeartbeatPayload { + server_type: "vine".to_string(), + server_version: "0.1.0".to_string(), + minecraft_version: "26.2".to_string(), + protocol_version: 776, + online_players: 1, + max_players: 20, + os: "Linux".to_string(), + arch: "x86_64".to_string(), + cpu_cores: 4, + cpu_model: "Test CPU".to_string(), + ram_allocated_mb: 1024, + total_ram_mb: 2048, + plugins: vec![], + is_public: false, + public_name: None, + }; + + // Send valid heartbeat -> 200 OK + let resp = client.send_heartbeat(&payload).await.unwrap(); + assert_eq!(resp.status, "ok"); + assert_eq!(resp.message, "Heartbeat accepted"); + assert_eq!(resp.server_public_key, expected_pubkey); + assert_eq!(resp.next_ping_seconds, 300); + + // Send valid shutdown -> 200 OK + let shutdown_res = client.send_shutdown(Some("maintenance")).await; + assert!(shutdown_res.is_ok()); + + // Test invalid signature / unauthorized + let body_bytes = serde_json::to_vec(&payload).unwrap(); + let timestamp_str = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + let response = pumpkin_util::client() + .post(format!( + "http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat" + )) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, &expected_pubkey) + .header(HEADER_SIGNATURE, "00".repeat(64)) // invalid signature + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } +}