diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml deleted file mode 100644 index ec12b62..0000000 --- a/.github/workflows/gradle.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Build and push to DockerHub -on: - push: - branches: - - main -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: ./ - push: true - tags: ghcr.io/mindustrytool/player-connect-server:latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8edb990 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Build and push to DockerHub +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./ + push: true + tags: ghcr.io/mindustrytool/player-connect-server:latest diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml new file mode 100644 index 0000000..f7de017 --- /dev/null +++ b/.github/workflows/rust-release.yml @@ -0,0 +1,26 @@ +name: Build and push to DockerHub +on: + push: + branches: + - v2 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./ + push: true + tags: ghcr.io/mindustrytool/player-connect-server-rust:latest diff --git a/.gitignore b/.gitignore index a31a6f7..072f409 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .project build/ bin/ +target diff --git a/.trae/rules/base.md b/.trae/rules/base.md new file mode 100644 index 0000000..0078b75 --- /dev/null +++ b/.trae/rules/base.md @@ -0,0 +1,115 @@ +1. Project & File Structure Rules + +One responsibility per module + +Each mod should have a single clear purpose. + +Avoid “god modules”. + +Predictable layout + +src/ main.rs error.rs config.rs + +Public API at the top + +pub struct, pub enum, pub fn first + +Helpers and private impls later + +2. Naming Rules (Very Important for AI) + +Use descriptive names, never single letters ❌ x, tmp, r, v ✅ request, buffer, user_id, packet_type + +Avoid abbreviations ❌ cfg, mgr, svc ✅ config, manager, service + +Types > comments + +Prefer expressive type names over comments + +Boolean names must answer yes/no + +is_connected has_permission should_retry + +3. Type System Rules + +Prefer strong types over primitives + +struct UserId(u64); struct Port(u16); + +Never use String when an enum fits + +enum Protocol { Tcp, Udp, } + +Avoid Option for required data + +Use constructor validation instead + +Avoid Vec without context + +struct PacketBytes(Vec); + +4. Error Handling Rules + +Never use unwrap() or expect() in library code + +OK only in tests or binaries + +Use a single error enum + +enum AppError { Io(std::io::Error), InvalidPacket, Timeout, } + +Errors must be meaningful ❌ Err(AppError::Invalid) ✅ Err(AppError::InvalidPacketHeader) + +Implement Display for errors + +AI tools rely on readable messages + +5. Function Design Rules + +Functions should fit on one screen + +~20–40 lines max + +One logical action per function + +Avoid hidden side effects + +No silent global mutation + +Explicit input > implicit state + +fn encode(packet: &Packet, buffer: &mut BytesMut) + +Prefer returning values over mutating inputs + +6. Ownership & Borrowing Rules + +Prefer borrowing over cloning + +fn process(data: &Data) + +Clone only at API boundaries + +Avoid complex lifetime annotations + +If lifetimes get hard, redesign + +Use Arc only for shared ownership + +Never “just in case” + +7. Async & Concurrency Rules + +Never block in async code ❌ std::thread::sleep ✅ tokio::time::sleep + +Name async functions clearly + +async fn fetch_user() + +One async runtime + +Do not mix Tokio + async-std + +Use channels instead of shared mutable state + +8. Run cargo check before committing, and fix all errors diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ae8ff26 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1168 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[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 = "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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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 = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "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.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bytes", + "dashmap", + "dotenvy", + "futures", + "rand", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tokio-stream", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +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" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = [ + "log", + "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", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom", + "js-sys", + "rand", + "serde_core", + "uuid-macro-internal", + "wasm-bindgen", +] + +[[package]] +name = "uuid-macro-internal" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39d11901c36b3650df7acb0f9ebe624f35b5ac4e1922ecd3c57f444648429594" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +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.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "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_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6e6c3eb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.0", features = ["full"] } +tokio-stream = { version = "0.1", features = ["sync"] } + +axum = { version = "0.7", features = ["macros"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_repr = "0.1" + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +dotenvy = "0.15" + +bytes = "1.0" +dashmap = "6.0" +uuid = { version = "1.6", features = [ + "v7", + "fast-rng", + "macro-diagnostics", + "serde", +] } +tower-http = { version = "0.5", features = ["cors", "trace"] } +futures = "0.3" +thiserror = "1.0" +anyhow = "1.0" +rand = "0.9.2" diff --git a/Dockerfile b/Dockerfile index 2e88686..df602d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,21 @@ -FROM gradle:8.9.0-jdk17 AS build -COPY --chown=gradle:gradle . /home/gradle/src +# ---------- Build stage ---------- +FROM rust:1.85-slim AS build +WORKDIR /app -WORKDIR /home/gradle/src +# Cache dependencies +COPY Cargo.toml Cargo.lock ./ +RUN mkdir -p src && touch src/main.rs +RUN cargo fetch -RUN chmod +x gradlew +# Copy real source +COPY . . +RUN cargo build --release -RUN ./gradlew jar --no-daemon +# ---------- Runtime stage ---------- +FROM gcr.io/distroless/cc-debian12 +WORKDIR /app -FROM eclipse-temurin:17-jre-alpine +COPY --from=build /app/target/release/server /app/server -COPY --from=build /home/gradle/src/build/libs/*.jar /app/server.jar - -ENTRYPOINT ["java","-Xmx384m","-jar", "/app/server.jar"] +USER nonroot +ENTRYPOINT ["/app/server"] diff --git a/README.md b/README.md deleted file mode 100644 index c006319..0000000 --- a/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Copy Link and Join (CLaJ) -This system allow you to play with your friends just by creating a room, copy the link and send it to your friends.
-In fact it's pretty much the same thing as Hamachi, but in a Mindustry mod. - -This is a bundled, reworked and optimized version of the [CLaJ server](https://github.com/xzxADIxzx/Copy-Link-and-Join) and the [xzxADIxzx's Scheme-Size mod](https://github.com/xzxADIxzx/Scheme-Size) (with only the CLaJ feature). - -> [!IMPORTANT] -> This CLaJ version is not compatible with the [xzxADIxzx's](https://github.com/xzxADIxzx) one.
-> The protocol has been reworked and optimized and CLaJ links have also been changed to a more standard version. - - -## Mindustry v8 note -Mindustry v8 has been released, and many changes have been made. Mods must now make changes to be compatible with this version.
-The mod is not officially updated to this version, at this time, but it remains compatible with it. - -To install the mod for mindustry v8, just go to the mod browser, search for **'claj'**, then click ``View Releases`` -and install the latest version named **'CLaJ for Mindustry v8'**.
-Or you can download the mod file from the [releases section](https://github.com/Xpdustry/claj/releases) of pre-releases versions and place it into the mod folder of your game. - - -## How to use -### Client -**First, if you don't have the mod yet, you can find it in the mod browser by searching for 'claj' and then installing it.** - -Start and host a map as normal (or your campaign): ``Host Multiplayer Game`` **>** ``Host``.
-Then go to ``Manage CLaJ Room``, select a server (or add your own), now ``Create Room`` and wait for the room to be created, click ``Copy Link`` and send the copied link to your friends. - -To join, it's simple, copy the link your friend sent you, open your game, go to ``Play`` **>** ``Join Game`` **>** ``Join via CLaJ``, paste the link and ``OK``. - -Now, if all goods, you can play with your friends, so enjoy =). - - -### Server -To host a server, just run the command ``java -jar claj-server.jar ``, where ```` is the port for the server.
-Also don't forget to open the port in TCP and UDP mode on your end-point and redirect it to the host machine. - -A CLaJ server doesn't need much memory and cpu, 256MB of memory and one core are enough, even at high traffic.
-To change the memory allocated to the server, change the command to ``java -Xms -Xmx -jar claj-server.jar ``, where ```` is the memory allocated to the server *(e.g. 256m for 256 MB of ram)*. - -> [!IMPORTANT] -> Please note that if you plan to make a public server, CLaJ servers are high bandwidth consumers, as they act as a relay. For an average server, around 1TB up/down of consumption per month and around 1MB/s of constant network usage. -> -> Also, you can create a Pull-Request to add your server to the public server list (in [public-servers.hjson](https://github.com/xpdustry/claj/blob/main/public-servers.hjson)). - - -## How it works -CLaJ is a system like [Hamachi](https://vpn.net/), that allows you to create a room and share the link to your friends. This way, they can connect to you as if they were on your private network. - -The only differences are that Hamachi requires account creation and therefore the collection of personal information, etc., while CLaJ does not. And CLaJ is directly integrated into Mindustry and optimized for it, which makes it easier to use compared to Hamachi, which needs to stay in parallel of the game. - -On the host player's side, it's server never receives packets from people connected via CLaJ, the work is done by the CLaJ Proxy which simply run the server's callbacks. - - -## How to build -Pre-build releases can be found in the [releases section](https://github.com/Xpdustry/claj/releases), but if you want to build the project yourself, follow the steps above. - -To build the client version, simply run the command ``./gradlew client:build``. The jar file will be located in the root directory and named ``claj-client.jar``. - -To build the server version, simply run the command ``./gradlew server:build``. The jar file will be located in the root directory and named ``claj-server.jar``. - -You can also run a test server by using the command ``./gradlew server:run``. It will be hosted on port ``7000``. - - -## Modding -The CLaJ server can be modded using plugins that are located in the ``plugins/`` directory.
-They work the same way as [Mindustry mods](https://mindustrygame.github.io/wiki/modding/2-plugins/), but only handles Java ones (not json and js) and doesn't handles sprites, icon, bundles, and others things designed for client-side. - -The descriptor file can therefore only be ``plugin.json`` or ``plugin.hjson``, and some properties are removed, such as ``java`` because these can only be Java plugins, or ``texturescale`` because there is no texture handling on servers.
-Supported plugin properties: ``name``, ``internalName``, ``displayName``, ``author``, ``description``, ``version``, ``repo``, ``main``, ``dependencies`` and ``softDependencies``. diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 07e110f..0000000 --- a/build.gradle +++ /dev/null @@ -1,64 +0,0 @@ -apply plugin: "java" - -version '1.0' - -java { - targetCompatibility = 8 - sourceCompatibility = JavaVersion.VERSION_17 -} - -allprojects{ - tasks.withType(JavaCompile){ - options.annotationProcessorPath = configurations.annotationProcessor - options.compilerArgs.addAll(['--release', '8']) - } -} - -sourceSets.main.java.srcDirs = ["src"] - -compileJava.options.encoding = "UTF-8" -compileTestJava.options.encoding = "UTF-8" - - - -repositories{ - mavenCentral() - maven{ url "https://raw.githubusercontent.com/Zelaux/MindustryRepo/master/repository" } - maven{ url 'https://jitpack.io' } -} - -ext{ - //the build number that this plugin is made for - mindustryVersion = 'v153' - jabelVersion = "93fde537c7" -} - -dependencies{ - implementation files("libs/PlayerConnectShared.jar") - implementation 'io.javalin:javalin:6.7.0' - - implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.2' - - implementation "com.github.Anuken.Arc:arc-core:$mindustryVersion" - implementation "com.github.Anuken.Mindustry:core:$mindustryVersion" - - compileOnly 'org.projectlombok:lombok:1.18.32' - - annotationProcessor 'org.projectlombok:lombok:1.18.32' - - annotationProcessor "com.github.Anuken:jabel:$jabelVersion" -} - -jar{ - duplicatesStrategy(DuplicatesStrategy.EXCLUDE) - archiveFileName = "${project.archivesBaseName}.jar" - from{ - configurations.runtimeClasspath.collect{it.isDirectory() ? it : zipTree(it)} - } - - manifest { - attributes( - 'Main-Class': 'playerconnect.PlayerConnect' - ) - } -} diff --git a/dev.env b/dev.env new file mode 100644 index 0000000..2ecd159 --- /dev/null +++ b/dev.env @@ -0,0 +1,2 @@ +PLAYER_CONNECT_PORT=11010 +PLAYER_CONNECT_HTTP_PORT=11011 diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 051052c..0000000 --- a/gradle.properties +++ /dev/null @@ -1,13 +0,0 @@ -org.gradle.jvmargs=--illegal-access=permit \ ---add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ ---add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ ---add-exports=java.base/sun.reflect.annotation=ALL-UNNAMED diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ca025c8..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100644 index 23d15a9..0000000 --- a/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH="\\\"\\\"" - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index db3a6ac..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/libs/PlayerConnectShared.jar b/libs/PlayerConnectShared.jar deleted file mode 100644 index e206f59..0000000 Binary files a/libs/PlayerConnectShared.jar and /dev/null differ diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index ddb8a08..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'PlayerConnectServer' diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8f3688f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use dotenvy::dotenv; +use std::env; + +#[derive(Clone, Debug)] +pub struct Config { + pub player_connect_port: u16, + pub player_connect_http_port: u16, +} + +impl Config { + pub fn from_env() -> Result { + dotenv().ok(); + + let player_connect_port = env::var("PLAYER_CONNECT_PORT") + .unwrap_or_else(|_| "11010".to_string()) + .parse()?; + + let player_connect_http_port = env::var("PLAYER_CONNECT_HTTP_PORT") + .unwrap_or_else(|_| "11011".to_string()) + .parse()?; + + Ok(Config { + player_connect_port, + player_connect_http_port, + }) + } +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..c6675f2 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,546 @@ +use crate::constant::{ConnectionCloseReason, MessageType, RoomCloseReason}; +use crate::packet::{ + AnyPacket, AppPacket, ConnectionClosedPacket, ConnectionId, ConnectionPacketWrapPacket, + FrameworkMessage, Message2Packet, MessagePacket, RoomId, RoomLinkPacket, +}; +use crate::rate::AtomicRateLimiter; +use crate::state::{AppState, ConnectionAction, RoomInit}; +use crate::writer::{TcpWriter, UdpWriter}; +use anyhow::anyhow; +use bytes::{Buf, Bytes, BytesMut}; +use std::io::{Cursor, IoSlice}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::AsyncReadExt; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +const TCP_BUFFER_SIZE: usize = 32768; +const CONNECTION_TIME_OUT_MS: Duration = Duration::from_millis(30000); +const KEEP_ALIVE_INTERVAL_MS: Duration = Duration::from_millis(3000); +const PACKET_LENGTH_LENGTH: usize = 2; +const TICK_INTERVAL_MS: u64 = 1000 / 120; +const PACKET_BACTH_SIZE: usize = 32; + +pub struct ConnectionRoom { + room_id: RoomId, + is_host: bool, +} + +impl ConnectionRoom { + pub fn room_id(&self) -> &RoomId { + &self.room_id + } + + pub fn is_host(&self) -> bool { + self.is_host + } +} + +pub struct ConnectionActor { + pub id: ConnectionId, + pub state: Arc, + pub rx: mpsc::Receiver, + pub tcp_writer: TcpWriter, + pub udp_writer: UdpWriter, + pub limiter: Arc, + pub last_read: Instant, + pub packet_queue: Vec, + pub room: Option, +} + +impl ConnectionActor { + pub async fn run(&mut self, mut reader: tokio::net::tcp::OwnedReadHalf) -> anyhow::Result<()> { + let register_packet = AnyPacket::Framework(FrameworkMessage::RegisterTCP { + connection_id: self.id, + }); + + self.write_packet(register_packet).await?; + + self.notify_idle(); + + let mut buf = BytesMut::with_capacity(TCP_BUFFER_SIZE); + let mut tick_interval = tokio::time::interval(Duration::from_millis(TICK_INTERVAL_MS)); + + loop { + let mut headers: Vec<[u8; 2]> = Vec::with_capacity(PACKET_BACTH_SIZE); + let mut payloads: Vec = Vec::with_capacity(PACKET_BACTH_SIZE); + + tokio::select! { + read_result = reader.read_buf(&mut buf) => { + match read_result { + Ok(0) => return Err(anyhow::anyhow!("Connection closed by peer")), + Ok(_n) => { + self.last_read = Instant::now(); + + self.process_tcp_buffer(&mut buf).await?; + } + Err(e) => return Err(e.into()), + } + } + + action = self.rx.recv() => { + if let Some(action) = action { + self.handle_action(action, &mut headers, &mut payloads).await?; + + for _ in 0..PACKET_BACTH_SIZE { + if let Ok(action) = self.rx.try_recv() { + self.handle_action(action, &mut headers, &mut payloads).await?; + + } else { + break; + } + } + + if !payloads.is_empty() { + let mut slices = Vec::with_capacity(headers.len() * 2); + for (header, payload) in headers.iter().zip(payloads.iter()) { + slices.push(IoSlice::new(header)); + slices.push(IoSlice::new(payload)); + } + self.tcp_writer.write_vectored(&slices).await?; + } + + + } else { + return Err(anyhow::anyhow!("Connection closed by peer, no action")); + } + } + + _ = tick_interval.tick() => { + if self.tcp_writer.idling { + self.notify_idle(); + } + + if self.tcp_writer.last_write.elapsed() > KEEP_ALIVE_INTERVAL_MS { + self.write_packet(AnyPacket::Framework(FrameworkMessage::KeepAlive)).await?; + } + + if self.last_read.elapsed() > CONNECTION_TIME_OUT_MS { + return Err(anyhow::anyhow!("Connection timed out")); + } + } + } + } + } + + async fn process_tcp_buffer(&mut self, buf: &mut BytesMut) -> anyhow::Result<()> { + loop { + if buf.len() < PACKET_LENGTH_LENGTH { + break; + } + + let len = { + let mut cur = Cursor::new(&buf[..]); + cur.get_u16() as usize + }; + + if !self.limiter.check() { + warn!("{} Connection rate limit exceeded", self.id); + return Err(anyhow::anyhow!("Rate limit exceeded")); + } + + if buf.len() < PACKET_LENGTH_LENGTH + len { + break; + } + + buf.advance(PACKET_LENGTH_LENGTH); + + let payload = buf.split_to(len).freeze(); + let mut cursor = Cursor::new(payload); + + match AnyPacket::read(&mut cursor) { + Ok(packet) => { + self.handle_packet(packet, true).await?; + } + Err(e) => { + error!("Error reading packet: {:?} from connection {}", e, self.id); + continue; + } + } + } + Ok(()) + } + + async fn handle_packet(&mut self, packet: AnyPacket, is_tcp: bool) -> anyhow::Result<()> { + let is_framework = matches!(packet, AnyPacket::Framework(_)); + + if !is_framework { + let is_host = self.room.as_ref().map(|r| r.is_host).unwrap_or(false); + + if !is_host && !self.limiter.check() { + warn!("Connection {} disconnected for packet spamming.", self.id); + return Err(anyhow!("Packet Spamming")); + } + } + + match packet { + AnyPacket::Framework(f) => self.handle_framework(f).await?, + AnyPacket::App(a) => self.handle_app(a).await?, + AnyPacket::Raw(bytes) => { + if let Some(ref room) = self.room { + let packet = AnyPacket::App(AppPacket::ConnectionPacketWrap( + ConnectionPacketWrapPacket { + connection_id: self.id, + is_tcp, + buffer: bytes, + }, + )); + + self.state + .room_state + .forward_to_host(&room.room_id, ConnectionAction::SendTCP(packet)); + } else { + if self.packet_queue.len() < 16 { + self.packet_queue.push(bytes); + } else { + warn!( + "Connection {} packet queue full, dropping raw packet", + self.id + ); + } + } + } + } + Ok(()) + } + + async fn handle_framework(&mut self, packet: FrameworkMessage) -> anyhow::Result<()> { + match packet { + FrameworkMessage::Ping { id, is_reply } => { + if !is_reply { + self.write_packet(AnyPacket::Framework(FrameworkMessage::Ping { + id, + is_reply: true, + })) + .await?; + } + } + FrameworkMessage::KeepAlive => {} + FrameworkMessage::RegisterUDP { .. } => panic!("This should be handled previous"), + _ => { + warn!("Unhandled Framework Packet: {:?}", packet); + } + } + Ok(()) + } + + async fn handle_app(&mut self, packet: AppPacket) -> anyhow::Result<()> { + match packet { + AppPacket::Ping(p) => { + self.write_packet(AnyPacket::App(AppPacket::Ping(p))) + .await?; + } + AppPacket::Stats(p) => { + if let Some(ref room) = self.room { + if !room.is_host { + warn!( + "Connection {} tried to update stats but is not host", + self.id + ); + return Ok(()); + } + + self.state.room_state.update_state(&room.room_id, p); + } + } + AppPacket::RoomJoin(p) => { + if let Some((current_room_id, is_host)) = + self.room.as_ref().map(|r| (r.room_id.clone(), r.is_host)) + { + if is_host { + self.write_packet(AnyPacket::App(AppPacket::Message2(Message2Packet { + message: MessageType::AlreadyHosting, + }))) + .await?; + + warn!( + "Connection {} tried to join room {} but is already hosting {}", + self.id, p.room_id, current_room_id + ); + return Ok(()); + } + + info!( + "Connection {} left room {} to join {}", + self.id, current_room_id, p.room_id + ); + + self.state.room_state.leave(self.id, ¤t_room_id); + self.room = None; + } + + let (room_exists, can_join) = + self.state.room_state.can_join(&p.room_id, &p.password); + + if !room_exists { + warn!( + "Connection {} tried to join a non-existent room {}.", + self.id, p.room_id + ); + + self.write_packet(AnyPacket::App(AppPacket::Message(MessagePacket { + message: "@player-connect.room-not-found".to_string(), + }))) + .await?; + + return Ok(()); + } + + if !can_join { + warn!( + "Connection {} tried to join room {} with wrong password.", + self.id, p.room_id + ); + + self.write_packet(AnyPacket::App(AppPacket::Message(MessagePacket { + message: "@player-connect.wrong-password".to_string(), + }))) + .await?; + + return Ok(()); + } + + if let Some(sender) = self.state.get_sender(self.id) { + self.state.room_state.join(self.id, &p.room_id, sender)?; + self.room = Some(ConnectionRoom { + room_id: p.room_id.clone(), + is_host: false, + }); + + info!("Connection {} joined the room {}.", self.id, p.room_id); + + for bytes in self.packet_queue.drain(..) { + self.state.room_state.forward_to_host( + &p.room_id, + ConnectionAction::SendTCP(AnyPacket::App( + AppPacket::ConnectionPacketWrap(ConnectionPacketWrapPacket { + connection_id: self.id, + is_tcp: false, + buffer: bytes, + }), + )), + ); + } + } + } + AppPacket::RoomCreationRequest(p) => { + if let Some(room_id) = self.room.as_ref().map(|r| r.room_id.clone()) { + self.write_packet(AnyPacket::App(AppPacket::Message2(Message2Packet { + message: MessageType::AlreadyHosting, + }))) + .await?; + warn!( + "Connection {} tried to create a room but is already hosting/in the room {}.", + self.id, room_id + ); + return Ok(()); + } + + if let Some(sender) = self.state.get_sender(self.id) { + let room_id = self.state.room_state.create(RoomInit { + connection_id: self.id, + password: p.password, + stats: p.data, + protocol_version: p.version, + sender, + }); + self.room = Some(ConnectionRoom { + room_id: room_id.clone(), + is_host: true, + }); + + self.write_packet(AnyPacket::App(AppPacket::RoomLink(RoomLinkPacket { + room_id: room_id.clone(), + }))) + .await?; + + info!("Room {} created by connection {}.", room_id, self.id); + } + } + AppPacket::RoomClosureRequest(_) => { + if let Some((room_id, is_host)) = + self.room.as_ref().map(|r| (r.room_id.clone(), r.is_host)) + { + if !is_host { + self.write_packet(AnyPacket::App(AppPacket::Message2(Message2Packet { + message: MessageType::RoomClosureDenied, + }))) + .await?; + warn!( + "Connection {} tried to close the room {} but is not the host.", + self.id, room_id + ); + return Ok(()); + } + + self.state + .room_state + .close(&room_id, RoomCloseReason::Closed); + self.room = None; + + info!( + "Room {} closed by connection {} (the host).", + room_id, self.id + ); + } + } + AppPacket::ConnectionClosed(p) => { + if let Some((room_id, is_host)) = + self.room.as_ref().map(|r| (r.room_id.clone(), r.is_host)) + { + if !is_host { + self.write_packet(AnyPacket::App(AppPacket::Message2(Message2Packet { + message: MessageType::ConClosureDenied, + }))) + .await?; + warn!("Connection {} tried to close the connection {} but is not the host of room {}.", self.id, p.connection_id, room_id); + return Ok(()); + } + + if let Some(sender) = self.state.get_sender(p.connection_id) { + let is_in_room = + self.state.room_state.is_in_room(&p.connection_id, &room_id); + + if is_in_room { + info!( + "Connection {} (room {}) closed the connection {}: {:?}.", + self.id, room_id, p.connection_id, p.reason + ); + + if let Err(e) = sender + .try_send(ConnectionAction::Close(ConnectionCloseReason::Closed)) + { + warn!("Failed to send close action to {}: {}", p.connection_id, e); + } + } else { + warn!( + "Connection {} (room {}) tried to close a connection from another room.", + self.id, room_id + ); + } + } + } + } + AppPacket::ConnectionPacketWrap(ConnectionPacketWrapPacket { + connection_id, + is_tcp, + buffer, + }) => { + if let Some((room_id, is_host)) = + self.room.as_ref().map(|r| (r.room_id.clone(), r.is_host)) + { + if !is_host { + return Err(anyhow!("Not room owner")); + } + + if AnyPacket::is_stream_packet(&buffer) { + self.tcp_writer.idling = true; + } + + let Some(sender) = self.state.get_sender(connection_id) else { + warn!("Connection not found: {}", connection_id); + + self.state.room_state.forward_to_host( + &room_id, + ConnectionAction::SendTCP(AnyPacket::App(AppPacket::ConnectionClosed( + ConnectionClosedPacket { + connection_id, + reason: ConnectionCloseReason::Closed, + }, + ))), + ); + + return Ok(()); + }; + + let action = if is_tcp { + ConnectionAction::SendTCPRaw(buffer) + } else { + ConnectionAction::SendUDPRaw(buffer) + }; + + if let Err(e) = sender.try_send(action) { + warn!("Failed to forward packet to {}: {}", connection_id, e); + } + } else { + warn!("No room found for connection {}", self.id); + } + } + _ => { + warn!("Unhandled App Packet: {:?}", packet); + } + } + Ok(()) + } + + async fn handle_action( + &mut self, + action: ConnectionAction, + headers: &mut Vec<[u8; 2]>, + payloads: &mut Vec, + ) -> anyhow::Result<()> { + match action { + ConnectionAction::SendTCP(p) => { + let bytes = p.to_bytes().freeze(); + let len = bytes.len() as u16; + headers.push(len.to_be_bytes()); + payloads.push(bytes); + } + ConnectionAction::SendTCPRaw(b) => { + let len = b.len() as u16; + headers.push(len.to_be_bytes()); + payloads.push(b); + } + ConnectionAction::SendUDPRaw(b) => { + self.udp_writer.send_raw(&b).await?; + } + ConnectionAction::Close(reason) => { + return Err(anyhow::anyhow!("Connection closed: {:?}", reason)); + } + ConnectionAction::RegisterUDP(addr) => { + if self.udp_writer.addr.is_some() { + return Ok(()); + } + + if let Some(sender) = self.state.get_sender(self.id) { + self.udp_writer.set_addr(addr); + self.state.register_udp(addr, sender, self.limiter.clone()); + } else { + return Err(anyhow::anyhow!( + "No sender found for connection {}", + self.id + )); + } + + self.write_packet(AnyPacket::Framework(FrameworkMessage::RegisterUDP { + connection_id: ConnectionId(0), + })) + .await?; + + info!("New connection {} from {}", self.id, addr); + } + ConnectionAction::ProcessPacket(packet, is_tcp) => { + self.handle_packet(packet, is_tcp).await?; + } + } + Ok(()) + } + + async fn write_packet(&mut self, packet: AnyPacket) -> anyhow::Result<()> { + let bytes = packet.to_bytes().freeze(); + let len = (bytes.len() as u16).to_be_bytes(); + let slices = [IoSlice::new(&len), IoSlice::new(&bytes)]; + self.tcp_writer.write_vectored(&slices).await + } + + fn notify_idle(&mut self) { + let Some(room) = &self.room else { + return; + }; + + if self.state.idle(self.id, &room.room_id()) { + self.tcp_writer.idling = false; + } + } +} diff --git a/src/constant.rs b/src/constant.rs new file mode 100644 index 0000000..44edbe1 --- /dev/null +++ b/src/constant.rs @@ -0,0 +1,81 @@ +use crate::error::AppError; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::convert::TryFrom; + +#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq)] +#[repr(u8)] +pub enum RoomCloseReason { + Closed = 0, + OutdatedVersion = 1, + ServerClosed = 2, +} + +impl TryFrom for RoomCloseReason { + type Error = AppError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(RoomCloseReason::Closed), + 1 => Ok(RoomCloseReason::OutdatedVersion), + 2 => Ok(RoomCloseReason::ServerClosed), + _ => Err(AppError::PacketParsing(format!( + "Invalid CloseReason: {}", + value + ))), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq)] +#[repr(u8)] +pub enum ConnectionCloseReason { + Closed = 0, + Timeout = 1, + Error = 2, + PacketSpam = 3, +} + +impl TryFrom for ConnectionCloseReason { + type Error = AppError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(ConnectionCloseReason::Closed), + 1 => Ok(ConnectionCloseReason::Timeout), + 2 => Ok(ConnectionCloseReason::Error), + 3 => Ok(ConnectionCloseReason::PacketSpam), + _ => Err(AppError::PacketParsing(format!( + "Invalid ConnectionCloseReason: {}", + value + ))), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq)] +#[repr(u8)] +pub enum MessageType { + ServerClosing = 0, + PacketSpamming = 1, + AlreadyHosting = 2, + RoomClosureDenied = 3, + ConClosureDenied = 4, +} + +impl TryFrom for MessageType { + type Error = AppError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(MessageType::ServerClosing), + 1 => Ok(MessageType::PacketSpamming), + 2 => Ok(MessageType::AlreadyHosting), + 3 => Ok(MessageType::RoomClosureDenied), + 4 => Ok(MessageType::ConClosureDenied), + _ => Err(AppError::PacketParsing(format!( + "Invalid MessageType: {}", + value + ))), + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..dfe79be --- /dev/null +++ b/src/error.rs @@ -0,0 +1,28 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Packet parsing error: {0}")] + PacketParsing(String), + + #[error("Room not found: {0}")] + RoomNotFound(String), + + #[error("Authentication failed: {0}")] + AuthFailed(String), + + #[error("Packet spamming detected")] + PacketSpamming, + + #[error("Lock poison error")] + LockPoison, + + #[error("Invalid state: {0}")] + InvalidState(String), + + #[error("Unknown error: {0}")] + Unknown(String), +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..4da2b7c --- /dev/null +++ b/src/http.rs @@ -0,0 +1,86 @@ +use crate::models::{RemoveRemoveEvent, RoomUpdateEvent, RoomView}; +use crate::state::{AppState, RoomUpdate}; +use axum::{ + extract::State, + http::header, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, + }, + routing::{get, post}, + Router, +}; +use futures::stream::once; +use std::sync::Arc; +use std::time::Duration; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::StreamExt; +use tracing::info; + +pub async fn run(state: Arc, port: u16) -> anyhow::Result<()> { + let app = Router::new() + .route("/ping", get(ping)) + .route("/rooms", get(rooms_sse)) + .route("/:roomId", post(room_port)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?; + info!("HTTP Server listening on {}", port); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn ping() -> impl IntoResponse { + "OK" +} + +async fn rooms_sse(State(state): State>) -> impl IntoResponse { + let rx = state.room_state.broadcast_sender.subscribe(); + let stream = BroadcastStream::new(rx); + + let initial_rooms: Vec = state.room_state.into_views(); + + let init_stream = once(async move { + Event::default() + .event("update") + .json_data(initial_rooms) + .map_err(axum::BoxError::from) + }); + + let update_stream = stream.filter_map(|msg| match msg { + Ok(update) => { + let data = match update { + RoomUpdate::Update { id, data } => Event::default() + .event("update") + .json_data(vec![RoomUpdateEvent { + room_id: id.0, + data: RoomView::from(&data), + }]) + .map_err(axum::BoxError::from), + RoomUpdate::Remove(id) => Event::default() + .event("remove") + .json_data(RemoveRemoveEvent { room_id: id.0 }) + .map_err(axum::BoxError::from), + }; + + Some(data) + } + Err(BroadcastStreamRecvError::Lagged(_)) => None, + }); + + let stream = init_stream.chain(update_stream); + + ( + [ + (header::CONTENT_TYPE, "text/event-stream"), + (header::CACHE_CONTROL, "no-cache"), + (header::CONNECTION, "keep-alive"), + ], + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(3))), + ) +} + +async fn room_port(State(state): State>) -> impl IntoResponse { + state.config.player_connect_port.to_string() +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..397d5e6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,52 @@ +pub mod config; +pub mod connection; +pub mod constant; +pub mod error; +pub mod http; +pub mod models; +pub mod packet; +pub mod proxy; +pub mod rate; +pub mod state; +pub mod utils; +pub mod writer; + +use config::Config; +use state::AppState; +use std::sync::Arc; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::DEBUG) + .finish(); + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + // Load config + let config = Config::from_env()?; + info!("Starting PlayerConnect Server V2..."); + info!("TCP Port: {}", config.player_connect_port); + info!("HTTP Port: {}", config.player_connect_http_port); + + // Initialize state + let state = Arc::new(AppState::new(config.clone())); + + // Start Proxy Server + let proxy_state = state.clone(); + let proxy_port = config.player_connect_port; + tokio::spawn(async move { + if let Err(e) = proxy::run(proxy_state, proxy_port).await { + tracing::error!("Proxy server error: {}", e); + } + }); + + // Start HTTP Server + let http_state = state.clone(); + let http_port = config.player_connect_http_port; + http::run(http_state, http_port).await?; + + Ok(()) +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..e3ccf6b --- /dev/null +++ b/src/models.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stats { + pub players: Vec, + #[serde(rename = "mapName")] + pub map_name: String, + pub name: String, + pub gamemode: String, + pub mods: Vec, + pub locale: String, + pub version: String, + #[serde(rename = "createdAt")] + pub created_at: u128, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Player { + pub name: String, + pub locale: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RoomView { + pub name: String, + pub status: String, + #[serde(rename = "isPrivate")] + pub is_private: bool, + #[serde(rename = "isSecured")] + pub is_secured: bool, + pub players: Vec, + #[serde(rename = "mapName")] + pub map_name: String, + pub gamemode: String, + pub mods: Vec, + pub locale: String, + pub version: String, + #[serde(rename = "createdAt")] + pub created_at: u128, + pub ping: u128, + #[serde(rename = "protocolVersion")] + pub protocol_version: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RemoveRemoveEvent { + #[serde(rename = "roomId")] + pub room_id: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RoomUpdateEvent { + #[serde(rename = "roomId")] + pub room_id: String, + pub data: RoomView, +} diff --git a/src/packet.rs b/src/packet.rs new file mode 100644 index 0000000..32164d6 --- /dev/null +++ b/src/packet.rs @@ -0,0 +1,427 @@ +use crate::constant::{ConnectionCloseReason, MessageType, RoomCloseReason}; +use crate::error::AppError; +use crate::models::Stats; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use std::convert::TryFrom; +use std::io::Cursor; + +pub const APP_PACKET_ID: i8 = -4; +pub const FRAMEWORK_PACKET_ID: i8 = -2; + +// These packet require server to send a idle packet to keep the host sending stream +pub const STREAM_BEGIN_PACKET_ID: u8 = 0; +pub const STREAM_CHUNK_PACKET_ID: u8 = 1; +pub const WORLD_STREAM_PACKET_ID: u8 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConnectionId(pub i32); + +impl std::fmt::Display for ConnectionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RoomId(pub String); + +impl std::fmt::Display for RoomId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone)] +pub enum AnyPacket { + Framework(FrameworkMessage), + App(AppPacket), + Raw(Bytes), +} + +#[derive(Debug, Clone, Copy)] +pub enum FrameworkMessage { + Ping { id: u32, is_reply: bool }, + DiscoverHost, + KeepAlive, + RegisterUDP { connection_id: ConnectionId }, + RegisterTCP { connection_id: ConnectionId }, +} + +#[derive(Debug, Clone)] +pub enum AppPacket { + ConnectionPacketWrap(ConnectionPacketWrapPacket), + ConnectionClosed(ConnectionClosedPacket), + ConnectionJoin(ConnectionJoinPacket), + ConnectionIdling(ConnectionIdlingPacket), + RoomLink(RoomLinkPacket), + RoomJoin(RoomJoinPacket), + RoomClosureRequest(RoomClosureRequestPacket), + RoomClosed(RoomClosedPacket), + RoomCreationRequest(RoomCreationRequestPacket), + Message(MessagePacket), + Popup(PopupPacket), + Message2(Message2Packet), + Stats(StatsPacket), + Ping(PingPacket), +} + +#[derive(Debug, Clone)] +pub struct ConnectionPacketWrapPacket { + pub connection_id: ConnectionId, + pub is_tcp: bool, + pub buffer: Bytes, +} + +#[derive(Debug, Clone)] +pub struct ConnectionClosedPacket { + pub connection_id: ConnectionId, + pub reason: ConnectionCloseReason, +} + +#[derive(Debug, Clone)] +pub struct ConnectionJoinPacket { + pub connection_id: ConnectionId, + pub room_id: RoomId, +} + +#[derive(Debug, Clone)] +pub struct ConnectionIdlingPacket { + pub connection_id: ConnectionId, +} + +#[derive(Debug, Clone)] +pub struct RoomLinkPacket { + pub room_id: RoomId, +} + +#[derive(Debug, Clone)] +pub struct RoomJoinPacket { + pub room_id: RoomId, + pub password: String, +} + +#[derive(Debug, Clone)] +pub struct RoomClosureRequestPacket; + +#[derive(Debug, Clone)] +pub struct RoomClosedPacket { + pub reason: RoomCloseReason, +} + +#[derive(Debug, Clone)] +pub struct RoomCreationRequestPacket { + pub version: String, + pub password: String, + pub data: Stats, +} + +#[derive(Debug, Clone)] +pub struct MessagePacket { + pub message: String, +} + +#[derive(Debug, Clone)] +pub struct PopupPacket { + pub message: String, +} + +#[derive(Debug, Clone)] +pub struct Message2Packet { + pub message: MessageType, +} + +#[derive(Debug, Clone)] +pub struct StatsPacket { + pub data: Stats, +} + +#[derive(Debug, Clone)] +pub struct PingPacket { + pub send_at: i64, +} + +impl AnyPacket { + pub fn is_stream_packet(bytes: &Bytes) -> bool { + bytes.starts_with(&[STREAM_BEGIN_PACKET_ID]) + || bytes.starts_with(&[STREAM_CHUNK_PACKET_ID]) + || bytes.starts_with(&[WORLD_STREAM_PACKET_ID]) + } + + pub fn read(buf: &mut Cursor) -> Result { + if !buf.has_remaining() { + return Err(AppError::PacketParsing("Empty packet".to_string())); + } + + let id = buf.get_i8(); + + let result = match id { + FRAMEWORK_PACKET_ID => Ok(AnyPacket::Framework(FrameworkMessage::read(buf)?)), + APP_PACKET_ID => Ok(AnyPacket::App(AppPacket::read(buf)?)), + _ => { + buf.set_position(buf.position() - 1); + let remaining = buf.remaining(); + let start = buf.position() as usize; + let end = start + remaining; + + let bytes = buf.get_ref().slice(start..end); + buf.advance(remaining); + + Ok(AnyPacket::Raw(bytes)) + } + }; + + if buf.has_remaining() { + return Err(AppError::PacketParsing(format!( + "Extra data ({} bytes) after packet", + buf.remaining() + ))); + } + + result + } + + pub fn to_bytes(&self) -> BytesMut { + let mut payload = BytesMut::new(); + + match self { + AnyPacket::Framework(package) => { + package.write(&mut payload); + } + AnyPacket::App(package) => { + package.write(&mut payload); + } + AnyPacket::Raw(raw) => { + payload.extend_from_slice(raw); + } + } + payload + } +} + +impl FrameworkMessage { + pub fn read(buf: &mut Cursor) -> Result { + let fid = buf.get_u8(); + + match fid { + 0 => Ok(FrameworkMessage::Ping { + id: buf.get_u32(), + is_reply: buf.get_u8() != 0, + }), + 1 => Ok(FrameworkMessage::DiscoverHost), + 2 => Ok(FrameworkMessage::KeepAlive), + 3 => Ok(FrameworkMessage::RegisterUDP { + connection_id: ConnectionId(buf.get_i32()), + }), + 4 => Ok(FrameworkMessage::RegisterTCP { + connection_id: ConnectionId(buf.get_i32()), + }), + _ => Err(AppError::PacketParsing(format!( + "Unknown Framework ID: {}", + fid + ))), + } + } + + pub fn write(&self, buf: &mut BytesMut) { + buf.put_i8(FRAMEWORK_PACKET_ID); + + match self { + FrameworkMessage::Ping { id, is_reply } => { + buf.put_u8(0); + buf.put_u32(*id); + buf.put_u8(if *is_reply { 1 } else { 0 }); + } + FrameworkMessage::DiscoverHost => buf.put_u8(1), + FrameworkMessage::KeepAlive => buf.put_u8(2), + FrameworkMessage::RegisterUDP { connection_id } => { + buf.put_u8(3); + buf.put_i32(connection_id.0); + } + FrameworkMessage::RegisterTCP { connection_id } => { + buf.put_u8(4); + buf.put_i32(connection_id.0); + } + } + } +} + +impl AppPacket { + pub fn read(buf: &mut Cursor) -> Result { + let pid = buf.get_u8(); + + match pid { + 0 => { + let connection_id = ConnectionId(buf.get_i32()); + let is_tcp = buf.get_u8() != 0; + + let start = buf.position() as usize; + let end = start + buf.remaining(); + let buffer = buf.get_ref().slice(start..end); + + buf.set_position(end as u64); + + Ok(AppPacket::ConnectionPacketWrap( + ConnectionPacketWrapPacket { + connection_id, + is_tcp, + buffer, + }, + )) + } + 1 => Ok(AppPacket::ConnectionClosed(ConnectionClosedPacket { + connection_id: ConnectionId(buf.get_i32()), + reason: ConnectionCloseReason::try_from(buf.get_u8())?, + })), + 2 => Ok(AppPacket::ConnectionJoin(ConnectionJoinPacket { + connection_id: ConnectionId(buf.get_i32()), + room_id: RoomId(read_string(buf)?), + })), + 3 => Ok(AppPacket::ConnectionIdling(ConnectionIdlingPacket { + connection_id: ConnectionId(buf.get_i32()), + })), + 4 => Ok(AppPacket::RoomCreationRequest(RoomCreationRequestPacket { + version: read_string(buf)?, + password: read_string(buf)?, + data: read_stats(buf)?, + })), + 5 => Ok(AppPacket::RoomClosureRequest(RoomClosureRequestPacket)), + 6 => Ok(AppPacket::RoomClosed(RoomClosedPacket { + reason: RoomCloseReason::try_from(buf.get_u8())?, + })), + 7 => Ok(AppPacket::RoomLink(RoomLinkPacket { + room_id: RoomId(read_string(buf)?), + })), + 8 => Ok(AppPacket::RoomJoin(RoomJoinPacket { + room_id: RoomId(read_string(buf)?), + password: read_string(buf)?, + })), + 9 => Ok(AppPacket::Message(MessagePacket { + message: read_string(buf)?, + })), + 10 => Ok(AppPacket::Message2(Message2Packet { + message: MessageType::try_from(buf.get_u8())?, + })), + 11 => Ok(AppPacket::Popup(PopupPacket { + message: read_string(buf)?, + })), + 12 => Ok(AppPacket::Stats(StatsPacket { + data: read_stats(buf)?, + })), + 13 => Ok(AppPacket::Ping(PingPacket { + send_at: buf.get_i64(), + })), + _ => Err(AppError::PacketParsing(format!( + "Unknown App Packet ID: {}", + pid + ))), + } + } + + pub fn write(&self, buf: &mut BytesMut) { + buf.put_i8(APP_PACKET_ID as i8); + + match self { + AppPacket::ConnectionPacketWrap(p) => { + buf.put_u8(0); + buf.put_i32(p.connection_id.0); + buf.put_u8(if p.is_tcp { 1 } else { 0 }); + buf.extend_from_slice(&p.buffer); + } + AppPacket::ConnectionClosed(p) => { + buf.put_u8(1); + buf.put_i32(p.connection_id.0); + buf.put_u8(p.reason as u8); + } + AppPacket::ConnectionJoin(p) => { + buf.put_u8(2); + buf.put_i32(p.connection_id.0); + write_string(buf, &p.room_id.0); + } + AppPacket::ConnectionIdling(p) => { + buf.put_u8(3); + buf.put_i32(p.connection_id.0); + } + AppPacket::RoomCreationRequest(_) => { + panic!("Client only") + } + AppPacket::RoomClosureRequest(_) => { + buf.put_u8(5); + } + AppPacket::RoomClosed(p) => { + buf.put_u8(6); + buf.put_u8(p.reason as u8); + } + AppPacket::RoomLink(p) => { + buf.put_u8(7); + write_string(buf, &p.room_id.0); + } + AppPacket::RoomJoin(p) => { + buf.put_u8(8); + write_string(buf, &p.room_id.0); + write_string(buf, &p.password); + } + AppPacket::Message(p) => { + buf.put_u8(9); + write_string(buf, &p.message); + } + AppPacket::Message2(p) => { + buf.put_u8(10); + buf.put_u8(p.message as u8); + } + AppPacket::Popup(p) => { + buf.put_u8(11); + write_string(buf, &p.message); + } + AppPacket::Stats(_) => { + panic!("Client only") + } + AppPacket::Ping(p) => { + buf.put_u8(13); + buf.put_i64(p.send_at); + } + } + } +} + +pub fn read_string(buf: &mut Cursor) -> Result { + if buf.remaining() < 2 { + return Err(AppError::PacketParsing(format!( + "Not enough bytes for string length: {}", + buf.remaining() + ))); + } + + let len = buf.get_u16() as usize; + + if buf.remaining() < len { + return Err(AppError::PacketParsing(format!( + "Not enough bytes for string content, expected {}, got {}", + len, + buf.remaining() + ))); + } + + let mut bytes = vec![0u8; len]; + + buf.copy_to_slice(&mut bytes); + + String::from_utf8(bytes).map_err(|e| AppError::PacketParsing(e.to_string())) +} + +pub fn write_string(buf: &mut BytesMut, s: &str) { + let bytes = s.as_bytes(); + buf.put_u16(bytes.len() as u16); + buf.put_slice(bytes); +} + +pub fn read_stats(buf: &mut Cursor) -> Result { + let json = read_string(buf)?; + + match serde_json::from_str::(&json) { + Ok(data) => Ok(data), + Err(e) => Err(AppError::PacketParsing(format!( + "Failed to parse stats: {}. JSON: {}", + e, json + ))), + } +} diff --git a/src/playerconnect/Configs.java b/src/playerconnect/Configs.java deleted file mode 100644 index 2ebf35b..0000000 --- a/src/playerconnect/Configs.java +++ /dev/null @@ -1,8 +0,0 @@ -package playerconnect; - -import arc.struct.Seq; - -public class Configs { - public static final Seq IP_BLACK_LIST = new Seq<>(); - public static final int SPAM_LIMIT = 300; -} diff --git a/src/playerconnect/HttpServer.java b/src/playerconnect/HttpServer.java deleted file mode 100644 index 7123bcd..0000000 --- a/src/playerconnect/HttpServer.java +++ /dev/null @@ -1,283 +0,0 @@ -package playerconnect; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.SerializationFeature; - -import arc.Events; -import arc.net.DcReason; -import arc.struct.Seq; -import arc.util.Log; -import io.javalin.Javalin; -import io.javalin.json.JavalinJackson; -import io.javalin.plugin.bundled.RouteOverviewPlugin; -import mindustry.game.Gamemode; -import playerconnect.PlayerConnectEvents.RoomClosedEvent; -import playerconnect.shared.Packets; -import playerconnect.shared.Packets.RoomStats; -import io.javalin.http.Context; -import io.javalin.http.sse.SseClient; - -import lombok.Data; - -public class HttpServer { - - private Javalin app; - - private final Queue statsConsumers = new ConcurrentLinkedQueue<>(); - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - - private final String magicWord = "ditmemay"; - - public HttpServer() { - app = Javalin.create(config -> { - config.showJavalinBanner = false; - config.jsonMapper(new JavalinJackson().updateMapper(mapper -> { - mapper// - - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)// - .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)// - .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) - .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - - })); - - config.http.asyncTimeout = 5_000; - config.useVirtualThreads = true; - - config.registerPlugin(new RouteOverviewPlugin()); - - config.requestLogger.http((ctx, ms) -> { - if (!ctx.fullUrl().contains("stats")) { - Log.info("[" + ctx.method().name() + "] " + Math.round(ms) + "ms " + ctx.fullUrl()); - } - }); - - config.bundledPlugins.enableCors(cors -> { - cors.addRule(rules -> { - rules.anyHost(); - }); - }); - }); - - app.sse("rooms", client -> { - client.keepAlive(); - - client.onClose(() -> { - Log.info("Client removed: <" + client + ">"); - statsConsumers.remove(client); - }); - - ArrayList data = PlayerConnect.relay.rooms - .values() - .toSeq() - .map(room -> toLiveData(room, room.stats)) - .list(); - - Log.info("Client connected <" + client + "> sending " + data.size() + " rooms"); - - client.sendEvent("update", data); - - statsConsumers.add(client); - }); - - app.get("ping", ctx -> ctx.result("pong")); - - app.get("connections", ctx -> { - if (!auth(ctx)) { - return; - } - - var result = Seq.with(PlayerConnect.relay.getConnections()) - .map(connection -> { - - ConnectionDto dto = new ConnectionDto(); - dto.id = Utils.toString(connection); - dto.ip = Utils.getIP(connection); - - return dto; - }).list(); - - ctx.json(result); - }); - - app.post("ban", ctx -> { - if (!auth(ctx)) { - return; - } - - var ip = ctx.body(); - - for (var connection : PlayerConnect.relay.getConnections()) { - if (Utils.getIP(connection).equals(ip)) { - connection.close(DcReason.closed); - Log.info("Kicked client <" + Utils.toString(connection) + "> for IP ban"); - Events.fire(new PlayerConnectEvents.ClientKickedEvent(connection)); - } - } - - Configs.IP_BLACK_LIST.add(ip); - - ctx.json(Configs.IP_BLACK_LIST.list()); - }); - - app.post("unban", ctx -> { - if (!auth(ctx)) { - return; - } - - var ip = ctx.body(); - Configs.IP_BLACK_LIST.remove(ip); - - ctx.json(Configs.IP_BLACK_LIST.list()); - }); - - app.start(Integer.parseInt(System.getenv("PLAYER_CONNECT_HTTP_PORT"))); - - Events.on(PlayerConnectEvents.RoomCreatedEvent.class, event -> { - try { - sendUpdateEvent(toLiveData(event.room, event.room.stats)); - } catch (Exception error) { - Log.err("Failed to send initial room stats", error); - } - }); - - Events.on(Packets.StatsPacket.class, event -> { - try { - ServerRoom room = PlayerConnect.relay.rooms.get(event.roomId); - - if (room != null) { - sendUpdateEvent(toLiveData(room, event.data)); - } else { - Log.warn("Update stats for non-existent room"); - } - } catch (Exception error) { - Log.err("Failed to send room stats", error); - } - }); - - Events.on(RoomClosedEvent.class, event -> { - sendRemoveEvent(event.room.id); - }); - - scheduler.scheduleWithFixedDelay(() -> { - try { - statsConsumers.forEach(client -> client.sendComment("Kept alive")); - } catch (Exception error) { - Log.err("Failed to send keep alive comment", error); - } - }, 0, 1, TimeUnit.SECONDS); - } - - private boolean auth(Context context) { - var word = context.header("Authorization"); - - if (!word.equals(magicWord)) { - context.status(403); - context.result("Forbidden"); - return false; - } - - return true; - } - - private void sendUpdateEvent(StatsLiveEvent stat) { - try { - ArrayList response = new ArrayList<>(); - - response.add(stat); - - for (SseClient client : statsConsumers) { - client.sendEvent("update", response); - } - } catch (Exception error) { - Log.err("Failed to send update event", error); - } - } - - private void sendRemoveEvent(String roomId) { - try { - - Log.info("Sent remove event for " + roomId); - - HashMap response = new HashMap<>(); - - response.put("roomId", roomId); - - for (SseClient client : statsConsumers) { - client.sendEvent("remove", response); - } - } catch (Exception error) { - Log.err("Failed to send remove event", error); - } - } - - private StatsLiveEvent toLiveData(ServerRoom room, RoomStats stats) { - StatsLiveEvent response = new StatsLiveEvent(); - - StatsLiveEventData data = new StatsLiveEventData(); - - data.mapName = stats.mapName; - data.name = stats.name; - data.gamemode = stats.gamemode; - data.mods = stats.mods.list(); - data.isSecured = room.password != null && !room.password.isEmpty(); - data.locale = stats.locale; - data.version = stats.version; - data.createdAt = room.createdAt; - data.ping = room.ping; - - for (Packets.RoomPlayer playerData : stats.players) { - StatsLiveEventPlayerData player = new StatsLiveEventPlayerData(); - player.name = playerData.name; - player.locale = playerData.locale; - data.players.add(player); - } - - response.roomId = room.id; - response.data = data; - - return response; - } - - @Data - private static class StatsLiveEvent { - public String roomId = null; - public StatsLiveEventData data; - } - - @Data - private static class StatsLiveEventData { - public String name = ""; - public String status = "UP"; - public boolean isPrivate = false; - public boolean isSecured = false; - public ArrayList players = new ArrayList<>(); - public String mapName = "unknown"; - public String gamemode = Gamemode.survival.name(); - public ArrayList mods = new ArrayList<>(); - public String locale; - public String version; - public Long createdAt; - public Long ping = 0L; - } - - @Data - private static class StatsLiveEventPlayerData { - public String name = ""; - public String locale = "en"; - } - - @Data - private static class ConnectionDto { - public String id = ""; - public String ip = ""; - } -} diff --git a/src/playerconnect/NetworkRelay.java b/src/playerconnect/NetworkRelay.java deleted file mode 100644 index c144ce5..0000000 --- a/src/playerconnect/NetworkRelay.java +++ /dev/null @@ -1,455 +0,0 @@ -package playerconnect; - -import java.nio.ByteBuffer; - -import arc.Events; -import arc.net.Connection; -import arc.net.DcReason; -import arc.net.FrameworkMessage; -import arc.net.FrameworkMessage.*; -import arc.net.NetListener; -import arc.net.NetSerializer; -import arc.net.Server; -import arc.struct.IntMap; -import arc.struct.IntSet; -import arc.struct.ObjectMap; -import arc.util.Log; -import arc.util.Ratekeeper; -import arc.util.io.ByteBufferInput; -import arc.util.io.ByteBufferOutput; -import playerconnect.shared.Packets; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class NetworkRelay extends Server implements NetListener { - protected boolean isClosed; - - /** - * Keeps a cache of packets received from connections that are not yet in a - * room. (queue of 3 packets)
- * Because sometimes {@link Packets.RoomJoinPacket} comes after - * {@link Packets.ConnectPacket}, - * when the client connection is slow, so the server will ignore this essential - * packet and the client - * will waits until the timeout. - */ - protected final IntMap packetQueue = new IntMap<>(); - /** Size of the packet queue */ - protected final int packetQueueSize = 6; - /** - * Keeps a cache of already notified idling connection, to avoid packet - * spamming. - */ - protected final IntSet notifiedIdle = new IntSet(); - /** List of created rooms */ - public final ObjectMap rooms = new ObjectMap<>(); - - public final int ROOM_IDLE_TIMEOUT = 10 * 60 * 1000; - - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - - public NetworkRelay() { - super(32768, 16384, new Serializer()); - addListener(this); - - scheduler.scheduleWithFixedDelay(() -> { - var delete = rooms.values().toSeq() - .select(r -> r.updatedAt < System.currentTimeMillis() - ROOM_IDLE_TIMEOUT); - for (ServerRoom room : delete) { - rooms.remove(room.id); - room.close(Packets.RoomClosedPacket.CloseReason.closed); - Events.fire(new PlayerConnectEvents.RoomClosedEvent(room)); - } - }, 0, 60, TimeUnit.SECONDS); - } - - @Override - public void run() { - isClosed = false; - super.run(); - } - - @Override - public void stop() { - isClosed = true; - Events.fire(new PlayerConnectEvents.ServerStoppingEvent()); - closeRooms(); - super.stop(); - } - - public boolean isClosed() { - return isClosed; - } - - public void closeRooms() { - try { - rooms.values().forEach(r -> { - r.close(Packets.RoomClosedPacket.CloseReason.serverClosed); - Events.fire(new PlayerConnectEvents.RoomClosedEvent(r)); - }); - } catch (Throwable ignored) { - } - rooms.clear(); - } - - @Override - public void connected(Connection connection) { - if (isClosed()) { - Log.info("Connection @ denied, server closed.", Utils.toString(connection)); - connection.close(DcReason.closed); - return; - } - - if (Configs.IP_BLACK_LIST.contains(Utils.getIP(connection))) { - Log.info("Connection @ denied, ip banned: @", Utils.toString(connection), Utils.getIP(connection)); - connection.close(DcReason.closed); - return; - } - - Log.info("Connection @ received, ip: @", Utils.toString(connection), Utils.getIP(connection)); - connection.setArbitraryData(new Ratekeeper()); - connection.setName("Connection" + Utils.toString(connection)); // fix id format in stacktraces - Events.fire(new PlayerConnectEvents.ClientConnectedEvent(connection)); - } - - @Override - public void disconnected(Connection connection, DcReason reason) { - if (reason != DcReason.error) { - Log.info("Connection @ lost: @.", Utils.toString(connection), reason); - } - - if (connection.getLastProtocolError() != null) { - Log.err(connection.getLastProtocolError()); - } - - notifiedIdle.remove(connection.getID()); - packetQueue.remove(connection.getID()); - - // Avoid searching for a room if it was an invalid connection or just a ping - if (!(connection.getArbitraryData() instanceof Ratekeeper)) - return; - - ServerRoom room = find(connection); - - if (room != null) { - room.disconnected(connection, reason); - // Remove the room if it was the host - - if (connection == room.host) { - rooms.remove(room.id); - Log.info("Room @ closed because connection @ (the host) has disconnected.", room.id, - Utils.toString(connection)); - Events.fire(new PlayerConnectEvents.RoomClosedEvent(room)); - } else - Log.info("Connection @ left the room @.", Utils.toString(connection), room.id); - } - - Events.fire(new PlayerConnectEvents.ClientDisconnectedEvent(connection, reason, room)); - } - - @Override - public void received(Connection connection, Object object) { - try { - if (!(connection.getArbitraryData() instanceof Ratekeeper) || (object instanceof FrameworkMessage)) - return; - - notifiedIdle.remove(connection.getID()); - - Ratekeeper rate = (Ratekeeper) connection.getArbitraryData(); - ServerRoom room = find(connection); - - // Simple packet spam protection, ignored for room hosts - if ((room == null || room.host != connection) && Configs.SPAM_LIMIT > 0 - && !rate.allow(3000L, Configs.SPAM_LIMIT)) { - - if (room != null) { - room.message(Packets.Message2Packet.MessageType.packetSpamming); - room.disconnected(connection, DcReason.closed); - } - - connection.close(DcReason.closed); - Log.warn("Connection @ disconnected for packet spamming.", Utils.toString(connection)); - Events.fire(new PlayerConnectEvents.ClientKickedEvent(connection)); - - } else if (object instanceof Packets.StatsPacket) { - Packets.StatsPacket statsPacket = (Packets.StatsPacket) object; - if (room != null) { - room.stats = statsPacket.data; - room.updatedAt = System.currentTimeMillis(); - room.ping = System.currentTimeMillis() - statsPacket.data.createdAt; - Events.fire(statsPacket); - } - } else if (object instanceof Packets.RoomJoinPacket) { - Packets.RoomJoinPacket joinPacket = (Packets.RoomJoinPacket) object; - // Disconnect from a potential another room. - if (room != null) { - // Ignore if it's the host of another room - if (room.host == connection) { - room.message(Packets.Message2Packet.MessageType.alreadyHosting); - Log.warn("Connection @ tried to join the room @ but is already hosting the room @.", - Utils.toString(connection), joinPacket.roomId, room.id); - Events.fire(new PlayerConnectEvents.ActionDeniedEvent(connection, - Packets.Message2Packet.MessageType.alreadyHosting)); - return; - } - // Disconnect from the room - Log.info("Connection @ left the room @ when trying to join", Utils.toString(connection), room.id); - room.disconnected(connection, DcReason.closed); - } - - room = get(((Packets.RoomJoinPacket) object).roomId); - if (room != null) { - if (!room.password.equals(joinPacket.password)) { - mindustry.net.Packets.Disconnect p = new mindustry.net.Packets.Disconnect(); - p.reason = "Wrong password"; - connection.sendTCP(p); - Log.info("Connection @ tried to join the room @ with wrong password.", - Utils.toString(connection), room.id); - return; - } - - room.connected(connection); - Log.info("Connection @ joined the room @.", Utils.toString(connection), room.id); - - // Send the queued packets of connections to room host - ByteBuffer[] queue = packetQueue.remove(connection.getID()); - if (queue != null) { - Log.debug("Sending queued packets of connection @ to room host.", - Utils.toString(connection)); - for (int i = 0; i < queue.length; i++) { - if (queue[i] != null) - room.received(connection, queue[i]); - } - } - - Events.fire(new PlayerConnectEvents.ConnectionJoinedEvent(connection, room)); - } else { - connection.close(DcReason.error); - Log.info("Connection @ tried to join a non-existent room @.", - Utils.toString(connection), joinPacket.roomId); - Log.info("Room list: @", rooms.values().toSeq().map(r -> r.id).toString()); - } - - } else if (object instanceof Packets.RoomCreationRequestPacket) { - // Ignore room creation requests when the server is closing - Packets.RoomCreationRequestPacket packet = (Packets.RoomCreationRequestPacket) object; - - if (isClosed()) { - Packets.RoomClosedPacket p = new Packets.RoomClosedPacket(); - p.reason = Packets.RoomClosedPacket.CloseReason.serverClosed; - connection.sendTCP(p); - connection.close(DcReason.error); - Events.fire(new PlayerConnectEvents.RoomCreationRejectedEvent(connection, p.reason)); - Log.info("Ignore room creation, server is closing"); - return; - } - - // Ignore if the connection is already in a room or hold one - if (room != null) { - room.message(Packets.Message2Packet.MessageType.alreadyHosting); - Log.warn("Connection @ tried to create a room but is already hosting the room @.", - Utils.toString(connection), room.id); - Events.fire(new PlayerConnectEvents.ActionDeniedEvent(connection, - Packets.Message2Packet.MessageType.alreadyHosting)); - return; - } - - room = new ServerRoom(connection, packet.password, packet.data); - - rooms.put(room.id, room); - room.create(); - Log.info("Room @ created by connection @.", room.id, Utils.toString(connection)); - Events.fire(new PlayerConnectEvents.RoomCreatedEvent(room)); - - } else if (object instanceof Packets.RoomClosureRequestPacket) { - // Only room host can close the room - if (room == null) - return; - if (room.host != connection) { - room.message(Packets.Message2Packet.MessageType.roomClosureDenied); - Log.warn("Connection @ tried to close the room @ but is not the host.", - Utils.toString(connection), - room.id); - Events.fire(new PlayerConnectEvents.ActionDeniedEvent(connection, - Packets.Message2Packet.MessageType.roomClosureDenied)); - return; - } - - rooms.remove(room.id); - room.close(); - Log.info("Room @ closed by connection @ (the host).", room.id, Utils.toString(connection)); - Events.fire(new PlayerConnectEvents.RoomClosedEvent(room)); - - } else if (object instanceof Packets.ConnectionClosedPacket) { - // Only room host can request a connection closing - if (room == null) - return; - - Packets.ConnectionClosedPacket closePacket = (Packets.ConnectionClosedPacket) object; - - if (room.host != connection) { - - room.message(Packets.Message2Packet.MessageType.conClosureDenied); - Log.warn("Connection @ tried to close the connection @ but is not the host of room @.", - Utils.toString(connection), closePacket.connectionId, room.id); - Events.fire(new PlayerConnectEvents.ActionDeniedEvent(connection, - Packets.Message2Packet.MessageType.conClosureDenied)); - return; - } - - int connectionId = closePacket.connectionId; - Connection con = arc.util.Structs.find(getConnections(), c -> c.getID() == connectionId); - DcReason reason = ((Packets.ConnectionClosedPacket) object).reason; - - // Ignore when trying to close itself or closing one that not in the same room - if (con == connection || !room.contains(con)) { - Log.warn("Connection @ (room @) tried to close a connection from another room.", - Utils.toString(connection), room.id); - return; - } - - if (con != null) { - Log.info("Connection @ (room @) closed the connection @.", Utils.toString(connection), room.id, - Utils.toString(con)); - room.disconnectedQuietly(con, reason); - con.close(reason); - // An event for this is useless, #disconnected() will trigger it - } - - // Ignore if the connection is not in a room - } else if (room != null) { - if (room.host == connection && (object instanceof Packets.ConnectionWrapperPacket)) - notifiedIdle.remove(((Packets.ConnectionWrapperPacket) object).connectionId); - - room.received(connection, object); - - // Puts in queue; if full, future packets will be ignored. - } else if (object instanceof ByteBuffer) { - ByteBuffer[] queue = packetQueue.get(connection.getID(), () -> new ByteBuffer[packetQueueSize]); - ByteBuffer buffer = (ByteBuffer) object; - - for (int i = 0; i < queue.length; i++) { - if (queue[i] == null) { - queue[i] = (ByteBuffer) ByteBuffer.allocate(buffer.remaining()).put(buffer).rewind(); - break; - } - } - } else { - Log.warn("Unhandled packet: @", object); - } - } catch (Exception error) { - Log.err("Failed to handle: " + object, error); - } - } - - /** - * Does nothing if the connection idle state was already notified to the room - * host. - */ - @Override - public void idle(Connection connection) { - if (!(connection.getArbitraryData() instanceof Ratekeeper)) - return; - if (!notifiedIdle.add(connection.getID())) - return; - - ServerRoom room = find(connection); - if (room != null) - room.idle(connection); - } - - public ServerRoom get(String roomId) { - return rooms.get(roomId); - } - - public ServerRoom find(Connection con) { - for (ServerRoom r : rooms.values()) { - if (r.contains(con)) - return r; - } - return null; - } - - public static class Serializer implements NetSerializer { - private final ThreadLocal last = arc.util.Threads.local(() -> ByteBuffer.allocate(32768)); - - @Override - public Object read(ByteBuffer buffer) { - byte id = buffer.get(); - - if (id == -2/* framework id */) - return readFramework(buffer); - - if (id == Packets.id) { - Packets.Packet packet = Packets.newPacket(buffer.get()); - packet.read(new ByteBufferInput(buffer)); - if (packet instanceof Packets.ConnectionPacketWrapPacket) // This one is special - ((Packets.ConnectionPacketWrapPacket) packet).buffer = (ByteBuffer) ((ByteBuffer) last.get() - .clear()).put(buffer).flip(); - return packet; - } - - // Non-claj packets are saved as raw buffer, to avoid re-serialization - return ((ByteBuffer) last.get().clear()).put((ByteBuffer) buffer.position(buffer.position() - 1)).flip(); - } - - @Override - public void write(ByteBuffer buffer, Object object) { - if (object instanceof ByteBuffer) { - buffer.put((ByteBuffer) object); - - } else if (object instanceof FrameworkMessage) { - buffer.put((byte) -2); // framework id - writeFramework(buffer, (FrameworkMessage) object); - - } else if (object instanceof Packets.Packet) { - Packets.Packet packet = (Packets.Packet) object; - buffer.put(Packets.id).put(Packets.getId(packet)); - packet.write(new ByteBufferOutput(buffer)); - if (packet instanceof Packets.ConnectionPacketWrapPacket) // This one is special - buffer.put(((Packets.ConnectionPacketWrapPacket) packet).buffer); - } - } - - public void writeFramework(ByteBuffer buffer, FrameworkMessage message) { - if (message instanceof Ping) { - Ping ping = (Ping) message; - buffer.put((byte) 0).putInt(ping.id).put(ping.isReply ? (byte) 1 : 0); - } else if (message instanceof DiscoverHost) - buffer.put((byte) 1); - else if (message instanceof KeepAlive) - buffer.put((byte) 2); - else if (message instanceof RegisterUDP) - buffer.put((byte) 3).putInt(((RegisterUDP) message).connectionID); - else if (message instanceof RegisterTCP) - buffer.put((byte) 4).putInt(((RegisterTCP) message).connectionID); - } - - public FrameworkMessage readFramework(ByteBuffer buffer) { - byte id = buffer.get(); - - if (id == 0) { - Ping p = new Ping(); - p.id = buffer.getInt(); - p.isReply = buffer.get() == 1; - return p; - } else if (id == 1) { - return FrameworkMessage.discoverHost; - } else if (id == 2) { - return FrameworkMessage.keepAlive; - } else if (id == 3) { - RegisterUDP p = new RegisterUDP(); - p.connectionID = buffer.getInt(); - return p; - } else if (id == 4) { - RegisterTCP p = new RegisterTCP(); - p.connectionID = buffer.getInt(); - return p; - } else { - throw new RuntimeException("Unknown framework message!"); - } - } - } -} diff --git a/src/playerconnect/PlayerConnect.java b/src/playerconnect/PlayerConnect.java deleted file mode 100644 index 70dfc17..0000000 --- a/src/playerconnect/PlayerConnect.java +++ /dev/null @@ -1,45 +0,0 @@ -package playerconnect; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -import arc.Events; -import arc.net.ArcNet; -import arc.util.Log; - -public class PlayerConnect { - public static final NetworkRelay relay = new NetworkRelay(); - public static final ExecutorService executor = Executors.newSingleThreadExecutor(); - public static final HttpServer httpServer = new HttpServer(); - - public static void main(String[] args) { - try { - ArcNet.errorHandler = (error) -> Log.err(error); - int port = Integer.parseInt(System.getenv("PLAYER_CONNECT_PORT")); - - if (port < 0 || port > 0xffff) - throw new RuntimeException("Invalid port range"); - // Init event loop - - relay.bind(port, port); - Events.fire(new PlayerConnectEvents.ServerLoadedEvent()); - Log.info("Server loaded and hosted on port @. Type @ for help.", port, "'help'"); - - } catch (Throwable t) { - Log.err("Failed to load server", t); - System.exit(1); - return; - } - - // Start the server - try { - relay.run(); - } catch (Throwable t) { - Log.err(t); - } finally { - relay.close(); - Log.info("Server closed."); - } - } - -} diff --git a/src/playerconnect/PlayerConnectEvents.java b/src/playerconnect/PlayerConnectEvents.java deleted file mode 100644 index f271ec9..0000000 --- a/src/playerconnect/PlayerConnectEvents.java +++ /dev/null @@ -1,102 +0,0 @@ -package playerconnect; - -import arc.net.Connection; -import arc.net.DcReason; -import playerconnect.shared.Packets; - -public class PlayerConnectEvents { - public static class ServerLoadedEvent { - } - - public static class ServerStoppingEvent { - } - - public static class ClientConnectedEvent { - public final Connection connection; - - public ClientConnectedEvent(Connection connection) { - this.connection = connection; - } - } - - /** - * @apiNote this event comes after {@link RoomClosedEvent} if the connection was - * the room host. - */ - public static class ClientDisconnectedEvent { - public final Connection connection; - public final DcReason reason; - /** not {@code null} if the client was in a room */ - public final ServerRoom room; - - public ClientDisconnectedEvent(Connection connection, DcReason reason, ServerRoom room) { - this.connection = connection; - this.reason = reason; - this.room = room; - } - } - - /** Currently the only reason is for packet spam. */ - public static class ClientKickedEvent { - public final Connection connection; - - public ClientKickedEvent(Connection connection) { - this.connection = connection; - } - } - - /** When a connection join a room */ - public static class ConnectionJoinedEvent { - public final Connection connection; - public final ServerRoom room; - - public ConnectionJoinedEvent(Connection connection, ServerRoom room) { - this.connection = connection; - this.room = room; - } - } - - public static class RoomCreatedEvent { - public final ServerRoom room; - - public RoomCreatedEvent(ServerRoom room) { - this.room = room; - } - } - - public static class RoomClosedEvent { - /** @apiNote the room is closed, so it cannot be used anymore. */ - public final ServerRoom room; - - public RoomClosedEvent(ServerRoom room) { - this.room = room; - } - } - - public static class RoomCreationRejectedEvent { - /** the connection that tried to create the room */ - public final Connection connection; - public final Packets.RoomClosedPacket.CloseReason reason; - - public RoomCreationRejectedEvent(Connection connection, Packets.RoomClosedPacket.CloseReason reason) { - this.connection = connection; - this.reason = reason; - } - } - - /** - * Defines an action tried by a connection but was not allowed to do it. - *

- * E.g. a client of the room tried to close it, or the host tried to join - * another room while hosting one. - */ - public static class ActionDeniedEvent { - public final Connection connection; - public final Packets.Message2Packet.MessageType reason; - - public ActionDeniedEvent(Connection connection, Packets.Message2Packet.MessageType reason) { - this.connection = connection; - this.reason = reason; - } - } -} diff --git a/src/playerconnect/ServerRoom.java b/src/playerconnect/ServerRoom.java deleted file mode 100644 index f26f80c..0000000 --- a/src/playerconnect/ServerRoom.java +++ /dev/null @@ -1,238 +0,0 @@ -package playerconnect; - -import java.nio.ByteBuffer; -import java.util.Date; -import java.util.UUID; - -import arc.net.Connection; -import arc.net.DcReason; -import arc.net.NetListener; -import arc.struct.IntMap; -import arc.util.Log; -import playerconnect.shared.Packets; - -public class ServerRoom implements NetListener { - - public final String id; - public final Connection host; - public final String password; - /** Using IntMap instead of Seq for faster search */ - public Packets.RoomStats stats; - public Long ping = -1L; - private boolean isClosed; - public final IntMap clients = new IntMap<>(); - public final Long createdAt = System.currentTimeMillis(); - public Long updatedAt = System.currentTimeMillis(); - - public ServerRoom(Connection host, String password, Packets.RoomStats stats) { - this.id = UUID.randomUUID().toString(); - this.host = host; - this.stats = stats; - this.password = password; - this.ping = new Date().getTime() - stats.createdAt; - } - - @Override - public void connected(Connection connection) { - if (isClosed) - return; - - Packets.ConnectionJoinPacket p = new Packets.ConnectionJoinPacket(); - p.connectionId = connection.getID(); - p.roomId = id; - host.sendTCP(p); - - clients.put(connection.getID(), connection); - } - - /** Alert the host that a client disconnected */ - @Override - public void disconnected(Connection connection, DcReason reason) { - if (isClosed) - return; - - if (connection == host) { - Log.info("Host disconnected, closing room: " + id); - close(); - return; - - } else if (host.isConnected()) { - try { - Packets.ConnectionClosedPacket p = new Packets.ConnectionClosedPacket(); - p.connectionId = connection.getID(); - p.reason = reason; - host.sendTCP(p); - } catch (Exception e) { - Log.err("Error while sending ConnectionClosedPacket to host: " + e.getMessage()); - } - } - - clients.remove(connection.getID()); - } - - /** Doesn't notify the room host about a disconnected client */ - public void disconnectedQuietly(Connection connection, DcReason reason) { - if (isClosed) - return; - - if (connection == host) { - Log.info("Host disconnected quietly, closing room: " + id); - close(); - } else { - clients.remove(connection.getID()); - } - } - - /** - * Wrap and re-send the packet to the host, if it come from a connection, - * else un-wrap and re-send the packet to the specified connection.
- * Only {@link Packets.ConnectionPacketWrapPacket} and {@link ByteBuffer} - * are allowed. - */ - @Override - public void received(Connection connection, Object object) { - if (isClosed) - return; - - if (connection == host) { - // Only claj packets are allowed in the host's connection - // and can only be ConnectionPacketWrapPacket at this point. - if (!(object instanceof Packets.ConnectionPacketWrapPacket)) - return; - - int connectionId = ((Packets.ConnectionPacketWrapPacket) object).connectionId; - Connection con = clients.get(connectionId); - - if (con != null && con.isConnected()) { - boolean tcp = ((Packets.ConnectionPacketWrapPacket) object).isTCP; - Object o = ((Packets.ConnectionPacketWrapPacket) object).buffer; - - if (tcp) - con.sendTCP(o); - else - con.sendUDP(o); - - // Notify that this connection doesn't exist, this case normally never happen - } else if (host.isConnected()) { - Packets.ConnectionClosedPacket p = new Packets.ConnectionClosedPacket(); - p.connectionId = connectionId; - p.reason = DcReason.error; - host.sendTCP(p); - } - - } else if (host.isConnected() && clients.containsKey(connection.getID())) { - // Only raw buffers are allowed here. - // We never send claj packets to anyone other than the room host, framework - // packets are ignored - // and mindustry packets are saved as raw buffer. - if (!(object instanceof ByteBuffer)) - return; - - Packets.ConnectionPacketWrapPacket p = new Packets.ConnectionPacketWrapPacket(); - p.connectionId = connection.getID(); - p.buffer = (ByteBuffer) object; - host.sendTCP(p); - - } - } - - /** Notify the host of an idle connection. */ - @Override - public void idle(Connection connection) { - if (isClosed) - return; - - if (connection == host) { - // Ignore if this is the room host - - } else if (host.isConnected() && clients.containsKey(connection.getID())) { - Packets.ConnectionIdlingPacket p = new Packets.ConnectionIdlingPacket(); - p.connectionId = connection.getID(); - host.sendTCP(p); - } - } - - /** Notify the room id to the host. Must be called once. */ - public void create() { - if (isClosed) - return; - - // Assume the host is still connected - Packets.RoomLinkPacket p = new Packets.RoomLinkPacket(); - p.roomId = id; - host.sendTCP(p); - } - - /** @return whether the room is isClosed or not */ - public boolean isClosed() { - return isClosed; - } - - public void close() { - close(Packets.RoomClosedPacket.CloseReason.closed); - } - - /** - * Closes the room and disconnects the host and all clients. - * The room object cannot be used anymore after this. - */ - public void close(Packets.RoomClosedPacket.CloseReason reason) { - if (isClosed) - return; - isClosed = true; // close before kicking connections, to avoid receiving events - try { - // Alert the close reason to the host - Packets.RoomClosedPacket p = new Packets.RoomClosedPacket(); - p.reason = reason; - host.sendTCP(p); - - host.close(DcReason.closed); - clients.values().forEach(c -> c.close(DcReason.closed)); - clients.clear(); - } catch (Exception e) { - Log.err("Error while closing room @: @", id, e); - } - - Log.info("Room @ closed, reason @", id, reason); - } - - /** Checks if the connection is the room host or one of his client */ - public boolean contains(Connection con) { - if (isClosed || con == null) - return false; - if (con == host) - return true; - return clients.containsKey(con.getID()); - } - - /** Send a message to the host and clients. */ - public void message(String text) { - if (isClosed) - return; - - // Just send to host, it will re-send it properly to all clients - Packets.MessagePacket p = new Packets.MessagePacket(); - p.message = text; - host.sendTCP(p); - } - - /** Send a message the host and clients. Will be translated by the room host. */ - public void message(Packets.Message2Packet.MessageType message) { - if (isClosed) - return; - - Packets.Message2Packet p = new Packets.Message2Packet(); - p.message = message; - host.sendTCP(p); - } - - /** Send a popup to the room host. */ - public void popup(String text) { - if (isClosed) - return; - - Packets.PopupPacket p = new Packets.PopupPacket(); - p.message = text; - host.sendTCP(p); - } -} diff --git a/src/playerconnect/Utils.java b/src/playerconnect/Utils.java deleted file mode 100644 index 864468a..0000000 --- a/src/playerconnect/Utils.java +++ /dev/null @@ -1,15 +0,0 @@ -package playerconnect; - -import arc.net.Connection; - -public class Utils { - - public static String getIP(Connection con) { - java.net.InetSocketAddress a = con.getRemoteAddressTCP(); - return a == null ? null : a.getAddress().getHostAddress(); - } - - public static String toString(Connection con) { - return "0x" + Integer.toHexString(con.getID()); - } -} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..c4e20d7 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,158 @@ +use crate::connection::ConnectionActor; +use crate::packet::{AnyPacket, FrameworkMessage}; +use crate::rate::AtomicRateLimiter; +use crate::state::{AppState, ConnectionAction}; +use crate::writer::{TcpWriter, UdpWriter}; +use bytes::BytesMut; +use std::io::Cursor; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::net::{TcpListener, UdpSocket}; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +use crate::packet::ConnectionId; +const UDP_BUFFER_SIZE: usize = 4096; +const CHANNEL_CAPACITY: usize = 1024; +const PACKET_RATE_LIMIT_WINDOW: Duration = Duration::from_millis(1000); +const PACKET_RATE_LIMIT: u32 = 50000; + +pub async fn run(state: Arc, port: u16) -> anyhow::Result<()> { + let address = format!("0.0.0.0:{}", port); + let tcp_listener = TcpListener::bind(&address).await?; + let udp_socket = Arc::new(UdpSocket::bind(&address).await?); + + info!("Proxy Server listening on TCP/UDP {}", port); + + for _ in 0..4 { + spawn_udp_listener(state.clone(), udp_socket.clone()); + } + + accept_tcp_connection(state, tcp_listener, udp_socket).await +} + +fn spawn_udp_listener(state: Arc, socket: Arc) { + tokio::spawn(async move { + let mut buf = BytesMut::with_capacity(UDP_BUFFER_SIZE); + + loop { + if buf.len() > 0 { + buf.clear(); + } + + if buf.capacity() < UDP_BUFFER_SIZE { + buf.reserve(UDP_BUFFER_SIZE); + } + + match socket.recv_buf_from(&mut buf).await { + Ok((_len, addr)) => { + if let Some((_, limiter)) = state.get_route(&addr) { + if !limiter.check() { + continue; + } + } + + let bytes = buf.split().freeze(); + let mut cursor = Cursor::new(bytes); + + match AnyPacket::read(&mut cursor) { + Ok(packet) => { + match packet { + AnyPacket::Framework(FrameworkMessage::RegisterUDP { + connection_id, + }) => { + if let Some(sender) = state.get_sender(connection_id) { + if let Err(e) = + sender.try_send(ConnectionAction::RegisterUDP(addr)) + { + info!( + "Failed to register UDP for connection {}: {}", + connection_id, e + ); + } + } + } + _ => { + let Some((sender, _)) = state.get_route(&addr) else { + warn!("Unknown UDP sender: {}", addr); + continue; + }; + + if let Err(e) = sender + .try_send(ConnectionAction::ProcessPacket(packet, false)) + { + warn!("Failed to forward UDP packet: {}", e); + } + } + }; + } + Err(e) => { + warn!("UDP Parse Error from {}: {}", addr, e); + } + } + } + Err(e) => error!("UDP Receive Error: {}", e), + } + } + }); +} + +async fn accept_tcp_connection( + state: Arc, + listener: TcpListener, + udp_socket: Arc, +) -> anyhow::Result<()> { + loop { + let (socket, _) = listener.accept().await?; + + let state = state.clone(); + let udp_socket = udp_socket.clone(); + + tokio::spawn(async move { + if let Err(e) = socket.set_nodelay(true) { + warn!("Failed to set nodelay for connection: {}", e); + } + let id = loop { + let id = ConnectionId(rand::random()); + if !state.has_connection_id(id) { + break id; + } + }; + + let (tx, rx) = mpsc::channel(CHANNEL_CAPACITY); + let limiter = Arc::new(AtomicRateLimiter::new( + PACKET_RATE_LIMIT, + PACKET_RATE_LIMIT_WINDOW, + )); + + state.register_connection(id, tx, limiter.clone()); + + let (reader, writer) = socket.into_split(); + + let mut actor = ConnectionActor { + id, + state: state.clone(), + rx, + tcp_writer: TcpWriter::new(writer), + udp_writer: UdpWriter::new(udp_socket), + limiter, + last_read: Instant::now(), + packet_queue: Vec::new(), + room: None, + }; + + if let Err(e) = actor.run(reader).await { + if actor.udp_writer.addr.is_some() { + info!("Connection {} closed: {}", id, e); + } + } + + state.remove_connection(id, actor.room.as_ref().map(|r| r.room_id().clone())); + + if let Some(addr) = actor.udp_writer.addr { + state.remove_udp(addr); + info!("UDP connection {} closed", addr); + } + }); + } +} diff --git a/src/rate.rs b/src/rate.rs new file mode 100644 index 0000000..35a3e75 --- /dev/null +++ b/src/rate.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug)] +pub struct AtomicRateLimiter { + rate: u32, + window_start: AtomicU64, + count: AtomicU32, + window_duration_ms: u64, +} + +impl AtomicRateLimiter { + pub fn new(rate: u32, window: Duration) -> Self { + Self { + rate, + window_start: AtomicU64::new(current_millis()), + count: AtomicU32::new(0), + window_duration_ms: window.as_millis() as u64, + } + } + + pub fn check(&self) -> bool { + let now = current_millis(); + + loop { + let start = self.window_start.load(Ordering::Relaxed); + + if now >= start + self.window_duration_ms { + if self + .window_start + .compare_exchange(start, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.count.store(1, Ordering::Relaxed); + return true; + } + continue; + } + + let c = self.count.fetch_add(1, Ordering::Relaxed); + return c < self.rate; + } + } +} + +fn current_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() as u64 +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..322500c --- /dev/null +++ b/src/state.rs @@ -0,0 +1,435 @@ +use crate::config::Config; +use crate::constant::{ConnectionCloseReason, RoomCloseReason}; +use crate::error::AppError; +use crate::models::{RoomUpdateEvent, RoomView, Stats}; +use crate::packet::{ + AnyPacket, AppPacket, ConnectionClosedPacket, ConnectionId, ConnectionIdlingPacket, + ConnectionJoinPacket, RoomClosedPacket, RoomId, StatsPacket, +}; +use crate::rate::AtomicRateLimiter; +use crate::utils::current_time_millis; +use bytes::Bytes; +use dashmap::DashMap; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub enum ConnectionAction { + SendTCP(AnyPacket), + SendTCPRaw(Bytes), + SendUDPRaw(Bytes), + Close(ConnectionCloseReason), + RegisterUDP(SocketAddr), + ProcessPacket(AnyPacket, bool), +} + +#[derive(Debug, Clone)] +pub struct Room { + pub id: RoomId, + pub host_connection_id: ConnectionId, + pub host_sender: mpsc::Sender, + pub password: Option, + pub created_at: u128, + pub updated_at: u128, + pub members: HashMap>, + pub stats: Stats, + pub ping: u128, + pub protocol_version: String, +} + +impl Room { + pub fn is_host(&self, connection_id: ConnectionId) -> bool { + self.host_connection_id == connection_id + } +} + +#[derive(Clone, Debug)] +pub enum RoomUpdate { + Update { id: RoomId, data: Room }, + Remove(RoomId), +} + +pub struct RoomState { + rooms: DashMap, + pub broadcast_sender: tokio::sync::broadcast::Sender, + // Keep a receiver to prevent the channel from closing when all clients disconnect + pub _broadcast_receiver: tokio::sync::broadcast::Receiver, +} + +pub struct RoomInit { + pub connection_id: ConnectionId, + pub password: String, + pub stats: Stats, + pub sender: mpsc::Sender, + pub protocol_version: String, +} + +impl RoomState { + pub fn get_sender( + &self, + room_id: &RoomId, + connection_id: ConnectionId, + ) -> Option> { + self.rooms + .get(room_id)? + .members + .get(&connection_id) + .cloned() + } + + pub fn into_views(&self) -> Vec { + self.rooms + .iter() + .map(|entry| RoomUpdateEvent { + room_id: entry.key().0.clone(), + data: RoomView::from(entry.value()), + }) + .collect() + } + + pub fn join( + &self, + connection_id: ConnectionId, + room_id: &RoomId, + sender: mpsc::Sender, + ) -> Result<(), AppError> { + if let Some(mut room) = self.rooms.get_mut(room_id) { + room.members.insert(connection_id, sender); + + let packet = AnyPacket::App(AppPacket::ConnectionJoin(ConnectionJoinPacket { + connection_id, + room_id: room_id.clone(), + })); + + if let Err(e) = room.host_sender.try_send(ConnectionAction::SendTCP(packet)) { + info!( + "Failed to forward to host {}: {}", + room.host_connection_id, e + ); + } + } else { + return Err(AppError::RoomNotFound(room_id.to_string())); + } + + Ok(()) + } + + pub fn leave(&self, connection_id: ConnectionId, room_id: &RoomId) { + if let Some(mut room) = self.rooms.get_mut(room_id) { + room.members.remove(&connection_id); + + let packet = AnyPacket::App(AppPacket::ConnectionClosed(ConnectionClosedPacket { + connection_id, + reason: ConnectionCloseReason::Closed, + })); + + if let Err(e) = room.host_sender.try_send(ConnectionAction::SendTCP(packet)) { + info!( + "Failed to forward to host {}: {}", + room.host_connection_id, e + ); + } + } + } + + pub fn update_state(&self, room_id: &RoomId, p: StatsPacket) { + if let Some(mut r) = self.rooms.get_mut(&room_id) { + let sent_at = p.data.created_at; + + r.stats = p.data; + r.updated_at = current_time_millis(); + r.ping = current_time_millis() - sent_at; + + if let Err(err) = self.broadcast_sender.send(RoomUpdate::Update { + id: r.id.clone(), + data: r.clone(), + }) { + warn!("Fail to broadcast room update {}", err); + } + } else { + warn!("Room not found {}", room_id); + } + } + + pub fn can_join(&self, room_id: &RoomId, password: &str) -> (bool, bool) { + if let Some(room) = self.rooms.get(room_id) { + if let Some(pwd) = room.password.as_deref() { + (true, pwd == password) + } else { + (true, true) + } + } else { + (false, false) + } + } + + pub fn create(&self, init: RoomInit) -> RoomId { + let RoomInit { + password, + connection_id, + stats, + sender, + protocol_version, + } = init; + + let password = if password.is_empty() { + None + } else { + Some(password) + }; + + let room_id = RoomId(Uuid::now_v7().to_string()); + let members = HashMap::new(); + + let room = Room { + id: room_id.clone(), + host_connection_id: connection_id, + host_sender: sender, + password, + stats, + members, + created_at: current_time_millis(), + updated_at: current_time_millis(), + protocol_version, + ping: 0, + }; + + self.rooms.insert(room_id.clone(), room.clone()); + + if let Err(err) = self.broadcast_sender.send(RoomUpdate::Update { + id: room_id.clone(), + data: room, + }) { + warn!("Fail to broadcast room update {}", err); + } + + room_id + } + + pub fn close(&self, room_id: &RoomId, reason: RoomCloseReason) { + let removed = self.rooms.remove(room_id); + + if let Some((_, room)) = removed { + info!("Room closed {}: {:?}", room_id, reason); + + if let Err(e) = room + .host_sender + .try_send(ConnectionAction::SendTCP(AnyPacket::App( + AppPacket::RoomClosed(RoomClosedPacket { reason }), + ))) + { + warn!( + "Failed to send room closed packet to host {}: {}", + room.host_connection_id, e + ); + } + + for (id, sender) in room.members { + if let Err(e) = + sender.try_send(ConnectionAction::Close(ConnectionCloseReason::Closed)) + { + warn!("Failed to send close action to {}: {}", id, e); + } + } + + if let Err(err) = self + .broadcast_sender + .send(RoomUpdate::Remove(room_id.clone())) + { + error!("Failed to send remove room event: {}", err); + }; + } + } + + pub fn broadcast( + &self, + room_id: &RoomId, + action: ConnectionAction, + exclude_id: Option, + ) { + if let Some(room) = self.rooms.get(room_id) { + for (id, sender) in &room.members { + if Some(*id) == exclude_id { + continue; + } + if let Err(e) = sender.try_send(action.clone()) { + warn!("Failed to broadcast to {}: {}", id, e); + } + } + } + } + + pub fn forward_to_host(&self, room_id: &RoomId, action: ConnectionAction) { + let room = match self.rooms.get(room_id) { + Some(room) => room, + None => { + warn!("Room {} not found for forwarding", room_id); + return; + } + }; + + if let Err(e) = room.host_sender.try_send(action) { + warn!( + "Failed to forward to host {}: {}", + room.host_connection_id, e + ); + } + } + + pub fn get_room_members( + &self, + room_id: &RoomId, + ) -> Vec<(ConnectionId, mpsc::Sender)> { + if let Some(room) = self.rooms.get(room_id) { + room.members.iter().map(|(k, v)| (*k, v.clone())).collect() + } else { + warn!("Room {} not found for getting members", room_id); + Vec::new() + } + } + + pub fn idle(&self, connection_id: ConnectionId, room_id: &RoomId) -> bool { + if let Some(room) = self.rooms.get(room_id) { + if room.is_host(connection_id) { + return true; + } + + let packet = AnyPacket::App(AppPacket::ConnectionIdling(ConnectionIdlingPacket { + connection_id, + })); + + match room.host_sender.try_send(ConnectionAction::SendTCP(packet)) { + Ok(_) => return true, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("Host channel full, retrying idle packet later"); + return false; + } + Err(e) => { + warn!( + "Failed to forward idle packet to host {}: {}", + room.host_connection_id, e + ); + return true; + } + } + } + true + } + + pub fn is_in_room(&self, connection_id: &ConnectionId, room_id: &RoomId) -> bool { + self.rooms + .get(&room_id) + .map(|r| r.members.contains_key(&connection_id)) + .unwrap_or(false) + } +} + +impl From<&Room> for RoomView { + fn from(room: &Room) -> Self { + Self { + name: room.stats.name.clone(), + status: "UP".to_string(), + is_private: false, + is_secured: room.password.is_some(), + players: room.stats.players.clone(), + map_name: room.stats.map_name.clone(), + gamemode: room.stats.gamemode.clone(), + mods: room.stats.mods.clone(), + locale: room.stats.locale.clone(), + version: room.stats.version.clone(), + created_at: room.stats.created_at, + ping: room.ping, + protocol_version: room.protocol_version.clone(), + } + } +} + +pub struct AppState { + pub config: Config, + pub room_state: RoomState, + connections: DashMap, Arc)>, + udp_routes: DashMap, Arc)>, +} + +impl AppState { + pub fn new(config: Config) -> Self { + let (tx, rx) = tokio::sync::broadcast::channel(1024); + Self { + config, + room_state: RoomState { + rooms: DashMap::new(), + broadcast_sender: tx, + _broadcast_receiver: rx, + }, + connections: DashMap::new(), + udp_routes: DashMap::new(), + } + } + + pub fn register_connection( + &self, + id: ConnectionId, + sender: mpsc::Sender, + limiter: Arc, + ) { + self.connections.insert(id, (sender, limiter)); + } + + pub fn has_connection_id(&self, id: ConnectionId) -> bool { + self.connections.contains_key(&id) + } + + pub fn register_udp( + &self, + addr: SocketAddr, + sender: mpsc::Sender, + limiter: Arc, + ) { + self.udp_routes.insert(addr, (sender, limiter)); + } + + pub fn remove_udp(&self, addr: SocketAddr) { + self.udp_routes.remove(&addr); + } + + pub fn get_sender(&self, id: ConnectionId) -> Option> { + self.connections.get(&id).map(|val| val.0.clone()) + } + + pub fn get_route( + &self, + addr: &SocketAddr, + ) -> Option<(mpsc::Sender, Arc)> { + self.udp_routes.get(addr).map(|val| val.clone()) + } + + pub fn idle(&self, connection_id: ConnectionId, room_id: &RoomId) -> bool { + self.room_state.idle(connection_id, room_id) + } + + pub fn remove_connection(&self, connection_id: ConnectionId, room_id: Option) { + self.connections.remove(&connection_id); + + // Handle room logic + if let Some(room_id) = room_id { + self.room_state.leave(connection_id, &room_id); + + // Check if host + let should_close = { + if let Some(room) = self.room_state.rooms.get(&room_id) { + room.is_host(connection_id) + } else { + false + } + }; + + if should_close { + self.room_state.close(&room_id, RoomCloseReason::Closed); + } + } + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..23b2de9 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,8 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn current_time_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() +} diff --git a/src/writer.rs b/src/writer.rs new file mode 100644 index 0000000..d405069 --- /dev/null +++ b/src/writer.rs @@ -0,0 +1,56 @@ +use anyhow::anyhow; +use std::io::IoSlice; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::AsyncWriteExt; +use tokio::net::UdpSocket; + +pub struct TcpWriter { + writer: tokio::net::tcp::OwnedWriteHalf, + pub last_write: Instant, + pub idling: bool, +} + +impl TcpWriter { + pub fn new(writer: tokio::net::tcp::OwnedWriteHalf) -> Self { + Self { + writer, + last_write: Instant::now(), + idling: false, + } + } + + pub async fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> anyhow::Result<()> { + self.writer.write_vectored(bufs).await?; + self.last_write = Instant::now(); + self.idling = true; + + Ok(()) + } +} + +pub struct UdpWriter { + socket: Arc, + pub addr: Option, +} + +impl UdpWriter { + pub fn new(socket: Arc) -> Self { + Self { socket, addr: None } + } + + pub fn set_addr(&mut self, addr: SocketAddr) { + self.addr = Some(addr); + } + + pub async fn send_raw(&self, bytes: &[u8]) -> anyhow::Result<()> { + if let Some(addr) = self.addr { + self.socket.send_to(bytes, addr).await?; + + return Ok(()); + } + + return Err(anyhow!("UPD not registered")); + } +} diff --git a/structure.md b/structure.md new file mode 100644 index 0000000..867c5bc --- /dev/null +++ b/structure.md @@ -0,0 +1,170 @@ +# This is a complete rewrite of player connect protocol (a protocol base on claj) in Rust + +## Purpose + +- Improve the performance of the player connect server +- Make the player connect server more readable, extendable +- Easier to moderate + +## Structure + +### App + +Handle main loop and stuff + +### Http Server + +Handle all related http stuff + +### Proxy Server + +Handle proxy all game packet + +### Event Bus + +Boardcast event between all components in system + +## Data Model + +Room { + id + host_connection + password (optional) + ping: number + isClosed: boolean + createdAt: number + updatedAt: number + clients: list of Connection + stats: Stats +} + +Stats { + players: list of Player + mapName: string + name: string + gamemode:string + mods: string[] + locale: string + version: string + createdAt: number +} + +Player { + name: string + locale: string +} + +ConnectionWrapperPacket { + connectionId: int +} + +ConnectionPacketWrapPacket (extends ConnectionWrapperPacket) +ConnectionPacketWrapPacket { + object: Object // non-primitive (generic object payload) + buffer: ByteBuffer // non-primitive (java.nio buffer) + isTCP: boolean +} + +ConnectionClosedPacket (extends ConnectionWrapperPacket) +ConnectionClosedPacket { + reason: DcReason // non-primitive (enum) +} + +ConnectionJoinPacket (extends ConnectionWrapperPacket) +ConnectionJoinPacket { + roomId: String // non-primitive +} + +ConnectionIdlingPacket (extends ConnectionWrapperPacket) +ConnectionIdlingPacket { + // no additional fields +} + +RoomLinkPacket (extends Packet) +RoomLinkPacket { + roomId: String // non-primitive +} + +RoomJoinPacket (extends RoomLinkPacket) +RoomJoinPacket { + password: String // non-primitive +} + +RoomClosureRequestPacket (extends Packet) +RoomClosureRequestPacket { + // no fields +} + +RoomClosedPacket (extends Packet) +RoomClosedPacket { + reason: CloseReason // non-primitive (enum) +} + +RoomCreationRequestPacket (extends Packet) +RoomCreationRequestPacket { + version: String // non-primitive + password: String // non-primitive + data: RoomStats // non-primitive (object) +} + +MessagePacket (extends Packet) +MessagePacket { + message: String // non-primitive +} + +PopupPacket (extends MessagePacket) +PopupPacket { + // inherits message +} + +Message2Packet (extends Packet) +Message2Packet { + message: MessageType // non-primitive (enum) +} + +StatsPacket (extends Packet) +StatsPacket { + roomId: String // non-primitive + data: Stats // non-primitive (object) +} + +Enums +enum MessageType { + serverClosing + packetSpamming + alreadyHosting + roomClosureDenied + conClosureDenied +} + +enum CloseReason { + closed + obsoleteClient + outdatedVersion + serverClosed +} + +## Flow + +### App + +- Read env PLAYER_CONNECT_PORT, PLAYER_CONNECT_HTTP_PORT from env, validate +- Do initiation, logging, create HTTP server, bind to PLAYER_CONNECT_HTTP_PORT, create proxy server +- Start main loop + +### Http Server + +- Setup server +- Create routes: ++ GET /api/v1/rooms is a server sent event route that: + - On connected: send all rooms to client as and "update" server sent event + - Listen to any room changes broadcast by proxy server and send as "update" server sent event + - Listen to room deletion by proxy server and send an "remove" server sent event + ++ GET /api/v1/ping: return 200 OK ++ GET /{roomId}: + - Return a html page that show room info with metadata and opengraph ++ POST /{roomId}: + - Return PLAYER_CONNECT_PORT + +### Proxy Server