diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7b2a5456..2202e5dc 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -40,17 +40,17 @@ jobs: artifact: playit-linux-i686 dpkg_arch: i386 - # - name: linux mipsel - # os: ubuntu-latest - # arch: mipsel-unknown-linux-musl - # artifact: playit-linux-mipsel - # dpkg_arch: mipsel - # - # - name: linux mips - # os: ubuntu-latest - # arch: mips-unknown-linux-musl - # artifact: playit-linux-mips - # dpkg_arch: mips + # - name: linux mipsel + # os: ubuntu-latest + # arch: mipsel-unknown-linux-musl + # artifact: playit-linux-mipsel + # dpkg_arch: mipsel + # + # - name: linux mips + # os: ubuntu-latest + # arch: mips-unknown-linux-musl + # artifact: playit-linux-mips + # dpkg_arch: mips runs-on: ${{ matrix.platform.os }} steps: @@ -81,7 +81,7 @@ jobs: - name: Package .deb shell: bash - run: 'bash ./build-scripts/package-linux-deb.sh "./target/${{ matrix.platform.arch }}/release/playit-cli" ${{ matrix.platform.dpkg_arch }}' + run: 'bash ./build-scripts/package-linux-deb.sh "./target/${{ matrix.platform.arch }}/release/playit-cli" "./target/${{ matrix.platform.arch }}/release/playitd" ${{ matrix.platform.dpkg_arch }}' - name: Upload .deb uses: actions/upload-release-asset@v1 @@ -92,111 +92,6 @@ jobs: asset_path: ./target/deb/playit_${{ matrix.platform.dpkg_arch }}.deb asset_name: playit_${{ matrix.platform.dpkg_arch }}.deb asset_content_type: application/octet-stream - - - # build_macos: - # environment: production - - # strategy: - # fail-fast: false - # matrix: - # platform: - # - name: macos intel - # os: macos-latest - # arch: x86_64-apple-darwin - # artifact: playit-darwin-intel - - # - name: macos arm - # os: macos-latest - # arch: aarch64-apple-darwin - # artifact: playit-darwin-arm - - # runs-on: ${{ matrix.platform.os }} - # steps: - # - uses: actions/checkout@v3 - - # - name: Get release - # id: get_release - # uses: bruceadams/get-release@v1.2.3 - # env: - # GITHUB_TOKEN: ${{ github.token }} - - # - uses: dtolnay/rust-toolchain@stable - # with: - # targets: ${{ matrix.platform.arch }} - - # - name: build release - # uses: actions-rs/cargo@v1 - # with: - # command: build - # args: --target ${{ matrix.platform.arch }} --release --all - - # # reference: https://federicoterzi.com/blog/automatic-code-signing-and-notarization-for-macos-apps-using-github-actions/ - # - name: Codesign binary - # # Extract the secrets we defined earlier as environment variables - # env: - # MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} - # MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} - # MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} - # MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} - # run: | - # # Turn our base64-encoded certificate back to a regular .p12 file - - # echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 - - # # We need to create a new keychain, otherwise using the certificate will prompt - # # with a UI dialog asking for the certificate password, which we can't - # # use in a headless CI environment - - # security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain - # security default-keychain -s build.keychain - # security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain - # security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign - # security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain - - # /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime target/${{ matrix.platform.arch }}/release/playit-cli - - # - name: Notorize binary - # env: - # PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} - # PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} - # PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} - # run: | - # echo "Create keychain profile" - # xcrun notarytool store-credentials "notarytool-profile" --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" --password "$PROD_MACOS_NOTARIZATION_PWD" - - # # We can't notarize an app bundle directly, but we need to compress it as an archive. - # # Therefore, we create a zip file containing our app bundle, so that we can send it to the - # # notarization service - - # echo "Creating temp notarization archive" - # ditto -c -k --keepParent "target/${{ matrix.platform.arch }}/release/playit-cli" "notarization.zip" - - # # Here we send the notarization request to the Apple's Notarization service, waiting for the result. - # # This typically takes a few seconds inside a CI environment, but it might take more depending on the App - # # characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if - # # you're curious - - # echo "Notarize app" - # xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" - - # # Finally, we need to "attach the staple" to our executable, which will allow our app to be - # # validated by macOS even when an internet connection is not available. - # # REMOVED: don't wait for notarization, takes too long - # # echo "Attach staple" - # # xcrun stapler staple "target/${{ matrix.platform.arch }}/release/playit-cli" - - # - name: Upload Binary - # if: matrix.platform.os != 'windows-latest' - # uses: actions/upload-release-asset@v1 - # env: - # GITHUB_TOKEN: ${{ github.token }} - # with: - # upload_url: ${{ steps.get_release.outputs.upload_url }} - # asset_path: ./target/${{ matrix.platform.arch }}/release/playit-cli - # asset_name: ${{ matrix.platform.artifact }} - # asset_content_type: application/octet-stream - build_windows: strategy: fail-fast: false @@ -232,6 +127,7 @@ jobs: targets: ${{ matrix.platform.arch }} - run: cargo install cargo-wix + - run: cargo build --target ${{ matrix.platform.arch }} --release --package playit-cli --package playitd --package playitd-tray --package playitd-windows-setup - run: cargo wix --target ${{ matrix.platform.arch }} --package playit-cli --nocapture --output=target/wix/${{ matrix.platform.artifact }}.msi - name: Upload .exe @@ -240,7 +136,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} with: upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./target/${{ matrix.platform.arch }}/release/playit-cli.exe + asset_path: ./target/${{ matrix.platform.arch }}/release/playitd.exe asset_name: ${{ matrix.platform.artifact }}.exe asset_content_type: application/octet-stream @@ -256,7 +152,7 @@ jobs: build_docker: runs-on: ubuntu-latest - needs: [ build_linux ] + needs: [build_linux] permissions: contents: read packages: write @@ -274,7 +170,7 @@ jobs: if: github.event_name != 'pull_request' uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 with: - cosign-release: 'v2.2.4' + cosign-release: "v2.2.4" # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache diff --git a/Cargo.lock b/Cargo.lock index e59a3d1d..b16a1691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -55,9 +55,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -82,6 +82,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-compression" version = "0.4.34" @@ -95,6 +101,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -113,11 +142,20 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -128,24 +166,70 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -171,6 +255,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -199,9 +293,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -209,9 +303,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -221,21 +315,30 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] [[package]] name = "colorchoice" @@ -360,7 +463,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "mio", "parking_lot", @@ -409,7 +512,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -420,7 +523,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -457,13 +560,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -474,10 +597,20 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -486,21 +619,53 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "dotenv" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d" +dependencies = [ + "codepage", + "encoding_rs", + "windows-sys 0.52.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -517,6 +682,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -569,6 +753,23 @@ 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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.31" @@ -577,7 +778,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -613,6 +814,64 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -650,6 +909,96 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "governor" version = "0.10.2" @@ -673,6 +1022,58 @@ dependencies = [ "web-time", ] +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -701,6 +1102,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -722,6 +1129,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -955,6 +1371,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -984,7 +1413,22 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", ] [[package]] @@ -1034,26 +1478,111 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kanal" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3953adf0cd667798b396c2fa13552d6d9b3269d7dd1154c4c416442d1ff574" +dependencies = [ + "futures-core", + "lock_api", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libredox" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags", + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ "libc", + "x11", +] + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", ] [[package]] @@ -1113,6 +1642,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "message-encoding" version = "0.2.7" @@ -1144,6 +1682,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -1174,6 +1743,66 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1192,6 +1821,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1239,9 +1893,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "playit-agent-core" -version = "0.17.1" +version = "1.0.0" dependencies = [ "byteorder", "chrono", @@ -1256,6 +1916,7 @@ dependencies = [ "serde", "serde_json", "slab", + "slotmap", "tokio", "tokio-util", "toml 0.9.8", @@ -1294,30 +1955,140 @@ dependencies = [ ] [[package]] -name = "playit-cli" -version = "0.17.1" +name = "playit-cli" +version = "1.0.0" +dependencies = [ + "chrono", + "clap", + "crossterm", + "dirs 6.0.0", + "dotenv", + "hex", + "playit-agent-core", + "playit-agent-proto", + "playit-api-client", + "playit-ipc", + "playitd", + "rand", + "ratatui", + "serde", + "serde_json", + "serde_yml", + "tokio", + "tokio-util", + "toml 0.9.8", + "tracing", + "tracing-appender", + "tracing-subscriber", + "urlencoding", + "uuid", + "whoami", + "winres", +] + +[[package]] +name = "playit-ipc" +version = "1.0.0" +dependencies = [ + "dirs 6.0.0", + "futures-util", + "interprocess", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "whoami", +] + +[[package]] +name = "playitd" +version = "1.0.0" +dependencies = [ + "clap", + "dirs 6.0.0", + "dotenv", + "hex", + "interprocess", + "libc", + "playit-agent-core", + "playit-api-client", + "playit-ipc", + "serde", + "serde_json", + "serde_yml", + "service-manager", + "tokio", + "tokio-util", + "toml 0.9.8", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uzers", + "widestring", + "windows-service", + "windows-sys 0.59.0", +] + +[[package]] +name = "playitd-tray" +version = "1.0.0" +dependencies = [ + "image", + "kanal", + "playit-ipc", + "playitd", + "tokio", + "tray-icon", + "windows", + "windows-sys 0.59.0", +] + +[[package]] +name = "playitd-windows-setup" +version = "1.0.0" +dependencies = [ + "playitd", + "windows", +] + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "clap", - "crossterm", - "dirs", - "dotenv", - "hex", - "playit-agent-core", - "playit-agent-proto", - "playit-api-client", - "rand", - "ratatui", - "serde", - "serde_json", - "serde_yaml", - "tokio", - "toml 0.9.8", - "tracing", - "tracing-appender", - "tracing-subscriber", - "urlencoding", - "uuid", - "winres", + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] @@ -1350,15 +2121,65 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quanta" version = "0.12.6" @@ -1374,6 +2195,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1431,9 +2261,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1479,7 +2309,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm", @@ -1500,16 +2330,33 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", ] [[package]] @@ -1601,13 +2448,22 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -1667,6 +2523,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1694,7 +2556,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1710,6 +2572,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -1732,16 +2603,34 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yml" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ "indexmap", "itoa", + "libyml", + "memchr", "ryu", "serde", - "unsafe-libyaml", + "version_check", +] + +[[package]] +name = "service-manager" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73b205a13c82cdd9fd05e22d5f4ff0269f656adf68732c4d4e4f11360975ebb" +dependencies = [ + "cfg-if", + "dirs 4.0.0", + "encoding-utils", + "encoding_rs", + "log", + "plist", + "which", + "xml-rs", ] [[package]] @@ -1812,6 +2701,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -1870,11 +2768,11 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -1885,9 +2783,19 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1911,9 +2819,28 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "thiserror" version = "1.0.69" @@ -1940,7 +2867,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1951,7 +2878,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2044,7 +2971,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2079,6 +3006,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "toml" version = "0.9.8" @@ -2087,11 +3026,20 @@ checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", ] [[package]] @@ -2103,13 +3051,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + [[package]] name = "toml_parser" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -2139,7 +3111,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -2194,7 +3166,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2236,6 +3208,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e1484378c343c5a9b291188fa58917c7184967683f8cfe4a05461986970553" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2283,12 +3276,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -2336,12 +3323,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uzers" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" +dependencies = [ + "libc", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -2372,6 +3374,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -2417,7 +3425,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2459,6 +3467,35 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2481,6 +3518,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2494,6 +3552,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2502,7 +3571,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2513,7 +3582,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2522,6 +3591,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2531,6 +3610,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac" +dependencies = [ + "bitflags 2.10.0", + "widestring", + "windows-sys 0.59.0", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -2609,6 +3699,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2705,6 +3804,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.13" @@ -2732,6 +3840,22 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.1" @@ -2751,7 +3875,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2772,7 +3896,7 @@ checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2792,7 +3916,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2832,5 +3956,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index cb86ab05..73be2641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ [workspace] members = [ - "packages/agent_cli", + "packages/playit-cli", + "packages/playit-ipc", + "packages/playitd", + "packages/playitd-tray", + "packages/playitd-windows-setup", "packages/agent_core", "packages/agent_proto", "packages/api_client", @@ -8,11 +12,11 @@ members = [ resolver = "3" [workspace.package] -version = "0.17.1" +version = "1.0.0" [workspace.dependencies] tokio = { version = "1.48", features = ["full"] } -tokio-util = "0.7" +tokio-util = { version = "0.7", features = ["codec"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/Dockerfile b/Dockerfile index f3e3fcc8..2ab90c44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,14 +6,17 @@ RUN apk --no-cache --update add build-base perl # Setup project structure with blank code so we can download libraries for better docker caching COPY Cargo.toml Cargo.lock ./ -RUN mkdir -p packages/agent_cli/src && mkdir -p packages/agent_core/src && mkdir -p packages/agent_proto/src && mkdir -p packages/ping_monitor/src && mkdir -p packages/api_client/src -COPY packages/agent_cli/Cargo.toml packages/agent_cli/Cargo.toml +RUN mkdir -p packages/playit-cli/src && mkdir -p packages/playit-ipc/src && mkdir -p packages/playitd/src && mkdir -p packages/playitd-tray/src && mkdir -p packages/playitd-windows-setup/src && mkdir -p packages/agent_core/src && mkdir -p packages/agent_proto/src && mkdir -p packages/api_client/src +COPY packages/playit-cli/Cargo.toml packages/playit-cli/Cargo.toml +COPY packages/playit-ipc/Cargo.toml packages/playit-ipc/Cargo.toml +COPY packages/playitd/Cargo.toml packages/playitd/Cargo.toml +COPY packages/playitd-tray/Cargo.toml packages/playitd-tray/Cargo.toml +COPY packages/playitd-windows-setup/Cargo.toml packages/playitd-windows-setup/Cargo.toml COPY packages/agent_core/Cargo.toml packages/agent_core/Cargo.toml COPY packages/agent_proto/Cargo.toml packages/agent_proto/Cargo.toml COPY packages/api_client/Cargo.toml packages/api_client/Cargo.toml -COPY packages/ping_monitor/Cargo.toml packages/ping_monitor/Cargo.toml -RUN touch packages/agent_cli/src/lib.rs && touch packages/agent_core/src/lib.rs && touch packages/agent_proto/src/lib.rs && touch packages/api_client/src/lib.rs && touch packages/ping_monitor/src/lib.rs +RUN touch packages/playit-cli/src/lib.rs && touch packages/playit-ipc/src/lib.rs && touch packages/playitd/src/lib.rs && touch packages/playitd-tray/src/main.rs && touch packages/playitd-windows-setup/src/main.rs && touch packages/agent_core/src/lib.rs && touch packages/agent_proto/src/lib.rs && touch packages/api_client/src/lib.rs RUN cargo fetch # Build dep packages @@ -23,24 +26,25 @@ RUN cargo build --release --package=playit-agent-proto COPY packages/api_client packages/api_client RUN cargo build --release --package=playit-api-client -COPY packages/ping_monitor packages/ping_monitor -RUN cargo build --release --package=playit-ping-monitor - COPY packages/agent_core packages/agent_core RUN cargo build --release --package=playit-agent-core -# Build CLI -COPY packages/agent_cli packages/agent_cli -RUN cargo build --release --all +# Build daemon +COPY packages/playit-ipc packages/playit-ipc +COPY packages/playitd packages/playitd +COPY packages/playitd-tray packages/playitd-tray +COPY packages/playitd-windows-setup packages/playitd-windows-setup +COPY packages/playit-cli packages/playit-cli +RUN cargo build --release --package playitd --bin playitd ########## RUNTIME CONTAINER ########## FROM alpine:3.18 RUN apk add --no-cache ca-certificates -COPY --from=build-env /src/playit-agent/target/release/playit-cli /usr/local/bin/playit +COPY --from=build-env /src/playit-agent/target/release/playitd /usr/local/bin/playitd RUN mkdir /playit COPY docker/entrypoint.sh /playit/entrypoint.sh RUN chmod +x /playit/entrypoint.sh -ENTRYPOINT ["/playit/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/playit/entrypoint.sh"] diff --git a/README.md b/README.md index 42fe4017..dd2a31e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # The playit program -* Latest Release: 0.16.X +* Latest Release: 1.0.X * Offical Website: https://playit.gg * Offical Downloads: https://playit.gg/download * Releases: https://github.com/playit-cloud/playit-agent/releases @@ -8,7 +8,7 @@ --- ** Non deprecated releases of the playit program: -`0.15.26` and `0.16.2` + `0.17.1` and `1.0.X` --- diff --git a/build-scripts/digicert.pem b/build-scripts/digicert.pem new file mode 100644 index 00000000..5075a744 --- /dev/null +++ b/build-scripts/digicert.pem @@ -0,0 +1,73 @@ +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIQBVZ4s97UM3mXY279ZngKWDANBgkqhkiG9w0BAQsFADBp +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMT +OERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMjU2 +IDIwMjEgQ0ExMB4XDTI2MDQyNTAwMDAwMFoXDTI3MDQyMjIzNTk1OVowgdQxEzAR +BgsrBgEEAYI3PAIBAxMCVVMxFzAVBgsrBgEEAYI3PAIBAhMGTmV2YWRhMR0wGwYD +VQQPDBRQcml2YXRlIE9yZ2FuaXphdGlvbjEWMBQGA1UEBRMNRTQ5NTI4MzIwMjAt +NDELMAkGA1UEBhMCVVMxDzANBgNVBAgTBk9yZWdvbjEPMA0GA1UEBxMGRXVnZW5l +MR4wHAYDVQQKExVEZXZlbG9wZWQgTWV0aG9kcyBMTEMxHjAcBgNVBAMTFURldmVs +b3BlZCBNZXRob2RzIExMQzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBzaNFYL +dWfq6vLFd8pSaojbIj1DtD8QY2yTVME6nR8nkdyl2jMcF6LFfCWZyDRMaG6Ky9WX +rHjH0ExQ+HxpYS6jggICMIIB/jAfBgNVHSMEGDAWgBS8ayJlnYxo5uYeBfHYciXU +uOBIwDAdBgNVHQ4EFgQUQNizsSS7c3XSX2ZMaUTWEOsbULIwPQYDVR0gBDYwNDAy +BgVngQwBAzApMCcGCCsGAQUFBwIBFhtodHRwOi8vd3d3LmRpZ2ljZXJ0LmNvbS9D +UFMwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMIG1BgNVHR8E +ga0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRy +dXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTI1NjIwMjFDQTEuY3JsMFOgUaBP +hk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2Rl +U2lnbmluZ1JTQTQwOTZTSEEyNTYyMDIxQ0ExLmNybDCBlAYIKwYBBQUHAQEEgYcw +gYQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBcBggrBgEF +BQcwAoZQaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3Rl +ZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMjU2MjAyMUNBMS5jcnQwCQYDVR0TBAIw +ADANBgkqhkiG9w0BAQsFAAOCAgEAgB57e2sUsZnBUjyd3hKajtul699cjpZKnw5S +DYGjE1rMVGMx7UijcEtp9Y+SGdPqMjP5Kgh5d42o+tNzWhBL1Py+hDlbPuFE7kZT +JBXZQHX+aAp+1OsYBx6txQ+GPI+rN9HFYbGlk3Tnjn8kdhKsdZiZ3+U/xPksxjTj +BX0RCCY6Lj34bE86fpz7GCmgkoqhRkHAN9T9wbhbhS9Asfzql/dSS5c0uRH6Z7LO +JuWiA5BslIfIY6EUoiaPh5sG9UR4go6WvGrL/cPXSZLxGdkVki/EQnevL8Yl9WOn +vGC3V9aqJxgisRxI8sr9kZAhRE+RC6lF5M39MVAj/xwsbn4sGbKZm2sRHBI6w+hu +aZaL4hsbqz2rI9oDuDYN7voD2qv8DZ0XP58LsZJL9PcK8imIR0Tz42JVHc+XcLIc +GryaiUhpQHcMwm4H8vmuEx2zSC655JYU4UQZP8mPx9GWYO3MIRvHaIQa1ThxZ4Jp +oyfV0lbM9N7yVuZsAcIAzGBXQc74EcWS4b4uC/MQWlHiRjg8KwcaxJajbIwD6UO/ +d3Oq5NHST7D1H1mUMWjdi0mbb9472impwWQjcXcaH2ME9o4csYo5eZjapVJZ06do +xixsjEUyugCvGkL0mNTXD4NaNMQuPZAbW+BkpQEuxPOSOap2OaEukA84lIpaT6ce +l2cveg0= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGsDCCBJigAwIBAgIQDk1n9kMWySo7ehfMRpdqjzANBgkqhkiG9w0BAQsFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMjEwNzI4MDAwMDAwWhcNMzYwNzI3MjM1OTU5WjBpMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRy +dXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMjU2IDIwMjEgQ0ExMIIC +IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri4MfdPvaOOECPqbDE3zNg/n +sfM0MoNGVGJf6ogVscpox00cZPOiAXZwGLyPm/HmSxmNCDxKUqFWE1LIVTzCYcNt +kqCv+MNCZoHxIY1mERdsU+sYYfADDDHpi10op0br0SrrrEuf6+uuXRxhpZ7xgopt +T/WhDG3vqXjabJPsKCDcEsnElut6Nm8XHHV+CcnZpbwle4OCcnrgLerBEHu+iIEj +gCts01n+1b1abmrFXzJ3aC/iXP/6HIqYs7sdAUU8C7WcLflXm3xzec6yltqyKEyx +cvgdBMTapqmw7pkFrm8l56JYpctFHJtdyFsroL6lojC8yS3j5z05+SgaAjSMI8Qf +al5WAy2Jii/EvnPZf1azgUvzYkfKgYEy5kA5c+3j9ZuKifb7EPp4pvkXHZJ6KzJh +abuRgXqjObO9YtRk5LnZgvwHJ74T/vFTYO47h2wOqv/JaZd8K5/IAiSop/JTjlHB +stkJfnAb6iH1UUKKgMhpNCpSkM9s2xzQQmZ44RGIRDs+AHm5lER4Z2OohQ+nMP/g +UjvnouZ2XZuI+yn2EBbtRDkBr9YRru28bpsKNCALgFmNzoxR7rs6FaQBzEsgDj80 +u6F04YlFIQwp9U8iWTljWWMX+p8OJ7Q1LXAPQl/aSZmlf+WLXQcr8x0qU+v7Xi1T +VSBZpvRMa0s941GE0WkCAwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAw +HQYDVR0OBBYEFLxrImWdjGjm5h4F8dhyJdS44EjAMB8GA1UdIwQYMBaAFOzX44LS +cV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEF +BQcDAzB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp +Z2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu +Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYy +aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j +cmwwHAYDVR0gBBUwEzAHBgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQELBQAD +ggIBAEnyvKd58wdZXmcA+X7z6oYDbVHvIUfZ06j7ceH8uVBtYIDZHk5oVHGzVui4 +wx3D97fdWCqsFL+B59Z9jZcTPdrIlmstIiLkystVOrkyDRsOoiI/6O/nuvxOS+iS +o+9MLMiU/rUB9YrzLL+USwFeIitwL4CRhdf+Zuar/NYsy1qU8cHV04pGIdie2mPK +fLa5TqRhA1BElbYPcakAoWKx//O1B04BYiVAbU9dEr2n+pItB2/QzjaIm9tNbcry +eXW8skjjVMp8AP6t/y9x0XchZ5pVbTABf/qVT3ffxsyzgyL7HyWgC/fVR7rbgVoX +9Bw/GmHw/xidbZdYWBceThHp1mR2MJtbA6wr3l9c+FguOMF51v9QV45/nhGCQJLc +nOgdM2wbz13Y7udRoF1pbBfDdBqyo1m9Ztl8NDyUnGp8+ujPUu+egIgPy3Z8hSCP +bLKQNSMsgRSdcrX+Kd+OOzZKpDvH2EJwzNRqizJLL+luO4AKf8NKIlTXHFbGGaXh +mNXB6PSUL2XobkwHmA9ZLV6AQ5G3S1gBMYnLBbbjEmuMYzWpT/4WWydXvGZGkCgw +MqHKVHkOp4Rxy31w8JJBVyp2NTvdr5JXNlrb1U0dJU1Ie0bNkXxvw5vVvv0jhgSL +d2cMpodm6dMlNU5TWt2QtLrUKtfNvcLKeF6wG2ayii6COsXo +-----END CERTIFICATE----- diff --git a/build-scripts/package-linux-deb.sh b/build-scripts/package-linux-deb.sh index cf257eb2..02bffab4 100644 --- a/build-scripts/package-linux-deb.sh +++ b/build-scripts/package-linux-deb.sh @@ -4,8 +4,41 @@ START_DIR="$(pwd)" SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -SRC_PATH="$1" -DEB_ARCH="$2" +if [[ $# -ne 2 && $# -ne 3 ]]; then + echo "usage: $0 [playitd-binary] " >&2 + exit 1 +fi + +resolve_input_path() { + if [[ "$1" = /* ]]; then + printf '%s\n' "$1" + else + printf '%s/%s\n' "${START_DIR}" "$1" + fi +} + +CLI_SRC_PATH="$1" + +if [[ $# -eq 3 ]]; then + DAEMON_SRC_PATH="$2" + DEB_ARCH="$3" +else + DAEMON_SRC_PATH="$(dirname "${CLI_SRC_PATH}")/playitd" + DEB_ARCH="$2" +fi + +CLI_BIN="$(resolve_input_path "${CLI_SRC_PATH}")" +DAEMON_BIN="$(resolve_input_path "${DAEMON_SRC_PATH}")" + +if [[ ! -f "${CLI_BIN}" ]]; then + echo "playit CLI binary not found: ${CLI_BIN}" >&2 + exit 1 +fi + +if [[ ! -f "${DAEMON_BIN}" ]]; then + echo "playit daemon binary not found: ${DAEMON_BIN}" >&2 + exit 1 +fi TEMP_DIR_NAME="temp-build-${DEB_ARCH}" @@ -21,7 +54,7 @@ cd "${TEMP_DIR_NAME}" INSTALL_FOLDER="/opt/playit" ROOT_CARGO_FILE="${SCRIPT_DIR}/../Cargo.toml" -CARGO_FILE="${SCRIPT_DIR}/../packages/agent_cli/Cargo.toml" +CARGO_FILE="${SCRIPT_DIR}/../packages/playit-cli/Cargo.toml" VERSION=$(toml get "${ROOT_CARGO_FILE}" workspace.package.version | sed "s/\"//g") DEB_PACKAGE="playit_${DEB_ARCH}" @@ -33,7 +66,9 @@ WK_DIR=$(pwd) echo "PREPARE BINARY AND RUN SCRIPT" mkdir -p "${WK_DIR}${INSTALL_FOLDER}" -cp "${START_DIR}/${SRC_PATH}" "${WK_DIR}${INSTALL_FOLDER}/agent" +cp "${CLI_BIN}" "${WK_DIR}${INSTALL_FOLDER}/agent" +cp "${DAEMON_BIN}" "${WK_DIR}${INSTALL_FOLDER}/playitd" +chmod 0755 "${WK_DIR}${INSTALL_FOLDER}/agent" "${WK_DIR}${INSTALL_FOLDER}/playitd" # Create run script echo "#!/bin/bash @@ -65,11 +100,30 @@ Depends: logrotate # setup script cat < "${WK_DIR}/DEBIAN/postinst" #!/bin/bash -ln -s ${INSTALL_FOLDER}/playit /usr/local/bin/playit -mkdir -p /var/log/playit # make logs folder -chmod 0766 -R /var/log/playit -mkdir -p /etc/playit -chmod 0777 /etc/playit +set -e + +mkdir -p /usr/local/bin +ln -sfn ${INSTALL_FOLDER}/playit /usr/local/bin/playit +getent group playit >/dev/null || groupadd --system playit +install -d -o root -g playit -m 0750 /etc/playit +install -d -o root -g playit -m 0750 /var/log/playit +chown root:playit /etc/playit +chmod 0750 /etc/playit +if [[ -f /etc/playit/playit.toml ]]; then + chown root:root /etc/playit/playit.toml + chmod 0600 /etc/playit/playit.toml +fi +chown root:playit /var/log/playit +chmod 0750 /var/log/playit + +if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is required to install playit" >&2 + exit 1 +fi + +systemctl daemon-reload +systemctl enable playit +systemctl restart playit || systemctl start playit EOF chmod 0555 "${WK_DIR}/DEBIAN/postinst" diff --git a/build-scripts/windows-sign.sh b/build-scripts/windows-sign.sh index eaba0d5b..5fb680e2 100644 --- a/build-scripts/windows-sign.sh +++ b/build-scripts/windows-sign.sh @@ -24,14 +24,38 @@ curl -L -o "${SIGN_DIR}/playit-windows-x86_64.msi" https://github.com/playit-clo curl -L -o "${SIGN_DIR}/playit-windows-x86.exe" https://github.com/playit-cloud/playit-agent/releases/download/v${VERSION}/playit-windows-x86.exe curl -L -o "${SIGN_DIR}/playit-windows-x86_64.exe" https://github.com/playit-cloud/playit-agent/releases/download/v${VERSION}/playit-windows-x86_64.exe -java -jar "${SCRIPT_DIR}/jsign-5.0.jar" --keystore /usr/local/lib/libykcs11.so --storetype YUBIKEY --tsaurl http://ts.ssl.com --storepass "${PIN}" --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86.msi" \ +java -jar "${SCRIPT_DIR}/jsign-5.0.jar" \ + --keystore PIV \ + --storetype YUBIKEY \ + --tsaurl http://timestamp.digicert.com \ + --storepass "${PIN}" \ + --certfile "${SCRIPT_DIR}/digicert.pem" \ + --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86.msi" \ && mv "${SIGN_DIR}/playit-windows-x86.msi" "${SIGN_DIR}/playit-windows-x86-signed.msi" -java -jar "${SCRIPT_DIR}/jsign-5.0.jar" --keystore /usr/local/lib/libykcs11.so --storetype YUBIKEY --tsaurl http://ts.ssl.com --storepass "${PIN}" --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86_64.msi" \ +java -jar "${SCRIPT_DIR}/jsign-5.0.jar" \ + --keystore PIV \ + --storetype YUBIKEY \ + --tsaurl http://timestamp.digicert.com \ + --storepass "${PIN}" \ + --certfile "${SCRIPT_DIR}/digicert.pem" \ + --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86_64.msi" \ && mv "${SIGN_DIR}/playit-windows-x86_64.msi" "${SIGN_DIR}/playit-windows-x86_64-signed.msi" -java -jar "${SCRIPT_DIR}/jsign-5.0.jar" --keystore /usr/local/lib/libykcs11.so --storetype YUBIKEY --tsaurl http://ts.ssl.com --storepass "${PIN}" --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86.exe" \ +java -jar "${SCRIPT_DIR}/jsign-5.0.jar" \ + --keystore PIV \ + --storetype YUBIKEY \ + --tsaurl http://timestamp.digicert.com \ + --storepass "${PIN}" \ + --certfile "${SCRIPT_DIR}/digicert.pem" \ + --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86.exe" \ && mv "${SIGN_DIR}/playit-windows-x86.exe" "${SIGN_DIR}/playit-windows-x86-signed.exe" -java -jar "${SCRIPT_DIR}/jsign-5.0.jar" --keystore /usr/local/lib/libykcs11.so --storetype YUBIKEY --tsaurl http://ts.ssl.com --storepass "${PIN}" --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86_64.exe" \ +java -jar "${SCRIPT_DIR}/jsign-5.0.jar" \ + --keystore PIV \ + --storetype YUBIKEY \ + --tsaurl http://timestamp.digicert.com \ + --storepass "${PIN}" \ + --certfile "${SCRIPT_DIR}/digicert.pem" \ + --tsmode RFC3161 "${SIGN_DIR}/playit-windows-x86_64.exe" \ && mv "${SIGN_DIR}/playit-windows-x86_64.exe" "${SIGN_DIR}/playit-windows-x86_64-signed.exe" diff --git a/ci.Dockerfile b/ci.Dockerfile index 3c10c551..f70548f3 100644 --- a/ci.Dockerfile +++ b/ci.Dockerfile @@ -3,17 +3,25 @@ FROM alpine:3.18 AS artifact-downloader ARG REPOSITORY ARG VERSION +ARG TARGETARCH WORKDIR /download -RUN wget -O playit "https://github.com/${REPOSITORY}/releases/download/${VERSION}/playit-linux-$([[ "$(uname -m)" == "x86_64" ]] && echo "amd64" || echo "aarch64")" && chmod +x playit +RUN apk add --no-cache dpkg wget +RUN case "${TARGETARCH}" in \ + amd64) deb_arch="amd64" ;; \ + arm64) deb_arch="arm64" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && wget -O playit.deb "https://github.com/${REPOSITORY}/releases/download/${VERSION}/playit_${deb_arch}.deb" \ + && dpkg-deb -x playit.deb /extract ########## RUNTIME CONTAINER ########## FROM alpine:3.18 RUN apk add --no-cache ca-certificates -COPY --from=artifact-downloader /download/playit /usr/local/bin/playit +COPY --from=artifact-downloader /extract/opt/playit/playitd /usr/local/bin/playitd RUN mkdir /playit COPY docker/entrypoint.sh /playit/entrypoint.sh RUN chmod +x /playit/entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 70962fba..4d372c8f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,13 +1,16 @@ #!/usr/bin/env sh +SECRET_KEY="${SECRET_KEY:-}" + if [ -z "${SECRET_KEY}" ]; then - echo "SECRET_KEY environment variable missing, using CLI argument"; - SECRET_KEY="$1" + SECRET_KEY="${1:-}" if [ -z "${SECRET_KEY}" ]; then - echo "secret key (first argument) is missing"; - exit 1; + echo "secret key is required via SECRET_KEY or the first argument" >&2 + exit 1 fi + + shift fi -exec playit -s --secret "${SECRET_KEY}" --platform_docker start +exec playitd --secret "${SECRET_KEY}" --platform-docker "$@" 2>&1 diff --git a/linux/playit.service b/linux/playit.service index 1f16474a..b69376a0 100644 --- a/linux/playit.service +++ b/linux/playit.service @@ -5,7 +5,7 @@ Wants=network-pre.target After=network-pre.target NetworkManager.service systemd-resolved.service [Service] -ExecStart=/opt/playit/playit --secret_wait --secret_path /etc/playit/playit.toml -l /var/log/playit/playit.log start +ExecStart=/opt/playit/playitd --secret-path /etc/playit/playit.toml -l /var/log/playit/playit.log Restart=on-failure [Install] diff --git a/mac-deploy.sh b/mac-deploy.sh index d70f58b2..2f8cf73f 100644 --- a/mac-deploy.sh +++ b/mac-deploy.sh @@ -1,7 +1,7 @@ # SHOULD BE RUN ON M1 MAC FOLDER=$(dirname "$0") -VERSION="$(toml get "${FOLDER}/packages/agent_cli/Cargo.toml" package.version | sed "s/\"//g")" +VERSION="$(toml get "${FOLDER}/packages/playit-cli/Cargo.toml" package.version | sed "s/\"//g")" bash ${FOLDER}/build-scripts/macos-app.sh diff --git a/packages/agent_cli/src/autorun.rs b/packages/agent_cli/src/autorun.rs deleted file mode 100644 index 97257f45..00000000 --- a/packages/agent_cli/src/autorun.rs +++ /dev/null @@ -1,359 +0,0 @@ -use std::{fmt::Write, net::SocketAddr, sync::Arc, time::Duration}; - -use playit_agent_core::{ - network::{ - origin_lookup::{OriginLookup, OriginResource, OriginTarget}, - tcp::tcp_settings::TcpSettings, - udp::udp_settings::UdpSettings, - }, - playit_agent::{PlayitAgent, PlayitAgentSettings}, - stats::AgentStats, - utils::now_milli, -}; -use playit_api_client::api::*; -use tokio::sync::mpsc; - -use crate::{ - API_BASE, CliError, - playit_secret::PlayitSecret, - ui::{ - tui_app::{AccountStatusInfo, AgentData, ConnectionStats, NoticeInfo, PendingTunnelInfo, TunnelInfo}, - UI, - }, -}; - -pub async fn autorun(ui: &mut UI, mut secret: PlayitSecret) -> Result<(), CliError> { - let secret_code = secret.ensure_valid(ui).await?.get_or_setup(ui).await?; - - let api = secret.create_api().await?; - - tokio::time::sleep(Duration::from_secs(2)).await; - - let lookup = Arc::new(OriginLookup::default()); - lookup - .update_from_run_data(&api.v1_agents_rundata().await?) - .await; - - let mut error_count = 0; - ui.write_screen("starting up tunnel connection").await; - - let settings = PlayitAgentSettings { - udp_settings: UdpSettings::default(), - tcp_settings: TcpSettings::default(), - api_url: API_BASE.to_string(), - secret_key: secret_code.clone(), - }; - - let (runner, stats) = loop { - match PlayitAgent::new(settings.clone(), lookup.clone()).await { - Ok(res) => { - let stats = res.stats(); - break (res, stats); - } - Err(error) => { - error_count += 1; - if error_count > 5 { - ui.write_error("Final attempted failed to setup tunnel", &error) - .await; - tokio::time::sleep(Duration::from_secs(5)).await; - return Err(CliError::TunnelSetupError(error)); - }; - - ui.write_error("Failed to setup tunnel client", error).await; - tokio::time::sleep(Duration::from_secs(5)).await; - } - } - }; - - tokio::spawn(runner.run()); - - tracing::info!("tunnel running"); - - // Run the appropriate UI loop - if ui.is_tui() { - run_tui_loop(ui, api, lookup, stats).await - } else { - run_log_only_loop(ui, api, lookup).await - } -} - -/// Run the TUI event loop with data updates -async fn run_tui_loop( - ui: &mut UI, - api: playit_api_client::PlayitApi, - lookup: Arc, - stats: AgentStats, -) -> Result<(), CliError> { - let (data_tx, mut data_rx) = mpsc::channel::(4); - - // Spawn the data fetcher task - let api_clone = api.clone(); - let lookup_clone = lookup.clone(); - tokio::spawn(async move { - let mut guest_login_link: Option<(String, u64)> = None; - let mut first_run = true; - - loop { - // Don't delay on first run - if first_run { - first_run = false; - } else { - tokio::time::sleep(Duration::from_secs(3)).await; - } - - let account_tunnels_res = api_clone.v1_agents_rundata().await; - let mut api_data = match account_tunnels_res { - Ok(v) => v, - Err(error) => { - tracing::error!(?error, "Failed to load latest tunnels"); - continue; - } - }; - - lookup_clone.update_from_run_data(&api_data).await; - - // Convert API data to TUI AgentData - let account_status = match api_data.permissions.account_status { - AccountStatus::Guest => AccountStatusInfo::Guest, - AccountStatus::EmailNotVerified => AccountStatusInfo::EmailNotVerified, - AccountStatus::Verified => AccountStatusInfo::Verified, - }; - - // Get login link for guest accounts - let login_link = match api_data.permissions.account_status { - AccountStatus::Guest => { - let now = now_milli(); - match &guest_login_link { - Some((link, ts)) if now - *ts < 15_000 => Some(link.clone()), - _ => { - if let Ok(session) = api_clone.login_guest().await { - let link = format!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ); - guest_login_link = Some((link.clone(), now_milli())); - Some(link) - } else { - None - } - } - } - } - _ => None, - }; - - api_data.notices.sort_by_key(|n| n.priority); - - let notices: Vec = api_data - .notices - .iter() - .map(|n| NoticeInfo { - priority: format!("{:?}", n.priority), - message: n.message.to_string(), - resolve_link: n.resolve_link.as_ref().map(|s| s.to_string()), - }) - .collect(); - - let tunnels: Vec = api_data - .tunnels - .iter() - .filter_map(|tunnel| { - let origin = OriginResource::from_agent_tunnel(tunnel)?; - - let destination = match origin.target { - OriginTarget::Https { - ip, - http_port, - https_port, - } => format!("{ip} (http: {http_port}, https: {https_port})"), - OriginTarget::Port { ip, port } => SocketAddr::new(ip, port).to_string(), - }; - - Some(TunnelInfo { - display_address: tunnel.display_address.clone(), - destination, - is_disabled: tunnel.disabled_reason.is_some(), - disabled_reason: tunnel.disabled_reason.as_ref().map(|s| s.to_string()), - }) - }) - .collect(); - - let pending_tunnels: Vec = api_data - .pending - .iter() - .map(|p| PendingTunnelInfo { - id: p.id.to_string(), - status_msg: p.status_msg.clone(), - }) - .collect(); - - let agent_data = AgentData { - version: env!("CARGO_PKG_VERSION").to_string(), - tunnels, - pending_tunnels, - notices, - account_status, - agent_id: api_data.agent_id.to_string(), - login_link, - }; - - if data_tx.send(agent_data).await.is_err() { - // UI has closed - break; - } - } - }); - - // Run the TUI with data updates - loop { - // Check for new data - while let Ok(data) = data_rx.try_recv() { - ui.update_agent_data(data); - } - - // Update stats from the agent - let snapshot = stats.snapshot(); - ui.update_stats(ConnectionStats { - bytes_in: snapshot.bytes_in, - bytes_out: snapshot.bytes_out, - active_tcp: snapshot.active_tcp, - active_udp: snapshot.active_udp, - }); - - // Run one iteration of the TUI - match ui.tick_tui() { - Ok(true) => {} // Continue - Ok(false) => { - // Quit requested - ui.shutdown_tui()?; - std::process::exit(0); - } - Err(e) => { - ui.shutdown_tui()?; - return Err(e); - } - } - - // Yield to allow other tasks to run - tokio::task::yield_now().await; - } -} - -/// Run the log-only loop (original behavior) -async fn run_log_only_loop( - ui: &mut UI, - api: playit_api_client::PlayitApi, - lookup: Arc, -) -> Result<(), CliError> { - let mut guest_login_link: Option<(String, u64)> = None; - - loop { - tokio::time::sleep(Duration::from_secs(3)).await; - - let account_tunnels_res = api.v1_agents_rundata().await; - let mut agent_data = match account_tunnels_res { - Ok(v) => v, - Err(error) => { - ui.write_error("Failed to load latest tunnels", error).await; - tokio::time::sleep(Duration::from_secs(3)).await; - continue; - } - }; - - lookup.update_from_run_data(&agent_data).await; - - let mut msg = format!( - "playit (v{}): {} tunnel running, {} tunnels registered\n\n", - env!("CARGO_PKG_VERSION"), - now_milli(), - agent_data.tunnels.len() - ); - - match agent_data.permissions.account_status { - AccountStatus::Guest => 'login_link: { - let now = now_milli(); - - match &guest_login_link { - Some((link, ts)) if now - *ts < 15_000 => { - writeln!(msg, "login: {}", link).unwrap(); - } - _ => { - let Ok(session) = api.login_guest().await else { - writeln!(msg, "Failed to create guest login link").unwrap(); - break 'login_link; - }; - - let link = format!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ); - writeln!(msg, "login: {}", link).unwrap(); - - guest_login_link = Some((link, now_milli())); - } - } - } - AccountStatus::EmailNotVerified => { - writeln!( - msg, - "Email not verified https://playit.gg/account/settings/account/verify-email" - ) - .unwrap(); - } - AccountStatus::Verified => {} - } - - agent_data.notices.sort_by_key(|n| n.priority); - - for notice in &agent_data.notices { - writeln!(msg, "[{:?}] {}", notice.priority, notice.message).unwrap(); - if let Some(link) = ¬ice.resolve_link { - writeln!(msg, "{link}").unwrap(); - } - } - - writeln!(msg, "\nTUNNELS").unwrap(); - - if agent_data.tunnels.is_empty() && agent_data.pending.is_empty() { - writeln!( - msg, - "Add tunnels here: https://playit.gg/account/agents/{}", - agent_data.agent_id - ) - .unwrap(); - } else { - for tunnel in &agent_data.tunnels { - let Some(origin) = OriginResource::from_agent_tunnel(&tunnel) else { - continue; - }; - - if let Some(reason) = &tunnel.disabled_reason { - writeln!(msg, "{} => (disabled {reason})", tunnel.display_address).unwrap(); - continue; - } - - let dst = match origin.target { - OriginTarget::Https { - ip, - http_port, - https_port, - } => format!("{ip} (http: {http_port}, https: {https_port})"), - OriginTarget::Port { ip, port } => SocketAddr::new(ip, port).to_string(), - }; - - writeln!(msg, "{} => {}", tunnel.display_address, dst).unwrap(); - } - - for tunnel in &agent_data.pending { - writeln!( - msg, - "tunnel ({}): https://playit.gg/account/tunnels/{}", - tunnel.status_msg, tunnel.id - ) - .unwrap(); - } - } - - ui.write_screen(msg).await; - } -} diff --git a/packages/agent_cli/src/main.rs b/packages/agent_cli/src/main.rs deleted file mode 100644 index f91aa089..00000000 --- a/packages/agent_cli/src/main.rs +++ /dev/null @@ -1,447 +0,0 @@ -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::sync::LazyLock; -use std::time::Duration; - -use clap::{Command, arg}; -use playit_agent_core::agent_control::platform::current_platform; -use playit_agent_core::agent_control::version::{help_register_version, register_platform}; -use rand::Rng; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::EnvFilter; -use uuid::Uuid; - -use autorun::autorun; -use playit_agent_core::agent_control::errors::SetupError; -use playit_agent_core::utils::now_milli; -use playit_api_client::http_client::HttpClientError; -use playit_api_client::{PlayitApi, api::*}; -use playit_secret::PlayitSecret; - -use crate::signal_handle::get_signal_handle; -use crate::ui::log_capture::LogCaptureLayer; -use crate::ui::{UI, UISettings}; - -pub static API_BASE: LazyLock = - LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); - -pub mod autorun; -pub mod playit_secret; -pub mod signal_handle; -pub mod ui; -pub mod util; - -#[tokio::main] -async fn main() -> Result { - let matches = cli().get_matches(); - - /* register docker */ - { - let platform = if matches.get_flag("platform_docker") { - Platform::Docker - } else { - current_platform() - }; - - register_platform(platform); - - help_register_version( - env!("CARGO_PKG_VERSION"), - "308943e8-faef-4835-a2ba-270351f72aa3", - ); - } - - let mut secret = PlayitSecret::from_args(&matches).await; - let _ = secret.with_default_path().await; - - let log_only = matches.get_flag("stdout"); - let log_path = matches.get_one::("log_path"); - - // Use log-only mode if stdout flag is set OR if a log file path is specified - let use_log_only = log_only || log_path.is_some(); - - // Create UI first so we can get its log capture - let mut ui = UI::new(UISettings { - auto_answer: None, - log_only: use_log_only, - }); - - /* setup logging */ - // Get log level from PLAYIT_LOG env var, defaulting to "info" - let log_filter = EnvFilter::try_from_env("PLAYIT_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); - - let _guard = match (log_only, log_path) { - (true, Some(_)) => panic!("try to use -s and -l at the same time"), - (false, Some(path)) => { - let write_path = match path.rsplit_once("/") { - Some((dir, file)) => tracing_appender::rolling::never(dir, file), - None => tracing_appender::rolling::never(".", path), - }; - - let (non_blocking, guard) = tracing_appender::non_blocking(write_path); - tracing_subscriber::fmt() - .with_ansi(false) - .with_writer(non_blocking) - .with_env_filter(log_filter) - .init(); - Some(guard) - } - (true, None) => { - let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); - tracing_subscriber::fmt() - .with_ansi(current_platform() == Platform::Linux) - .with_writer(non_blocking) - .with_env_filter(log_filter) - .init(); - Some(guard) - } - (false, None) => { - // TUI mode - set up log capture layer with filter - if let Some(log_capture) = ui.log_capture() { - let capture_layer = LogCaptureLayer::new(log_capture); - tracing_subscriber::registry() - .with(log_filter) - .with(capture_layer) - .init(); - } - None - } - }; - - match matches.subcommand() { - None => { - ui.write_screen("no command provided, doing auto run").await; - tokio::time::sleep(Duration::from_secs(1)).await; - autorun(&mut ui, secret).await?; - } - Some(("start", _)) => { - autorun(&mut ui, secret).await?; - } - Some(("version", _)) => println!("{}", env!("CARGO_PKG_VERSION")), - #[cfg(target_os = "linux")] - Some(("setup", _)) => { - let mut secret = PlayitSecret::linux_service(); - let key = secret - .ensure_valid(&mut ui) - .await? - .get_or_setup(&mut ui) - .await?; - - let api = PlayitApi::create(API_BASE.to_string(), Some(key)); - if let Ok(session) = api.login_guest().await { - ui.write_screen(format!( - "Guest login:\nhttps://playit.gg/login/guest-account/{}", - session.session_key - )) - .await; - tokio::time::sleep(Duration::from_secs(10)).await; - } - - ui.write_screen("Playit setup, secret written to /etc/playit/playit.toml") - .await; - } - Some(("reset", _)) => loop { - let mut secerts = PlayitSecret::from_args(&matches).await; - secerts.with_default_path().await; - - let path = secerts.get_path().unwrap(); - if !tokio::fs::try_exists(path).await.unwrap_or(false) { - break; - } - - tokio::fs::remove_file(path).await.unwrap(); - println!("deleted secret at: {}", path); - }, - Some(("secret-path", _)) => { - let mut secerts = PlayitSecret::from_args(&matches).await; - secerts.with_default_path().await; - let path = secerts.get_path().unwrap(); - println!("{}", path); - } - Some(("account", m)) => match m.subcommand() { - Some(("login-url", _)) => { - let api = secret.create_api().await?; - let session = api.login_guest().await?; - println!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ) - } - _ => return Err(CliError::NotImplemented), - }, - Some(("claim", m)) => match m.subcommand() { - Some(("generate", _)) => { - ui.write_screen(claim_generate()).await; - } - Some(("url", m)) => { - let code = m.get_one::("CLAIM_CODE").expect("required"); - ui.write_screen(claim_url(code)?.to_string()).await; - } - Some(("exchange", m)) => { - let claim_code = m.get_one::("CLAIM_CODE").expect("required"); - let wait: u32 = m - .get_one::("wait") - .expect("required") - .parse() - .expect("invalid wait value"); - - let secret_key = - claim_exchange(&mut ui, claim_code, ClaimAgentType::SelfManaged, wait).await?; - ui.write_screen(secret_key).await; - } - _ => return Err(CliError::NotImplemented), - }, - _ => return Err(CliError::NotImplemented), - } - - Ok(std::process::ExitCode::SUCCESS) -} - -pub fn claim_generate() -> String { - let mut buffer = [0u8; 5]; - rand::rng().fill(&mut buffer); - hex::encode(&buffer) -} - -pub fn claim_url(code: &str) -> Result { - if hex::decode(code).is_err() { - return Err(CliError::InvalidClaimCode); - } - - Ok(format!("https://playit.gg/claim/{}", code,)) -} - -pub async fn claim_exchange( - ui: &mut UI, - claim_code: &str, - agent_type: ClaimAgentType, - wait_sec: u32, -) -> Result { - let api = PlayitApi::create(API_BASE.to_string(), None); - - let end_at = if wait_sec == 0 { - u64::MAX - } else { - now_milli() + (wait_sec as u64) * 1000 - }; - - { - let _close_guard = get_signal_handle().close_guard(); - let mut last_message = "Preparing Setup".to_string(); - - loop { - let setup_res = api - .claim_setup(ReqClaimSetup { - code: claim_code.to_string(), - agent_type, - version: format!("playit-cli {}", env!("CARGO_PKG_VERSION")), - }) - .await; - - let setup = match setup_res { - Ok(v) => v, - Err(error) => { - tracing::error!(?error, "Failed loading claim setup"); - ui.write_screen(format!("{}\n\nError: {:?}", last_message, error)) - .await; - tokio::time::sleep(Duration::from_secs(2)).await; - continue; - } - }; - - last_message = match setup { - ClaimSetupResponse::WaitingForUserVisit => { - format!("Visit link to setup {}", claim_url(claim_code)?) - } - ClaimSetupResponse::WaitingForUser => { - format!("Approve program at {}", claim_url(claim_code)?) - } - ClaimSetupResponse::UserAccepted => { - ui.write_screen("Program approved :). Secret code being setup.") - .await; - break; - } - ClaimSetupResponse::UserRejected => { - ui.write_screen("Program rejected :(").await; - tokio::time::sleep(Duration::from_secs(3)).await; - return Err(CliError::AgentClaimRejected); - } - }; - - ui.write_screen(&last_message).await; - tokio::time::sleep(Duration::from_millis(200)).await; - } - } - - let secret_key = loop { - match api - .claim_exchange(ReqClaimExchange { - code: claim_code.to_string(), - }) - .await - { - Ok(res) => break res.secret_key, - Err(ApiError::Fail(status)) => { - let msg = format!("code \"{}\" not ready, {:?}", claim_code, status); - ui.write_screen(msg).await; - } - Err(error) => return Err(error.into()), - }; - - if now_milli() > end_at { - ui.write_screen("you took too long to approve the program, closing") - .await; - tokio::time::sleep(Duration::from_secs(2)).await; - return Err(CliError::TimedOut); - } - - tokio::time::sleep(Duration::from_secs(2)).await; - }; - - Ok(secret_key) -} - -#[derive(Debug)] -pub enum CliError { - InvalidClaimCode, - NotImplemented, - MissingSecret, - MalformedSecret, - InvalidSecret, - RenderError(std::io::Error), - SecretFileLoadError, - SecretFileWriteError(std::io::Error), - SecretFilePathMissing, - InvalidPortType, - InvalidPortCount, - InvalidMappingOverride, - AgentClaimRejected, - InvalidConfigFile, - TunnelNotFound(Uuid), - TimedOut, - AnswerNotProvided, - TunnelOverwrittenAlready(Uuid), - ResourceNotFoundAfterCreate(Uuid), - RequestError(HttpClientError), - ApiError(ApiResponseError), - ApiFail(String), - TunnelSetupError(SetupError), -} - -impl Error for CliError {} - -impl Display for CliError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -} - -impl From> for CliError { - fn from(e: ApiError) -> Self { - match e { - ApiError::ApiError(e) => CliError::ApiError(e), - ApiError::ClientError(e) => CliError::RequestError(e), - ApiError::Fail(fail) => CliError::ApiFail(serde_json::to_string(&fail).unwrap()), - } - } -} - -impl From> for CliError { - fn from(e: ApiErrorNoFail) -> Self { - match e { - ApiErrorNoFail::ApiError(e) => CliError::ApiError(e), - ApiErrorNoFail::ClientError(e) => CliError::RequestError(e), - } - } -} - -impl From for CliError { - fn from(e: SetupError) -> Self { - CliError::TunnelSetupError(e) - } -} - -fn cli() -> Command { - let mut cmd = Command::new("playit-cli") - .arg(arg!(--secret "secret code for the agent").required(false)) - .arg(arg!(--secret_path "path to file containing secret").required(false)) - .arg(arg!(-w --secret_wait "wait for secret_path file to read secret").required(false)) - .arg(arg!(-s --stdout "prints logs to stdout").required(false)) - .arg(arg!(-l --log_path "path to write logs to").required(false)) - .arg(arg!(--platform_docker "overrides platform in version to be docker").required(false)) - .subcommand_required(false) - .subcommand(Command::new("version")) - .subcommand( - Command::new("account") - .subcommand_required(true) - .subcommand( - Command::new("login-url") - .about("Generates a link to allow user to login") - ) - ) - .subcommand( - Command::new("claim") - .subcommand_required(true) - .arg(arg!(--name "name of the agent").required(false)) - .about("Setting up a new playit agent") - .long_about("Provides a URL that can be visited to claim the agent and generate a secret key") - .subcommand( - Command::new("generate") - .about("Generates a random claim code") - ) - .subcommand( - Command::new("url") - .about("Print a claim URL given the code and options") - .arg(arg!( "claim code")) - .arg(arg!(--name [NAME] "name for the agent").default_value("from-cli")) - .arg(arg!(--type [TYPE] "the agent type").default_value("self-managed")) - ) - .subcommand( - Command::new("exchange") - .about("Exchanges the claim for the secret key") - .arg(arg!( "claim code (see \"claim generate\")")) - .arg(arg!(--wait "number of seconds to wait 0=infinite").default_value("0")) - ) - ) - .subcommand( - Command::new("start") - .about("Start the playit agent") - ) - .subcommand( - Command::new("tunnels") - .subcommand_required(true) - .about("Manage tunnels") - .subcommand( - Command::new("prepare") - .about("Create a tunnel if it doesn't exist with the parameters") - .arg(arg!(--type [TUNNEL_TYPE] "the tunnel type")) - .arg(arg!(--name [NAME] "name of the tunnel")) - .arg(arg!( "either \"tcp\", \"udp\", or \"both\"")) - .arg(arg!( "number of ports in a series to allocate").default_value("1")) - .arg(arg!(--exact)) - .arg(arg!(--ignore_name)) - ) - .subcommand( - Command::new("list") - .about("List tunnels (format \"[tunnel-id] [port-type] [port-count] [public-address]\")") - ) - ) - .subcommand( - Command::new("reset") - .about("removes the secret key on your system so the playit agent can be re-claimed") - ) - .subcommand( - Command::new("secret-path") - .about("shows the file path where the playit secret can be found") - ) - ; - - #[cfg(target_os = "linux")] - { - cmd = cmd.subcommand(Command::new("setup")); - } - - cmd -} diff --git a/packages/agent_cli/src/playit_secret.rs b/packages/agent_cli/src/playit_secret.rs deleted file mode 100644 index f15fa575..00000000 --- a/packages/agent_cli/src/playit_secret.rs +++ /dev/null @@ -1,264 +0,0 @@ -use std::time::Duration; - -use clap::ArgMatches; -use playit_api_client::{PlayitApi, api::*}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -use crate::{API_BASE, CliError, claim_exchange, claim_generate, ui::UI}; - -pub struct PlayitSecret { - secret: RwLock>, - path: Option, - allow_path_read: bool, - wait_for_path: bool, -} - -impl PlayitSecret { - pub async fn create_api(&self) -> Result { - let secret: String = self.get().await?; - Ok(PlayitApi::create(API_BASE.to_string(), Some(secret))) - } - - pub async fn with_default_path(&mut self) -> &mut Self { - if self.path.is_some() { - return self; - } - - let config_path = dirs::config_local_dir(); - - if config_path.is_none() || tokio::fs::try_exists("playit.toml").await.unwrap_or(false) { - self.path = Some("playit.toml".to_string()); - return self; - } - - /* old versions for linux used /etc/playit/playit.toml */ - #[cfg(target_os = "linux")] - { - let old_path = "/etc/playit/playit.toml"; - if tokio::fs::try_exists(&old_path).await.unwrap_or(false) { - self.path = Some(old_path.to_string()); - return self; - } - } - - let config_root = config_path.as_ref().unwrap().to_string_lossy(); - let config_folder = format!("{}/playit_gg", config_root); - if let Err(error) = tokio::fs::create_dir_all(&config_folder).await { - tracing::error!(?error, "failed to create configuration folder"); - self.path = Some("playit.toml".to_string()); - return self; - } - - self.path = Some(format!("{}/playit.toml", config_folder)); - self - } - - pub fn get_path(&self) -> Option<&str> { - let path = self.path.as_ref()?; - Some(path.as_str()) - } - - pub async fn ensure_valid(&mut self, ui: &mut UI) -> Result<&mut Self, CliError> { - let api = match self.create_api().await { - Ok(v) => v, - Err(error) => { - if !self.allow_path_read { - tracing::warn!("invalid secret, not reading secret from path"); - return Err(error); - } - - { - let mut secret = self.secret.write().await; - let _ = secret.take(); - } - - return Ok(self); - } - }; - - ui.write_screen("checking if secret key is valid").await; - tokio::time::sleep(Duration::from_secs(1)).await; - - loop { - match api.agents_rundata().await { - Ok(data) => { - ui.write_screen(format!( - "secret key valid, agent has {} tunnels", - data.tunnels.len() - )) - .await; - - tokio::time::sleep(Duration::from_secs(3)).await; - break; - } - Err(ApiErrorNoFail::ClientError(error)) => { - ui.write_error("Failed to load data from api\nretrying in 3 seconds", error) - .await; - tokio::time::sleep(Duration::from_secs(3)).await; - } - Err(ApiErrorNoFail::ApiError(ApiResponseError::Auth( - AuthError::InvalidAgentKey, - ))) => { - if !self.path.is_some() || !self.allow_path_read { - return Err(CliError::InvalidSecret); - } - - let reset = ui - .yn_question("Invalid secret, do you want to reset", Some(true)) - .await?; - - if reset { - self.allow_path_read = false; - - let mut locked = self.secret.write().await; - let _ = locked.take(); - break; - } - } - Err(ApiErrorNoFail::ApiError(error)) => { - ui.write_error("unexpected error checking if secret is valid", &error) - .await; - tokio::time::sleep(Duration::from_secs(5)).await; - return Err(CliError::ApiError(error)); - } - } - } - - Ok(self) - } - - pub async fn get_or_setup(&mut self, ui: &mut UI) -> Result { - loop { - match self.get().await { - Ok(secret) => return Ok(secret), - Err(CliError::SecretFileLoadError) if self.wait_for_path => { - tracing::info!(path = ?self.path, "waiting for secret to be populated (run `playit setup`)"); - tokio::time::sleep(Duration::from_secs(2)).await; - } - _ => break, - } - } - - if self.path.is_none() { - return Err(CliError::SecretFilePathMissing); - } - - let claim_code = claim_generate(); - let secret = claim_exchange(ui, &claim_code, ClaimAgentType::Assignable, 0).await?; - - { - let mut lock = self.secret.write().await; - lock.replace(secret.clone()); - } - - self.write_secret(ui, secret.clone()).await?; - Ok(secret) - } - - async fn write_secret(&mut self, ui: &mut UI, secret: String) -> Result<(), CliError> { - let path = self - .path - .as_ref() - .ok_or(CliError::SecretFilePathMissing)? - .trim(); - - let content = if path.ends_with(".toml") { - toml::to_string(&OldConfig { secret_key: secret }).unwrap() - } else { - secret - }; - - if let Err(error) = tokio::fs::write(path, &content).await { - ui.write_error(format!("failed to save secret, path: {}", path), &error) - .await; - tokio::time::sleep(Duration::from_secs(5)).await; - return Err(CliError::SecretFileWriteError(error)); - } - - self.allow_path_read = true; - Ok(()) - } - - pub async fn get(&self) -> Result { - { - let lock = self.secret.read().await; - if let Some(value) = &*lock { - let trimmed = value.trim(); - if hex::decode(trimmed).is_err() { - return Err(CliError::MalformedSecret); - } - return Ok(trimmed.to_string()); - } - } - - if !self.allow_path_read { - return Err(CliError::MissingSecret); - } - - let file_path = self.path.as_ref().ok_or(CliError::MissingSecret)?; - tracing::info!(%file_path, "loading secret"); - - let mut lock = self.secret.write().await; - - let content = tokio::fs::read_to_string(file_path) - .await - .map_err(|_| CliError::SecretFileLoadError)?; - let trimmed = content.trim(); - - if hex::decode(trimmed).is_err() { - let config = - toml::from_str::(&content).map_err(|_| CliError::MalformedSecret)?; - let trimmed = config.secret_key.trim(); - - hex::decode(trimmed).map_err(|_| CliError::MalformedSecret)?; - - lock.replace(trimmed.to_string()); - Ok(trimmed.to_string()) - } else { - lock.replace(trimmed.to_string()); - Ok(trimmed.to_string()) - } - } - - pub async fn from_args(matches: &ArgMatches) -> Self { - let mut secret = matches.get_one::("secret").cloned(); - let mut path = matches.get_one::("secret_path").cloned(); - - if secret.is_none() && path.is_none() { - if let Ok(secret_env) = std::env::var("PLAYIT_SECRET") { - secret.replace(secret_env); - } - } - - if path.is_none() { - if let Ok(path_env) = std::env::var("PLAYIT_SECRET_PATH") { - path.replace(path_env); - } - } - - let allow_path_read = secret.is_none(); - - PlayitSecret { - secret: RwLock::new(secret), - path, - allow_path_read, - wait_for_path: matches.get_flag("secret_wait"), - } - } - - #[cfg(target_os = "linux")] - pub fn linux_service() -> Self { - PlayitSecret { - secret: RwLock::new(None), - path: Some("/etc/playit/playit.toml".to_string()), - allow_path_read: true, - wait_for_path: false, - } - } -} - -#[derive(Deserialize, Serialize)] -struct OldConfig { - secret_key: String, -} diff --git a/packages/agent_cli/src/ui/log_capture.rs b/packages/agent_cli/src/ui/log_capture.rs deleted file mode 100644 index e6737acc..00000000 --- a/packages/agent_cli/src/ui/log_capture.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; -use tracing::{Event, Subscriber}; -use tracing_subscriber::layer::Context; -use tracing_subscriber::Layer; - -/// A log entry captured from tracing -#[derive(Clone, Debug)] -pub struct LogEntry { - pub timestamp: u64, - pub level: LogLevel, - pub target: String, - pub message: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, -} - -impl LogLevel { - pub fn as_str(&self) -> &'static str { - match self { - LogLevel::Trace => "TRACE", - LogLevel::Debug => "DEBUG", - LogLevel::Info => "INFO", - LogLevel::Warn => "WARN", - LogLevel::Error => "ERROR", - } - } -} - -impl From<&tracing::Level> for LogLevel { - fn from(level: &tracing::Level) -> Self { - match *level { - tracing::Level::TRACE => LogLevel::Trace, - tracing::Level::DEBUG => LogLevel::Debug, - tracing::Level::INFO => LogLevel::Info, - tracing::Level::WARN => LogLevel::Warn, - tracing::Level::ERROR => LogLevel::Error, - } - } -} - -/// Ring buffer for captured log entries -pub struct LogCapture { - entries: Mutex>, - capacity: usize, -} - -impl LogCapture { - pub fn new(capacity: usize) -> Arc { - Arc::new(LogCapture { - entries: Mutex::new(VecDeque::with_capacity(capacity)), - capacity, - }) - } - - /// Add a log entry to the buffer - pub fn push(&self, entry: LogEntry) { - let mut entries = self.entries.lock().unwrap(); - if entries.len() >= self.capacity { - entries.pop_front(); - } - entries.push_back(entry); - } - - /// Get a snapshot of all log entries - pub fn get_entries(&self) -> Vec { - let entries = self.entries.lock().unwrap(); - entries.iter().cloned().collect() - } - - /// Get the number of log entries - pub fn len(&self) -> usize { - self.entries.lock().unwrap().len() - } - - /// Check if the log buffer is empty - pub fn is_empty(&self) -> bool { - self.entries.lock().unwrap().is_empty() - } - - /// Clear all log entries - #[allow(dead_code)] - pub fn clear(&self) { - self.entries.lock().unwrap().clear(); - } -} - -/// Tracing layer that captures log events into a LogCapture buffer -pub struct LogCaptureLayer { - capture: Arc, -} - -impl LogCaptureLayer { - pub fn new(capture: Arc) -> Self { - LogCaptureLayer { capture } - } -} - -impl Layer for LogCaptureLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - use playit_agent_core::utils::now_milli; - - let metadata = event.metadata(); - let level = LogLevel::from(metadata.level()); - let target = metadata.target().to_string(); - - // Extract the message from the event - let mut visitor = MessageVisitor::default(); - event.record(&mut visitor); - - let entry = LogEntry { - timestamp: now_milli(), - level, - target, - message: visitor.message, - }; - - self.capture.push(entry); - } -} - -/// Visitor to extract the message field from a tracing event -#[derive(Default)] -struct MessageVisitor { - message: String, -} - -impl tracing::field::Visit for MessageVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - if field.name() == "message" { - self.message = format!("{:?}", value); - } else if self.message.is_empty() { - // Fallback: use any debug value if no message field - if !self.message.is_empty() { - self.message.push_str(", "); - } - self.message.push_str(&format!("{}={:?}", field.name(), value)); - } - } - - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - if field.name() == "message" { - self.message = value.to_string(); - } else { - if !self.message.is_empty() { - self.message.push_str(", "); - } - self.message.push_str(&format!("{}={}", field.name(), value)); - } - } -} diff --git a/packages/agent_cli/src/ui/mod.rs b/packages/agent_cli/src/ui/mod.rs deleted file mode 100644 index f6e3f57a..00000000 --- a/packages/agent_cli/src/ui/mod.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::sync::Arc; - -use crossterm::event::{self, Event, KeyCode, KeyEvent}; -use playit_agent_core::utils::now_milli; - -use crate::signal_handle::get_signal_handle; -use crate::CliError; - -pub mod log_capture; -pub mod tui_app; -pub mod widgets; - -pub use log_capture::LogCapture; -pub use tui_app::{AgentData, ConnectionStats, TuiApp}; - -/// UI mode - either TUI (interactive) or log-only (stdout) -pub enum UI { - Tui(Box), - LogOnly(LogOnlyUI), -} - -#[derive(Default, Clone)] -pub struct UISettings { - pub auto_answer: Option, - pub log_only: bool, -} - -impl UI { - pub fn new(settings: UISettings) -> Self { - if settings.log_only { - UI::LogOnly(LogOnlyUI::new(settings)) - } else { - UI::Tui(Box::new(TuiApp::new(settings))) - } - } - - pub async fn write_screen(&mut self, content: T) { - match self { - UI::Tui(tui) => tui.write_screen(content).await, - UI::LogOnly(log_only) => log_only.write_screen(content).await, - } - } - - pub async fn yn_question( - &mut self, - question: T, - default_yes: Option, - ) -> Result { - match self { - UI::Tui(tui) => tui.yn_question(question, default_yes).await, - UI::LogOnly(log_only) => log_only.yn_question(question, default_yes).await, - } - } - - pub async fn write_error( - &mut self, - msg: M, - error: E, - ) { - self.write_screen(format!("Got Error\nMSG: {}\nError: {:?}\n", msg, error)) - .await - } - - /// Update UI with agent data (for TUI mode) - pub fn update_agent_data(&mut self, data: AgentData) { - if let UI::Tui(tui) = self { - tui.update_agent_data(data); - } - } - - /// Update UI with connection stats (for TUI mode) - pub fn update_stats(&mut self, stats: ConnectionStats) { - if let UI::Tui(tui) = self { - tui.update_stats(stats); - } - } - - /// Get the log capture for TUI mode - pub fn log_capture(&self) -> Option> { - if let UI::Tui(tui) = self { - Some(tui.log_capture()) - } else { - None - } - } - - /// Run one iteration of the TUI event loop - /// Returns Ok(true) if should continue, Ok(false) if should quit - pub fn tick_tui(&mut self) -> Result { - if let UI::Tui(tui) = self { - tui.tick() - } else { - Ok(true) - } - } - - /// Shutdown the TUI - pub fn shutdown_tui(&mut self) -> Result<(), CliError> { - if let UI::Tui(tui) = self { - tui.shutdown() - } else { - Ok(()) - } - } - - /// Check if TUI mode is active - pub fn is_tui(&self) -> bool { - matches!(self, UI::Tui(_)) - } -} - -/// Log-only UI mode (original behavior) -pub struct LogOnlyUI { - auto_answer: Option, - last_display: Option<(u64, String)>, -} - -impl LogOnlyUI { - pub fn new(settings: UISettings) -> Self { - LogOnlyUI { - auto_answer: settings.auto_answer, - last_display: None, - } - } - - pub async fn write_screen(&mut self, content: T) { - let signal = get_signal_handle(); - let exit_confirm = signal.is_confirming_close(); - - if exit_confirm { - match self - .yn_question( - format!("{}\nClose requested, close program?", content), - Some(true), - ) - .await - { - Ok(close) => { - if close { - std::process::exit(0); - } else { - signal.decline_close(); - } - } - Err(error) => { - tracing::error!(%error, "failed to ask close signal question"); - } - } - - return; - } - - self.write_screen_inner(content).await - } - - async fn write_screen_inner(&mut self, content: T) { - { - let content = content.to_string(); - - if let Some((ts, last_render)) = &self.last_display { - if now_milli() - *ts < 10_000 && content.eq(last_render) { - return; - } - } - - tracing::info!("{}", content.lines().next().unwrap()); - self.last_display = Some((now_milli(), content)); - } - } - - pub async fn yn_question( - &mut self, - question: T, - default_yes: Option, - ) -> Result { - let mut line = String::new(); - let mut count = 0; - - 'ask_loop: loop { - count += 1; - - let pref = if count == 1 { - "".to_string() - } else { - format!("Invalid input: {:?}\n", line) - }; - - line.clear(); - - if let Some(default_yes) = default_yes { - if default_yes { - self.write_screen_inner(format!("{}{} (Y/n)? ", pref, question)) - .await; - } else { - self.write_screen_inner(format!("{}{} (y/N)? ", pref, question)) - .await; - } - } else { - self.write_screen_inner(format!("{}{} (y/n)? ", pref, question)) - .await; - } - - loop { - let code = match tokio::task::spawn_blocking(|| event::read()).await.unwrap() { - Ok(Event::Key(KeyEvent { code, .. })) => code, - _ => break 'ask_loop, - }; - - match code { - KeyCode::Enter => { - let input = line.trim().to_lowercase(); - if input.len() == 0 { - if let Some(default_yes) = default_yes { - return Ok(default_yes); - } - } - - if input.eq("y") || input.eq("yes") { - return Ok(true); - } - - if input.eq("n") || input.eq("no") { - return Ok(false); - } - - break; - } - KeyCode::Char(c) => { - line.push(c); - } - _ => {} - } - } - } - - if let Some(auto) = self.auto_answer { - return Ok(auto); - } - - if let Some(default_yes) = default_yes { - return Ok(default_yes); - } - - Err(CliError::AnswerNotProvided) - } -} diff --git a/packages/agent_cli/src/ui/tui_app.rs b/packages/agent_cli/src/ui/tui_app.rs deleted file mode 100644 index d7ae3b72..00000000 --- a/packages/agent_cli/src/ui/tui_app.rs +++ /dev/null @@ -1,724 +0,0 @@ -use std::io::{self, stdout, Stdout}; -use std::sync::Arc; -use std::time::Duration; - -use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use playit_agent_core::utils::now_milli; -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Frame, Terminal, -}; - -use super::log_capture::{LogCapture, LogEntry, LogLevel}; -use super::widgets::{render_header, render_help_bar, render_stats_bar}; -use super::UISettings; -use crate::signal_handle::get_signal_handle; -use crate::CliError; - -/// Data about the running agent -#[derive(Clone, Default)] -pub struct AgentData { - pub version: String, - pub tunnels: Vec, - pub pending_tunnels: Vec, - pub notices: Vec, - pub account_status: AccountStatusInfo, - pub agent_id: String, - pub login_link: Option, -} - -#[derive(Clone, Debug)] -pub struct TunnelInfo { - pub display_address: String, - pub destination: String, - pub is_disabled: bool, - pub disabled_reason: Option, -} - -#[derive(Clone, Debug)] -pub struct PendingTunnelInfo { - pub id: String, - pub status_msg: String, -} - -#[derive(Clone, Debug)] -pub struct NoticeInfo { - pub priority: String, - pub message: String, - pub resolve_link: Option, -} - -#[derive(Clone, Default, Debug, PartialEq)] -pub enum AccountStatusInfo { - #[default] - Unknown, - Guest, - EmailNotVerified, - Verified, -} - -/// Connection statistics -#[derive(Clone, Default)] -pub struct ConnectionStats { - pub bytes_in: u64, - pub bytes_out: u64, - pub active_tcp: u32, - pub active_udp: u32, -} - -/// Which panel is currently focused -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum FocusedPanel { - Tunnels, - Logs, -} - -/// UI mode for TuiApp -#[derive(Clone, Debug, PartialEq)] -pub enum TuiMode { - /// Setup mode - showing a message (e.g., claim URL) - Setup { message: String }, - /// Running mode - showing tunnels and stats - Running, -} - -/// Main TUI application state -pub struct TuiApp { - settings: UISettings, - log_capture: Arc, - agent_data: AgentData, - stats: ConnectionStats, - start_time: u64, - - // UI state - mode: TuiMode, - focused_panel: FocusedPanel, - tunnel_list_state: ListState, - log_scroll: usize, - log_follow: bool, // Auto-scroll logs when at bottom - should_quit: bool, - quit_confirm: bool, - - // Terminal - terminal: Option>>, -} - -impl TuiApp { - pub fn new(settings: UISettings) -> Self { - TuiApp { - settings, - log_capture: LogCapture::new(500), - agent_data: AgentData::default(), - stats: ConnectionStats::default(), - start_time: now_milli(), - mode: TuiMode::Setup { message: "Initializing...".to_string() }, - focused_panel: FocusedPanel::Tunnels, - tunnel_list_state: ListState::default(), - log_scroll: 0, - log_follow: true, // Start with follow mode enabled - should_quit: false, - quit_confirm: false, - terminal: None, - } - } - - pub fn log_capture(&self) -> Arc { - self.log_capture.clone() - } - - pub fn update_agent_data(&mut self, data: AgentData) { - self.agent_data = data; - // Switch to running mode when we get agent data - self.mode = TuiMode::Running; - } - - pub fn update_stats(&mut self, stats: ConnectionStats) { - self.stats = stats; - } - - /// Set the setup message to display - pub fn set_setup_message(&mut self, message: String) { - self.mode = TuiMode::Setup { message }; - } - - /// Switch to running mode - pub fn set_running_mode(&mut self) { - self.mode = TuiMode::Running; - } - - /// Initialize the terminal for TUI mode - fn init_terminal(&mut self) -> io::Result<()> { - enable_raw_mode()?; - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - self.terminal = Some(Terminal::new(backend)?); - Ok(()) - } - - /// Restore the terminal to normal mode - fn restore_terminal(&mut self) -> io::Result<()> { - disable_raw_mode()?; - if let Some(ref mut terminal) = self.terminal { - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - } - Ok(()) - } - - /// Initialize the TUI (call once before tick()) - pub fn init(&mut self) -> Result<(), CliError> { - if self.terminal.is_none() { - self.init_terminal().map_err(CliError::RenderError)?; - } - Ok(()) - } - - /// Shutdown the TUI (call when done) - pub fn shutdown(&mut self) -> Result<(), CliError> { - self.restore_terminal().map_err(CliError::RenderError) - } - - /// Check if the TUI should quit - pub fn should_quit(&self) -> bool { - self.should_quit - } - - /// Run one iteration of the TUI (draw + handle events) - /// Returns Ok(true) if should continue, Ok(false) if should quit - pub fn tick(&mut self) -> Result { - // Initialize if not already - if self.terminal.is_none() { - self.init()?; - } - - // Draw the UI - self.draw().map_err(CliError::RenderError)?; - - // Handle events with a short timeout to allow for async updates - if event::poll(Duration::from_millis(50)).map_err(CliError::RenderError)? { - if let Event::Key(key) = event::read().map_err(CliError::RenderError)? { - self.handle_key_event(key); - } - } - - // Check for signal close request - let signal = get_signal_handle(); - if signal.is_confirming_close() && !self.quit_confirm { - self.quit_confirm = true; - } - - // Return whether to continue - Ok(!self.should_quit) - } - - /// Run the TUI event loop (blocking) - pub async fn run(&mut self) -> Result<(), CliError> { - self.init()?; - - loop { - if !self.tick()? { - break; - } - // Yield to allow other tasks to run - tokio::task::yield_now().await; - } - - self.shutdown()?; - Ok(()) - } - - fn handle_key_event(&mut self, key: KeyEvent) { - // Handle quit confirmation - if self.quit_confirm { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - self.should_quit = true; - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - self.quit_confirm = false; - get_signal_handle().decline_close(); - } - _ => {} - } - return; - } - - match key.code { - // Quit - KeyCode::Char('q') => { - self.quit_confirm = true; - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.quit_confirm = true; - } - - // Navigation - KeyCode::Tab => { - self.focused_panel = match self.focused_panel { - FocusedPanel::Tunnels => FocusedPanel::Logs, - FocusedPanel::Logs => FocusedPanel::Tunnels, - }; - } - - // Scrolling - KeyCode::Char('j') | KeyCode::Down => self.scroll_down(), - KeyCode::Char('k') | KeyCode::Up => self.scroll_up(), - KeyCode::Char('g') => self.scroll_to_top(), - KeyCode::Char('G') => self.scroll_to_bottom(), - KeyCode::PageDown => { - for _ in 0..10 { - self.scroll_down(); - } - } - KeyCode::PageUp => { - for _ in 0..10 { - self.scroll_up(); - } - } - - _ => {} - } - } - - fn scroll_down(&mut self) { - match self.focused_panel { - FocusedPanel::Tunnels => { - let total = self.agent_data.tunnels.len(); - if total > 0 { - let i = match self.tunnel_list_state.selected() { - Some(i) => (i + 1).min(total - 1), - None => 0, - }; - self.tunnel_list_state.select(Some(i)); - } - } - FocusedPanel::Logs => { - let total = self.log_capture.len(); - if self.log_scroll < total.saturating_sub(1) { - self.log_scroll += 1; - // Re-enable follow if we scrolled to the bottom - if self.log_scroll >= total.saturating_sub(1) { - self.log_follow = true; - } - } - } - } - } - - fn scroll_up(&mut self) { - match self.focused_panel { - FocusedPanel::Tunnels => { - let i = match self.tunnel_list_state.selected() { - Some(i) => i.saturating_sub(1), - None => 0, - }; - self.tunnel_list_state.select(Some(i)); - } - FocusedPanel::Logs => { - self.log_scroll = self.log_scroll.saturating_sub(1); - // Disable follow when scrolling up - self.log_follow = false; - } - } - } - - fn scroll_to_top(&mut self) { - match self.focused_panel { - FocusedPanel::Tunnels => { - self.tunnel_list_state.select(Some(0)); - } - FocusedPanel::Logs => { - self.log_scroll = 0; - // Disable follow when going to top - self.log_follow = false; - } - } - } - - fn scroll_to_bottom(&mut self) { - match self.focused_panel { - FocusedPanel::Tunnels => { - let total = self.agent_data.tunnels.len(); - if total > 0 { - self.tunnel_list_state.select(Some(total - 1)); - } - } - FocusedPanel::Logs => { - let total = self.log_capture.len(); - self.log_scroll = total.saturating_sub(1); - // Enable follow when going to bottom - self.log_follow = true; - } - } - } - - fn draw(&mut self) -> io::Result<()> { - let terminal = self.terminal.as_mut().unwrap(); - - let mode = self.mode.clone(); - let agent_data = self.agent_data.clone(); - let stats = self.stats.clone(); - let start_time = self.start_time; - let focused_panel = self.focused_panel; - let quit_confirm = self.quit_confirm; - let log_entries = self.log_capture.get_entries(); - let log_follow = self.log_follow; - - // Auto-scroll to bottom if following logs - let log_scroll = if log_follow { - let total = log_entries.len(); - self.log_scroll = total.saturating_sub(1); - self.log_scroll - } else { - self.log_scroll - }; - - let mut tunnel_list_state = self.tunnel_list_state.clone(); - - terminal.draw(|frame| { - let area = frame.area(); - - match &mode { - TuiMode::Setup { message } => { - // Render setup screen with centered message - Self::render_setup_screen(frame, area, message, quit_confirm); - return; - } - TuiMode::Running => { - // Normal running mode - } - } - - // Main layout: Header, Content, Stats, Logs, Help - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Min(8), // Tunnels - Constraint::Length(3), // Stats - Constraint::Length(10), // Logs - Constraint::Length(1), // Help bar - ]) - .split(area); - - // Render header - render_header(frame, chunks[0], &agent_data, start_time); - - // Render tunnel list - Self::render_tunnels( - frame, - chunks[1], - &agent_data, - focused_panel == FocusedPanel::Tunnels, - &mut tunnel_list_state, - ); - - // Render stats bar - render_stats_bar(frame, chunks[2], &stats); - - // Render log panel - Self::render_logs( - frame, - chunks[3], - &log_entries, - log_scroll, - focused_panel == FocusedPanel::Logs, - log_follow, - ); - - // Render help bar - render_help_bar(frame, chunks[4], quit_confirm); - })?; - - self.tunnel_list_state = tunnel_list_state; - - Ok(()) - } - - fn render_tunnels( - frame: &mut Frame, - area: Rect, - agent_data: &AgentData, - focused: bool, - list_state: &mut ListState, - ) { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let block = Block::default() - .title(" Tunnels ") - .borders(Borders::ALL) - .border_style(border_style); - - if agent_data.tunnels.is_empty() && agent_data.pending_tunnels.is_empty() { - let msg = if agent_data.agent_id.is_empty() { - "No tunnels configured. Setting up..." - } else { - "No tunnels configured. Add tunnels at playit.gg" - }; - let paragraph = Paragraph::new(msg) - .style(Style::default().fg(Color::Yellow)) - .block(block) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); - return; - } - - let items: Vec = agent_data - .tunnels - .iter() - .map(|tunnel| { - let (style, prefix) = if tunnel.is_disabled { - (Style::default().fg(Color::Red), "✗ ") - } else { - (Style::default().fg(Color::Green), "● ") - }; - - let content = if let Some(reason) = &tunnel.disabled_reason { - format!( - "{}{} => (disabled: {})", - prefix, tunnel.display_address, reason - ) - } else { - format!("{}{} => {}", prefix, tunnel.display_address, tunnel.destination) - }; - - ListItem::new(content).style(style) - }) - .chain(agent_data.pending_tunnels.iter().map(|pending| { - let content = format!("◐ {} ({})", pending.id, pending.status_msg); - ListItem::new(content).style(Style::default().fg(Color::Yellow)) - })) - .collect(); - - let list = List::new(items) - .block(block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .bg(Color::DarkGray), - ) - .highlight_symbol("▶ "); - - frame.render_stateful_widget(list, area, list_state); - } - - fn render_logs( - frame: &mut Frame, - area: Rect, - log_entries: &[LogEntry], - scroll: usize, - focused: bool, - following: bool, - ) { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let title = if following { - format!(" Logs ({}) [following] ", log_entries.len()) - } else { - format!(" Logs ({}) ", log_entries.len()) - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style); - - let inner_height = area.height.saturating_sub(2) as usize; - let start = scroll.min(log_entries.len().saturating_sub(inner_height)); - let visible_entries = log_entries - .iter() - .skip(start) - .take(inner_height); - - let lines: Vec = visible_entries - .map(|entry| { - let level_style = match entry.level { - LogLevel::Error => Style::default().fg(Color::Red).bold(), - LogLevel::Warn => Style::default().fg(Color::Yellow).bold(), - LogLevel::Info => Style::default().fg(Color::Green), - LogLevel::Debug => Style::default().fg(Color::Blue), - LogLevel::Trace => Style::default().fg(Color::DarkGray), - }; - - Line::from(vec![ - Span::styled( - format!("[{}] ", entry.level.as_str()), - level_style, - ), - Span::styled( - format!("{}: ", entry.target.split("::").last().unwrap_or(&entry.target)), - Style::default().fg(Color::DarkGray), - ), - Span::raw(&entry.message), - ]) - }) - .collect(); - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, area); - } - - fn render_setup_screen(frame: &mut Frame, area: Rect, message: &str, quit_confirm: bool) { - use ratatui::layout::Alignment; - - // Create a centered layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(30), - Constraint::Min(10), - Constraint::Length(1), - ]) - .split(area); - - // Title block - let title_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)) - .title(" playit.gg "); - - // Parse message into lines and style URLs differently - let lines: Vec = message - .lines() - .map(|line| { - if line.starts_with("http://") || line.starts_with("https://") { - Line::from(Span::styled( - line, - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - )) - } else if line.contains("https://") || line.contains("http://") { - // Line contains a URL somewhere - let mut spans = Vec::new(); - let mut remaining = line; - while let Some(pos) = remaining.find("https://").or_else(|| remaining.find("http://")) { - if pos > 0 { - spans.push(Span::styled(&remaining[..pos], Style::default().fg(Color::White))); - } - // Find end of URL (space or end of string) - let url_start = pos; - let url_end = remaining[pos..].find(' ').map(|p| pos + p).unwrap_or(remaining.len()); - spans.push(Span::styled( - &remaining[url_start..url_end], - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - )); - remaining = &remaining[url_end..]; - } - if !remaining.is_empty() { - spans.push(Span::styled(remaining, Style::default().fg(Color::White))); - } - Line::from(spans) - } else { - Line::from(Span::styled(line, Style::default().fg(Color::White))) - } - }) - .collect(); - - let paragraph = Paragraph::new(lines) - .block(title_block) - .alignment(Alignment::Center) - .wrap(Wrap { trim: false }); - - frame.render_widget(paragraph, chunks[1]); - - // Help bar - render_help_bar(frame, chunks[2], quit_confirm); - } - - /// Simple screen write for compatibility (used during setup) - pub async fn write_screen(&mut self, content: T) { - let signal = get_signal_handle(); - let exit_confirm = signal.is_confirming_close(); - - if exit_confirm { - match self - .yn_question( - format!("{}\nClose requested, close program?", content), - Some(true), - ) - .await - { - Ok(close) => { - if close { - std::process::exit(0); - } else { - signal.decline_close(); - } - } - Err(error) => { - tracing::error!(%error, "failed to ask close signal question"); - } - } - return; - } - - // Set the setup message and render - let message = content.to_string(); - tracing::info!("{}", message.lines().next().unwrap_or("")); - self.set_setup_message(message); - - // Initialize terminal if not already done - if self.terminal.is_none() { - if let Err(e) = self.init_terminal() { - tracing::error!(?e, "Failed to init terminal"); - return; - } - } - - // Draw the screen - if let Err(e) = self.draw() { - tracing::error!(?e, "Failed to draw screen"); - } - - // Handle any pending keyboard events (non-blocking) - if let Ok(true) = event::poll(Duration::from_millis(10)) { - if let Ok(Event::Key(key)) = event::read() { - self.handle_key_event(key); - } - } - - // Check if quit was requested - if self.should_quit { - let _ = self.restore_terminal(); - std::process::exit(0); - } - } - - pub async fn yn_question( - &mut self, - _question: T, - default_yes: Option, - ) -> Result { - // For TUI mode, we use the quit confirm mechanism - // For now, return the default if available - if let Some(default) = default_yes { - return Ok(default); - } - if let Some(auto) = self.settings.auto_answer { - return Ok(auto); - } - Err(CliError::AnswerNotProvided) - } -} - -impl Drop for TuiApp { - fn drop(&mut self) { - let _ = self.restore_terminal(); - } -} diff --git a/packages/agent_core/Cargo.toml b/packages/agent_core/Cargo.toml index b8f3d2d4..380eebb9 100644 --- a/packages/agent_core/Cargo.toml +++ b/packages/agent_core/Cargo.toml @@ -29,15 +29,7 @@ playit-api-client = { path = "../api_client", version = "0.2.0" } governor = "0.10.0" crossbeam = "0.8.4" slab = "0.4.9" +slotmap = "1.1.1" [dev-dependencies] tracing-subscriber = { workspace = true } - -# hyper = { version = "1.4", features = ["client", "http2", "http1"] } -# bytes = "1.7" -# time = "0.3.36" -# hyper-util = { version = "0.1.7", features = ["client", "client-legacy", "http1", "http2"] } -# http-body-util = "0.1.2" -# hyper-rustls = { version = "0.27", features = ["http2", "webpki-roots"] } - -# rustls = { version = "0.23", default-features = false, features = ["ring"] } diff --git a/packages/agent_core/examples/mtu_control.rs b/packages/agent_core/examples/mtu_control.rs new file mode 100644 index 00000000..896e4831 --- /dev/null +++ b/packages/agent_core/examples/mtu_control.rs @@ -0,0 +1,148 @@ +// Example: +// PLAYIT_API_URL="https://api.playit.gg" \ +// PLAYIT_SECRET_KEY="..." \ +// PLAYIT_MTU_DC_IDS="1,2" \ +// cargo run -p playit-agent-core --example mtu_control +// +// The example connects to control, sends a few MTU probes, waits briefly for +// responses, then prints both pending and committed MTU discovery state. +use std::env; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use playit_agent_core::agent_control::address_selector::AddressSelector; +use playit_agent_core::agent_control::{AuthApi, AuthResource, DualStackUdpSocket}; + +const CHECK_MTU_SIZES: [u32; 5] = [1200, 1300, 1400, 1420, 1480]; +const MTU_TEST_SIZES: [u32; 5] = [1200, 1300, 1400, 1420, 1450]; +const RECV_TIMEOUT: Duration = Duration::from_secs(5); +const RECV_POLL_INTERVAL: Duration = Duration::from_millis(500); + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("{error}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), String> { + let api_url = required_env("PLAYIT_API_URL")?; + let secret_key = required_env("PLAYIT_SECRET_KEY")?; + let data_center_ids = parse_data_center_ids()?; + + println!("creating udp io"); + let io = DualStackUdpSocket::new() + .await + .map_err(|error| format!("failed to create UDP socket: {error}"))?; + + println!("fetching control addresses"); + let auth = AuthApi::new(api_url, secret_key); + let control_addresses = auth + .get_control_addresses() + .await + .map_err(|error| format!("failed to fetch control addresses: {error:?}"))?; + + println!("connecting to control"); + let connected = AddressSelector::new(control_addresses, io) + .connect_to_first() + .await + .map_err(|error| format!("failed to connect to control: {error:?}"))?; + + println!("authenticating established control"); + let mut control = connected + .auth_into_established(auth) + .await + .map_err(|error| format!("failed to authenticate control: {error:?}"))?; + + control.clear_pending_mtu_data(); + + let mut next_request_id = time_id_seed(); + + println!("sending CheckMtuReceived probes"); + for message_size in CHECK_MTU_SIZES { + let request_id = take_next_id(&mut next_request_id); + let test_id = take_next_id(&mut next_request_id); + + control + .send_check_mtu_received(request_id, test_id, message_size) + .await + .map_err(|error| { + format!("failed to send CheckMtuReceived({message_size}): {error:?}") + })?; + } + + println!("sending SendMtuTest probes"); + for data_center_id in &data_center_ids { + for udp_payload_length in MTU_TEST_SIZES { + let request_id = take_next_id(&mut next_request_id); + let test_id = take_next_id(&mut next_request_id); + + control + .send_mtu_test(request_id, test_id, *data_center_id, udp_payload_length) + .await + .map_err(|error| { + format!( + "failed to send SendMtuTest(dc={data_center_id}, payload={udp_payload_length}): {error:?}" + ) + })?; + } + } + + println!("waiting up to {:?} for MTU responses", RECV_TIMEOUT); + let deadline = Instant::now() + RECV_TIMEOUT; + while Instant::now() < deadline { + match tokio::time::timeout(RECV_POLL_INTERVAL, control.recv_feed_msg()).await { + Ok(Ok(feed)) => println!("received feed: {feed:?}"), + Ok(Err(error)) => eprintln!("control receive error: {error:?}"), + Err(_) => {} + } + } + + println!("pending_mtu_data: {:#?}", control.pending_mtu_data()); + control.commit_pending_mtu_data(); + println!("known_mtu_data: {:#?}", control.known_mtu_data()); + + Ok(()) +} + +fn required_env(name: &str) -> Result { + env::var(name).map_err(|_| format!("missing required environment variable `{name}`")) +} + +fn parse_data_center_ids() -> Result, String> { + let raw = required_env("PLAYIT_MTU_DC_IDS")?; + let mut ids = Vec::new(); + + for part in raw.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + + let parsed = trimmed + .parse::() + .map_err(|error| format!("invalid data center id `{trimmed}`: {error}"))?; + ids.push(parsed); + } + + if ids.is_empty() { + return Err( + "`PLAYIT_MTU_DC_IDS` must contain at least one comma-separated data center id".into(), + ); + } + + Ok(ids) +} + +fn time_id_seed() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn take_next_id(next: &mut u64) -> u64 { + let current = *next; + *next = (*next).saturating_add(1); + current +} diff --git a/packages/agent_core/src/agent_control/address_selector.rs b/packages/agent_core/src/agent_control/address_selector.rs index 6500315c..01831dce 100644 --- a/packages/agent_core/src/agent_control/address_selector.rs +++ b/packages/agent_core/src/agent_control/address_selector.rs @@ -9,7 +9,7 @@ use playit_agent_proto::{ use crate::utils::now_milli; -use super::{connected_control::ConnectedControl, errors::SetupError, PacketIO}; +use super::{PacketIO, connected_control::ConnectedControl, errors::SetupError}; pub struct AddressSelector { options: Vec, diff --git a/packages/agent_core/src/agent_control/connected_control.rs b/packages/agent_core/src/agent_control/connected_control.rs index ea2447ca..d197f951 100644 --- a/packages/agent_core/src/agent_control/connected_control.rs +++ b/packages/agent_core/src/agent_control/connected_control.rs @@ -11,9 +11,9 @@ use playit_agent_proto::{ use crate::utils::now_milli; use super::{ - errors::{ControlError, SetupError}, - established_control::EstablishedControl, AuthResource, PacketIO, + errors::{ControlError, SetupError}, + established_control::{EstablishedControl, MtuData}, }; #[derive(Debug)] @@ -65,6 +65,8 @@ impl ConnectedControl { current_ping: None, clock_offset: 0, force_expired: false, + pending_mtu_data: MtuData::default(), + known_mtu_data: MtuData::default(), } } @@ -78,6 +80,8 @@ impl ConnectedControl { established.conn = self; established.current_ping = None; established.force_expired = false; + established.pending_mtu_data = MtuData::default(); + established.known_mtu_data = MtuData::default(); } pub async fn authenticate( diff --git a/packages/agent_core/src/agent_control/errors.rs b/packages/agent_core/src/agent_control/errors.rs index dd21b6e3..3d72b790 100644 --- a/packages/agent_core/src/agent_control/errors.rs +++ b/packages/agent_core/src/agent_control/errors.rs @@ -60,7 +60,7 @@ pub trait TryTimeoutHelper { type Error; fn try_timeout(self, max: Duration) - -> impl Future>; + -> impl Future>; } impl TimeoutHelper for F { diff --git a/packages/agent_core/src/agent_control/established_control.rs b/packages/agent_core/src/agent_control/established_control.rs index d5ce9f1b..1e8aec60 100644 --- a/packages/agent_core/src/agent_control/established_control.rs +++ b/packages/agent_core/src/agent_control/established_control.rs @@ -1,6 +1,9 @@ +use std::collections::BTreeMap; + use playit_agent_proto::control_feed::ControlFeed; use playit_agent_proto::control_messages::{ - AgentRegistered, ControlRequest, ControlResponse, Ping, Pong, + AgentRegistered, CheckMtuReceived, CheckMtuReceivedAck, ControlRequest, ControlResponse, + MtuTestFail, MtuTestFailCode, MtuTestPacket, Ping, Pong, SendMtuTest, }; use playit_agent_proto::rpc::ControlRpcMessage; @@ -18,6 +21,39 @@ pub struct EstablishedControl { pub(super) current_ping: Option, pub(super) clock_offset: i64, pub(super) force_expired: bool, + pub(super) pending_mtu_data: MtuData, + pub(super) known_mtu_data: MtuData, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MtuData { + pub largest_received_packet: Option, + pub largest_payload_by_datacenter: BTreeMap, + pub latest_test_fail: Option, +} + +impl MtuData { + fn record_check_mtu_received_ack(&mut self, ack: CheckMtuReceivedAck) { + let should_replace = match self.largest_received_packet.as_ref() { + Some(current) => ack.message_length >= current.message_length, + None => true, + }; + + if should_replace { + self.largest_received_packet = Some(ack); + } + } + + fn record_mtu_test_packet(&mut self, packet: MtuTestPacket) { + self.largest_payload_by_datacenter + .entry(packet.data_center_id) + .and_modify(|current| *current = (*current).max(packet.udp_payload_length)) + .or_insert(packet.udp_payload_length); + } + + fn record_mtu_test_fail(&mut self, fail: MtuTestFail) { + self.latest_test_fail = Some(fail); + } } impl EstablishedControl { @@ -49,6 +85,37 @@ impl EstablishedControl { .await } + pub async fn send_check_mtu_received( + &mut self, + request_id: u64, + id: u64, + message_size: u32, + ) -> Result<(), ControlError> { + self.send(ControlRpcMessage { + request_id, + content: ControlRequest::CheckMtuReceived(CheckMtuReceived { id, message_size }), + }) + .await + } + + pub async fn send_mtu_test( + &mut self, + request_id: u64, + id: u64, + data_center_id: u32, + udp_payload_length: u32, + ) -> Result<(), ControlError> { + self.send(ControlRpcMessage { + request_id, + content: ControlRequest::SendMtuTest(SendMtuTest { + id, + data_center_id, + udp_payload_length, + }), + }) + .await + } + pub fn get_expire_at(&self) -> u64 { self.registered.expires_at } @@ -70,6 +137,26 @@ impl EstablishedControl { self.force_expired = true; } + pub fn pending_mtu_data(&self) -> &MtuData { + &self.pending_mtu_data + } + + pub fn known_mtu_data(&self) -> &MtuData { + &self.known_mtu_data + } + + pub fn commit_pending_mtu_data(&mut self) { + self.known_mtu_data = self.pending_mtu_data.clone(); + } + + pub fn clear_pending_mtu_data(&mut self) { + self.pending_mtu_data = MtuData::default(); + } + + pub fn clear_known_mtu_data(&mut self) { + self.known_mtu_data = MtuData::default(); + } + fn flow_changed(&self) -> bool { self.conn.pong_latest.client_addr != self.pong_at_auth.client_addr || self.conn.pong_latest.tunnel_addr != self.pong_at_auth.tunnel_addr @@ -132,6 +219,16 @@ impl EstablishedControl { - rtt as u64; } } + ControlResponse::CheckMtuReceivedAck(ack) => { + self.pending_mtu_data + .record_check_mtu_received_ack(ack.clone()); + } + ControlResponse::MtuTestPacket(packet) => { + self.pending_mtu_data.record_mtu_test_packet(packet.clone()); + } + ControlResponse::MtuTestFail(fail) => { + self.pending_mtu_data.record_mtu_test_fail(fail.clone()); + } _ => {} } } @@ -146,3 +243,9 @@ pub enum ExpiredReason { SessionNotSetup, FlowChanged, } + +impl MtuData { + pub fn latest_test_fail_code(&self) -> Option { + self.latest_test_fail.as_ref().map(|fail| fail.error_code) + } +} diff --git a/packages/agent_core/src/agent_control/maintained_control.rs b/packages/agent_core/src/agent_control/maintained_control.rs index 3e106399..45342172 100644 --- a/packages/agent_core/src/agent_control/maintained_control.rs +++ b/packages/agent_core/src/agent_control/maintained_control.rs @@ -182,14 +182,14 @@ impl MaintainedControl { .await { Ok(Ok(ControlFeed::NewClient(new_client))) => { - return Some(TunnelControlEvent::NewClient(new_client)) + return Some(TunnelControlEvent::NewClient(new_client)); } Ok(Ok(ControlFeed::NewClientOld(new_client))) => { - return Some(TunnelControlEvent::NewClient(new_client.into())) + return Some(TunnelControlEvent::NewClient(new_client.into())); } Ok(Ok(ControlFeed::Response(msg))) => match msg.content { ControlResponse::UdpChannelDetails(details) => { - return Some(TunnelControlEvent::UdpChannelDetails(details)) + return Some(TunnelControlEvent::UdpChannelDetails(details)); } ControlResponse::Unauthorized => { tracing::info!("session no longer authorized"); diff --git a/packages/agent_core/src/lib.rs b/packages/agent_core/src/lib.rs index d7da0714..ca4bbb97 100644 --- a/packages/agent_core/src/lib.rs +++ b/packages/agent_core/src/lib.rs @@ -7,9 +7,3 @@ pub mod stats; pub mod utils; pub const PROTOCOL_VERSION: u64 = 2; - -#[cfg(test)] -mod test { - #[test] - fn test() {} -} diff --git a/packages/agent_core/src/network/lan_address.rs b/packages/agent_core/src/network/lan_address.rs index 4d20f7bc..0c338c8c 100644 --- a/packages/agent_core/src/network/lan_address.rs +++ b/packages/agent_core/src/network/lan_address.rs @@ -1,4 +1,4 @@ -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; use byteorder::{BigEndian, ByteOrder}; use tokio::net::{TcpSocket, TcpStream, UdpSocket}; @@ -20,12 +20,20 @@ impl LanAddress { match socket.bind(SocketAddrV4::new(local_ip, 0).into()) { Err(e) => { - tracing::warn!("Failed to bind connection to special local address to support IP based banning: {:?}", e); + tracing::warn!( + "Failed to bind connection to special local address to support IP based banning: {:?}", + e + ); } Ok(_) => { match socket.connect(host).await { Err(e) => { - tracing::warn!("Failed to establish connection using special lan {} for flow {:?} {:?}", local_ip, (peer, host), e); + tracing::warn!( + "Failed to establish connection using special lan {} for flow {:?} {:?}", + local_ip, + (peer, host), + e + ); } v => return v, }; @@ -50,6 +58,7 @@ impl LanAddress { pub async fn udp_socket( special_lan_ip: bool, peer: SocketAddr, + target: SocketAddr, tunnel_id: u64, ) -> std::io::Result { let ip_shuffle = shuffle_ip_to_u32(peer.ip()); @@ -70,22 +79,44 @@ impl LanAddress { Err(bad_port_error) => { match UdpSocket::bind(SocketAddrV4::new(local_ip, 0)).await { Ok(v) => { - tracing::warn!("Failed to bind UDP port to {} to have connections survive agent restart: {:?}", local_port, bad_port_error); + tracing::warn!( + "Failed to bind UDP port to {} to have connections survive agent restart: {:?}", + local_port, + bad_port_error + ); Ok(v) } Err(bad_local_ip_err) => { let v = UdpSocket::bind(SocketAddrV4::new(0.into(), 0)).await?; - tracing::warn!("Failed to bind UDP to special local address, in-game ip banning will not work: {:?}", bad_local_ip_err); + tracing::warn!( + "Failed to bind UDP to special local address, in-game ip banning will not work: {:?}", + bad_local_ip_err + ); Ok(v) } } } } } else { - match UdpSocket::bind(SocketAddrV4::new(0.into(), local_port)).await { + let bind_addr = match target { + SocketAddr::V4(_) => { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, local_port)) + } + SocketAddr::V6(_) => { + SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, local_port, 0, 0)) + } + }; + let fallback_addr = match target { + SocketAddr::V4(_) => SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)), + SocketAddr::V6(_) => { + SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0)) + } + }; + + match UdpSocket::bind(bind_addr).await { Ok(v) => Ok(v), Err(bad_port_error) => { - let v = UdpSocket::bind(SocketAddrV4::new(0.into(), 0)).await?; + let v = UdpSocket::bind(fallback_addr).await?; tracing::warn!("Failed to bind UDP to special port: {:?}", bad_port_error); Ok(v) } diff --git a/packages/agent_core/src/network/origin_lookup.rs b/packages/agent_core/src/network/origin_lookup.rs index 6e48b415..ae79d11e 100644 --- a/packages/agent_core/src/network/origin_lookup.rs +++ b/packages/agent_core/src/network/origin_lookup.rs @@ -1,11 +1,13 @@ use std::{ collections::HashMap, + fmt::Display, net::{IpAddr, SocketAddr}, str::FromStr, }; use playit_agent_proto::PortProto; use playit_api_client::api::{AgentRunDataV1, AgentTunnelV1, PortType, ProxyProtocol, TunnelType}; +use tokio::net::lookup_host; use tokio::sync::RwLock; #[derive(Default)] @@ -25,13 +27,12 @@ impl OriginLookup { } pub async fn update>(&self, resources: I) { - let mut lock = self.map.write().await; - lock.clear(); + let mut next = HashMap::new(); for res in resources { match res.proto { PortProto::Tcp => { - lock.insert( + next.insert( Key { tunnel_id: res.tunnel_id, is_tcp: true, @@ -40,7 +41,7 @@ impl OriginLookup { ); } PortProto::Udp => { - lock.insert( + next.insert( Key { tunnel_id: res.tunnel_id, is_tcp: false, @@ -49,14 +50,14 @@ impl OriginLookup { ); } PortProto::Both => { - lock.insert( + next.insert( Key { tunnel_id: res.tunnel_id, is_tcp: true, }, res.clone(), ); - lock.insert( + next.insert( Key { tunnel_id: res.tunnel_id, is_tcp: false, @@ -66,6 +67,9 @@ impl OriginLookup { } } } + + let mut lock = self.map.write().await; + *lock = next; } pub async fn lookup(&self, tunnel_id: u64, is_tcp: bool) -> Option { @@ -92,20 +96,74 @@ pub struct OriginResource { pub proxy_protocol: Option, } +#[derive(Debug, Clone)] +pub enum OriginIp { + IpAddress(IpAddr), + Hostname(String), +} + +impl Display for OriginIp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OriginIp::IpAddress(ip) => write!(f, "{ip}"), + OriginIp::Hostname(host) => write!(f, "{host}"), + } + } +} + +impl OriginIp { + async fn resolve(&self, port: u16) -> Option { + match self { + OriginIp::IpAddress(ip) => Some(SocketAddr::new(*ip, port)), + OriginIp::Hostname(hostname) => { + let mut addrs = match lookup_host((hostname.as_str(), port)).await { + Ok(addrs) => addrs, + Err(error) => { + tracing::error!( + ?error, + %hostname, + port, + "failed to resolve origin hostname" + ); + return None; + } + }; + + addrs.next() + } + } + } +} + #[derive(Debug, Clone)] pub enum OriginTarget { Https { - ip: IpAddr, + ip: OriginIp, http_port: u16, https_port: u16, }, Port { - ip: IpAddr, + ip: OriginIp, port: u16, }, } impl OriginResource { + fn parse_origin_ip(tunn: &AgentTunnelV1) -> OriginIp { + tunn.agent_config + .fields + .iter() + .find(|f| f.name.eq("local_ip")) + .map(|v| v.value.trim()) + .filter(|v| !v.is_empty()) + .map(|value| { + IpAddr::from_str(value) + .map(OriginIp::IpAddress) + .unwrap_or_else(|_| OriginIp::Hostname(value.to_owned())) + }) + .unwrap_or_else(|| OriginIp::IpAddress("127.0.0.1".parse().unwrap())) + } + pub fn from_agent_tunnel(tunn: &AgentTunnelV1) -> Option { let tunnel_type = tunn .tunnel_type @@ -124,13 +182,7 @@ impl OriginResource { let target = match tunnel_type { Some(TunnelType::Https) => OriginTarget::Https { - ip: tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("local_ip")) - .and_then(|v| IpAddr::from_str(&v.value).ok()) - .unwrap_or_else(|| "127.0.0.1".parse().unwrap()), + ip: Self::parse_origin_ip(tunn), http_port: tunn .agent_config .fields @@ -163,13 +215,7 @@ impl OriginResource { })?; OriginTarget::Port { - ip: tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("local_ip")) - .and_then(|v| IpAddr::from_str(&v.value).ok()) - .unwrap_or_else(|| "127.0.0.1".parse().unwrap()), + ip: Self::parse_origin_ip(tunn), port: local_port, } } @@ -188,7 +234,7 @@ impl OriginResource { }) } - pub fn resolve_local(&self, port_offset: u16) -> Option { + pub async fn resolve_local(&self, port_offset: u16) -> Option { match &self.target { OriginTarget::Https { ip, @@ -196,24 +242,102 @@ impl OriginResource { https_port, } => { if port_offset == 0 { - Some(SocketAddr::new(*ip, *http_port)) + ip.resolve(*http_port).await } else if port_offset == 1 { - Some(SocketAddr::new(*ip, *https_port)) + ip.resolve(*https_port).await } else { None } } OriginTarget::Port { ip, port } => { if self.port_count == 0 { - return Some(SocketAddr::new(*ip, *port)); + return ip.resolve(*port).await; } if self.port_count <= port_offset { return None; } - Some(SocketAddr::new(*ip, *port + port_offset)) + let resolved_port = port.checked_add(port_offset)?; + ip.resolve(resolved_port).await } } } } + +#[cfg(test)] +mod tests { + use super::*; + use playit_api_client::api::{AgentTunnelAttr, AgentTunnelConfig}; + use uuid::Uuid; + + fn build_tunnel( + tunnel_type: Option<&str>, + local_ip: &str, + local_port: Option<&str>, + port_type: PortType, + port_count: u16, + ) -> AgentTunnelV1 { + let mut fields = vec![AgentTunnelAttr { + name: "local_ip".to_owned(), + value: local_ip.to_owned(), + }]; + + if let Some(local_port) = local_port { + fields.push(AgentTunnelAttr { + name: "local_port".to_owned(), + value: local_port.to_owned(), + }); + } + + AgentTunnelV1 { + id: Uuid::nil(), + internal_id: 7, + name: "test".to_owned(), + display_address: "public.example:25565".to_owned(), + port_type, + port_count, + tunnel_type: tunnel_type.map(str::to_owned), + tunnel_type_display: "test".to_owned(), + agent_config: AgentTunnelConfig { fields }, + disabled_reason: None, + } + } + + #[test] + fn from_agent_tunnel_preserves_hostname_target() { + let tunnel = build_tunnel(None, "origin.internal", Some("25565"), PortType::Tcp, 0); + + let resource = OriginResource::from_agent_tunnel(&tunnel).expect("resource"); + + match resource.target { + OriginTarget::Port { + ip: OriginIp::Hostname(hostname), + port, + } => { + assert_eq!(hostname, "origin.internal"); + assert_eq!(port, 25565); + } + target => panic!("unexpected target: {target:?}"), + } + } + + #[tokio::test] + async fn resolve_local_supports_hostname_lookup() { + let resource = OriginResource { + tunnel_id: 1, + proto: PortProto::Tcp, + target: OriginTarget::Port { + ip: OriginIp::Hostname("localhost".to_owned()), + port: 8080, + }, + port_count: 0, + proxy_protocol: None, + }; + + let resolved = resource.resolve_local(0).await.expect("resolved"); + + assert_eq!(resolved.port(), 8080); + assert!(resolved.ip().is_loopback()); + } +} diff --git a/packages/agent_core/src/network/tcp/tcp_client.rs b/packages/agent_core/src/network/tcp/tcp_client.rs index d954b049..58caa407 100644 --- a/packages/agent_core/src/network/tcp/tcp_client.rs +++ b/packages/agent_core/src/network/tcp/tcp_client.rs @@ -16,7 +16,11 @@ impl TcpClient { Self::create_with_stats(tunn, origin, None).await } - pub async fn create_with_stats(tunn: TcpStream, origin: TcpStream, stats: Option) -> Self { + pub async fn create_with_stats( + tunn: TcpStream, + origin: TcpStream, + stats: Option, + ) -> Self { let (tunn_read, tunn_write) = tunn.into_split(); let (origin_read, origin_write) = origin.into_split(); diff --git a/packages/agent_core/src/network/tcp/tcp_clients.rs b/packages/agent_core/src/network/tcp/tcp_clients.rs index a2f168c8..47443ace 100644 --- a/packages/agent_core/src/network/tcp/tcp_clients.rs +++ b/packages/agent_core/src/network/tcp/tcp_clients.rs @@ -7,7 +7,7 @@ use serde::Serialize; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, - sync::mpsc::{channel, Receiver, Sender}, + sync::mpsc::{Receiver, Sender, channel}, time::Instant, }; use tokio_util::sync::CancellationToken; @@ -26,6 +26,19 @@ use super::{ tcp_settings::TcpSettings, }; +fn build_quota(settings: &TcpSettings) -> Quota { + let rate = NonZeroU32::new(settings.new_client_ratelimit).unwrap_or_else(|| { + tracing::warn!("invalid tcp new client rate limit of 0, clamping to 1"); + NonZeroU32::MIN + }); + let burst = NonZeroU32::new(settings.new_client_ratelimit_burst).unwrap_or_else(|| { + tracing::warn!("invalid tcp new client burst of 0, clamping to 1"); + NonZeroU32::MIN + }); + + Quota::per_second(rate).allow_burst(burst) +} + pub struct TcpClients { events_tx: Sender, new_client_limiter: DefaultDirectRateLimiter, @@ -92,15 +105,14 @@ enum Event { } impl TcpClients { - pub fn new(settings: TcpSettings, lookup: Arc, stats: AgentStats) -> Self { - let quota = unsafe { - Quota::per_second(NonZeroU32::new_unchecked(settings.new_client_ratelimit)).allow_burst( - NonZeroU32::new_unchecked(settings.new_client_ratelimit_burst), - ) - }; - + pub fn new( + settings: TcpSettings, + lookup: Arc, + stats: AgentStats, + cancel: CancellationToken, + ) -> Self { + let quota = build_quota(&settings); let (events_tx, events_rx) = channel(1024); - let cancel = CancellationToken::new(); tokio::spawn( Worker { @@ -108,7 +120,7 @@ impl TcpClients { lookup, events: events_rx, events_tx: events_tx.clone(), - cancel: cancel.clone(), + cancel: cancel.child_token(), settings, stats, clients: Vec::with_capacity(32), @@ -157,7 +169,13 @@ impl Worker { loop { let event = tokio::select! { - recv_opt = self.events.recv() => recv_opt.unwrap(), + recv_opt = self.events.recv() => { + let Some(event) = recv_opt else { + tracing::info!("TcpClients worker closed because event channel closed"); + break; + }; + event + }, _ = tokio::time::sleep_until(next_clear) => { next_clear = Instant::now() + Duration::from_secs(15); Event::ClearOld @@ -202,34 +220,40 @@ impl Worker { } } _ => { - tracing::error!("Tunnel server provide miss match protol versions for peer and connect addr"); + tracing::error!( + "Tunnel server provide miss match protol versions for peer and connect addr" + ); tcp_errors().invalid_proto_match.inc(); continue; } }; - let Some(origin_addr) = found.resolve_local(details.port_offset) else { - tracing::error!( - port_offset = details.port_offset, - tunnel_id = details.tunnel_id, - "port offset not valid for tunnel" - ); - tcp_errors().new_client_invalid_port_offset.inc(); - continue; - }; - let setting_tcp_no_delay = self.settings.tcp_no_delay; let event_tx = self.events_tx.clone(); let stats = self.stats.clone(); + let cancel = self.cancel.child_token(); tokio::spawn(async move { + let Some(origin_addr) = found.resolve_local(details.port_offset).await + else { + tracing::error!( + port_offset = details.port_offset, + tunnel_id = details.tunnel_id, + "port offset not valid for tunnel" + ); + tcp_errors().new_client_invalid_port_offset.inc(); + return; + }; + /* connect to tunnel server */ - let conn_res = tokio::time::timeout( - Duration::from_secs(8), - TcpStream::connect(details.claim_instructions.address), - ) - .await; + let conn_res = tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(8), + TcpStream::connect(details.claim_instructions.address), + ) => res, + }; let mut tunn_stream = match conn_res { Ok(Ok(stream)) => stream, @@ -256,11 +280,13 @@ impl Worker { /* send token to tunnel server to claim client */ - let send_res = tokio::time::timeout( - Duration::from_secs(8), - tunn_stream.write_all(&details.claim_instructions.token), - ) - .await; + let send_res = tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(8), + tunn_stream.write_all(&details.claim_instructions.token), + ) => res, + }; match send_res { Ok(Ok(_)) => {} Err(_) => { @@ -279,11 +305,13 @@ impl Worker { } let mut expect_buffer = [0u8; 8]; - let confirm_res = tokio::time::timeout( - Duration::from_secs(4), - tunn_stream.read_exact(&mut expect_buffer[..]), - ) - .await; + let confirm_res = tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(4), + tunn_stream.read_exact(&mut expect_buffer[..]), + ) => res, + }; match confirm_res { Ok(Ok(_)) => {} Err(_) => { @@ -300,11 +328,13 @@ impl Worker { /* connect to origin */ - let connect_res = tokio::time::timeout( - Duration::from_secs(2), - LanAddress::tcp_socket(true, details.peer_addr, origin_addr), - ) - .await; + let connect_res = tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(2), + LanAddress::tcp_socket(true, details.peer_addr, origin_addr), + ) => res, + }; let mut origin_stream = match connect_res { Ok(Ok(stream)) => stream, @@ -331,18 +361,22 @@ impl Worker { let proxy_write_res = match found.proxy_protocol { Some(ProxyProtocol::ProxyProtocolV1) => { - tokio::time::timeout( - Duration::from_secs(2), - proxy_header.write_v1_tcp(&mut origin_stream), - ) - .await + tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(2), + proxy_header.write_v1_tcp(&mut origin_stream), + ) => res, + } } Some(ProxyProtocol::ProxyProtocolV2) => { - tokio::time::timeout( - Duration::from_secs(2), - proxy_header.write_v2_tcp(&mut origin_stream), - ) - .await + tokio::select! { + _ = cancel.cancelled() => return, + res = tokio::time::timeout( + Duration::from_secs(2), + proxy_header.write_v2_tcp(&mut origin_stream), + ) => res, + } } None => Ok(Ok(())), }; @@ -361,19 +395,23 @@ impl Worker { } } - let tcp_client = TcpClient::create_with_stats(tunn_stream, origin_stream, Some(stats)).await; - let _ = event_tx - .send(Event::ConnectedClient(Client { - id: client_id, - added_at: now_milli(), - tunnel_id: details.tunnel_id, - port_offset: details.port_offset, - source_addr: details.peer_addr, - tunnel_addr: details.connect_addr, - origin_addr, - tcp: tcp_client, - })) - .await; + let tcp_client = + TcpClient::create_with_stats(tunn_stream, origin_stream, Some(stats)) + .await; + let event = Event::ConnectedClient(Client { + id: client_id, + added_at: now_milli(), + tunnel_id: details.tunnel_id, + port_offset: details.port_offset, + source_addr: details.peer_addr, + tunnel_addr: details.connect_addr, + origin_addr, + tcp: tcp_client, + }); + let _ = tokio::select! { + _ = cancel.cancelled() => return, + res = event_tx.send(event) => res, + }; }); } Event::GetDetails(resp) => { diff --git a/packages/agent_core/src/network/tcp/tcp_pipe.rs b/packages/agent_core/src/network/tcp/tcp_pipe.rs index 59b5b0f3..14640ec9 100644 --- a/packages/agent_core/src/network/tcp/tcp_pipe.rs +++ b/packages/agent_core/src/network/tcp/tcp_pipe.rs @@ -1,6 +1,6 @@ use std::sync::{ - atomic::{AtomicU64, Ordering}, Arc, + atomic::{AtomicU64, Ordering}, }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -9,6 +9,8 @@ use tokio_util::sync::CancellationToken; use crate::stats::AgentStats; use crate::utils::now_milli; +const TCP_PIPE_BUFFER_SIZE: usize = 16 * 1024; + /// Direction of data flow for stats tracking #[derive(Clone, Copy)] pub enum PipeDirection { @@ -86,11 +88,7 @@ impl TcpPipe { pub fn last_activity(&self) -> u64 { let value = self.shared.last_activity.load(Ordering::Acquire); - if value == u64::MAX { - 0 - } else { - value - } + if value == u64::MAX { 0 } else { value } } pub fn is_closed(&self) -> bool { @@ -119,10 +117,17 @@ struct Worker { impl Worker { pub async fn start(mut self) { - let mut buffer = vec![0u8; 2048]; + let mut buffer = vec![0u8; TCP_PIPE_BUFFER_SIZE]; loop { - tokio::task::yield_now().await; + // Keep the pipe cooperative when both sockets stay continuously ready. + tokio::select! { + _ = self.cancel.cancelled() => { + tracing::info!("TcpPipe cancelled"); + break; + } + _ = tokio::task::yield_now() => {} + } let Some(read_res) = self .cancel diff --git a/packages/agent_core/src/network/udp/packets.rs b/packages/agent_core/src/network/udp/packets.rs index 720b1eb4..2830cb22 100644 --- a/packages/agent_core/src/network/udp/packets.rs +++ b/packages/agent_core/src/network/udp/packets.rs @@ -1,5 +1,5 @@ use std::{ - sync::{atomic::Ordering, Arc}, + sync::{Arc, atomic::Ordering}, task::{Poll, Waker}, }; diff --git a/packages/agent_core/src/network/udp/udp_channel.rs b/packages/agent_core/src/network/udp/udp_channel.rs index f520085b..c92e731c 100644 --- a/packages/agent_core/src/network/udp/udp_channel.rs +++ b/packages/agent_core/src/network/udp/udp_channel.rs @@ -1,7 +1,8 @@ use std::{ + net::SocketAddr, sync::{ + Arc, RwLock, atomic::{AtomicU64, Ordering}, - Arc, }, time::Duration, }; @@ -9,9 +10,9 @@ use tokio::time::Instant; use playit_agent_proto::{ control_messages::UdpChannelDetails, - udp_proto::{UdpFlow, UDP_CHANNEL_ESTABLISH_ID}, + udp_proto::{UDP_CHANNEL_ESTABLISH_ID, UdpFlow}, }; -use tokio::sync::mpsc::{channel, Receiver, Sender}; +use tokio::sync::mpsc::{Receiver, Sender, channel}; use crate::{ agent_control::{DualStackUdpSocket, PacketIO}, @@ -34,39 +35,49 @@ pub struct UdpChannel { struct Shared { establish_rx_epoch: AtomicU64, establish_tx_epoch: AtomicU64, + session_tunnel_addr: RwLock>, } -struct Task { - socket: DualStackUdpSocket, +struct SendTask { + socket: Arc, session: Option, session_rx: Receiver, + send_rx: Receiver<(UdpFlow, Packet)>, + shared: Arc, +} +struct RecvTask { + socket: Arc, packets: Packets, - - send_rx: Receiver<(UdpFlow, Packet)>, recv_tx: Sender<(UdpFlow, Packet)>, - shared: Arc, } impl UdpChannel { pub async fn new(packets: Packets) -> Result { - let socket = DualStackUdpSocket::new().await?; + let socket = Arc::new(DualStackUdpSocket::new().await?); let (session_tx, session_rx) = channel(32); let (send_tx, send_rx) = channel(1024); - let (recv_tx, recv_rx) = channel(1024); + let (recv_tx, recv_rx) = channel(4096); let shared = Arc::new(Shared::default()); tokio::spawn( - Task { - socket, + SendTask { + socket: socket.clone(), session: None, session_rx, - packets, send_rx, + shared: shared.clone(), + } + .start(), + ); + tokio::spawn( + RecvTask { + socket, + packets, recv_tx, shared: shared.clone(), } @@ -114,9 +125,8 @@ impl UdpChannel { } } -impl Task { +impl SendTask { async fn start(mut self) { - let mut packet = self.packets.allocate_wait().await; let mut last_establish_send = Instant::now(); loop { @@ -140,7 +150,7 @@ impl Task { } }; - let recv_res = tokio::select! { + tokio::select! { _ = tokio::time::sleep_until(next_send) => { last_establish_send = Instant::now(); self.send_establish().await; @@ -156,52 +166,7 @@ impl Task { self.send(flow, to_send).await; continue; } - recv_res = self.socket.recv_from(packet.full_slice_mut()) => recv_res, }; - - let Ok((bytes, source)) = recv_res else { - udp_errors().recv_io_error.inc(); - tokio::time::sleep(Duration::from_millis(20)).await; - continue; - }; - - let Some(session) = self.session.as_ref() else { - udp_errors().recv_with_no_session.inc(); - continue; - }; - - if session.tunnel_addr != source { - udp_errors().recv_source_no_match.inc(); - continue; - } - - packet.set_len(bytes).expect("failed to update packet len"); - let flow = match UdpFlow::from_tail(packet.as_ref()) { - Ok(flow) => flow, - Err(Some(footer)) if footer == UDP_CHANNEL_ESTABLISH_ID => { - self.shared - .establish_rx_epoch - .store(now_milli(), Ordering::Release); - continue; - } - Err(id) => { - if id.is_none() { - udp_errors().recv_too_small.inc(); - } else { - udp_errors().recv_invalid_footer_id.inc(); - } - continue; - } - }; - - packet - .set_len(bytes - flow.footer_len()) - .expect("failed to remove udp footer"); - - if self.recv_tx.send((flow, packet)).await.is_err() { - break; - } - packet = self.packets.allocate_wait().await } } @@ -212,11 +177,14 @@ impl Task { if old != details { true } else { - 5_000 < now_milli().saturating_sub(self.shared.establish_rx_epoch.load(Ordering::Relaxed)) + 5_000 + < now_milli() + .saturating_sub(self.shared.establish_rx_epoch.load(Ordering::Relaxed)) } } }; + *self.shared.session_tunnel_addr.write().unwrap() = Some(details.tunnel_addr); self.session = Some(details); if should_send { self.send_establish().await; @@ -270,3 +238,55 @@ impl Task { } } } + +impl RecvTask { + async fn start(self) { + let mut packet = self.packets.allocate_wait().await; + + loop { + let Ok((bytes, source)) = self.socket.recv_from(packet.full_slice_mut()).await else { + udp_errors().recv_io_error.inc(); + tokio::time::sleep(Duration::from_millis(20)).await; + continue; + }; + + let Some(session_addr) = *self.shared.session_tunnel_addr.read().unwrap() else { + udp_errors().recv_with_no_session.inc(); + continue; + }; + + if session_addr != source { + udp_errors().recv_source_no_match.inc(); + continue; + } + + packet.set_len(bytes).expect("failed to update packet len"); + let flow = match UdpFlow::from_tail(packet.as_ref()) { + Ok(flow) => flow, + Err(Some(footer)) if footer == UDP_CHANNEL_ESTABLISH_ID => { + self.shared + .establish_rx_epoch + .store(now_milli(), Ordering::Release); + continue; + } + Err(id) => { + if id.is_none() { + udp_errors().recv_too_small.inc(); + } else { + udp_errors().recv_invalid_footer_id.inc(); + } + continue; + } + }; + + packet + .set_len(bytes - flow.footer_len()) + .expect("failed to remove udp footer"); + + if self.recv_tx.send((flow, packet)).await.is_err() { + break; + } + packet = self.packets.allocate_wait().await; + } + } +} diff --git a/packages/agent_core/src/network/udp/udp_clients.rs b/packages/agent_core/src/network/udp/udp_clients.rs index 40ce9420..d99184cf 100644 --- a/packages/agent_core/src/network/udp/udp_clients.rs +++ b/packages/agent_core/src/network/udp/udp_clients.rs @@ -1,8 +1,11 @@ use std::{ - collections::{HashMap, hash_map}, - net::{IpAddr, SocketAddr, SocketAddrV4}, + collections::HashMap, + net::{SocketAddr, SocketAddrV4}, num::NonZeroU32, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU32, Ordering}, + }, }; use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; @@ -14,9 +17,7 @@ use tokio::{ }; use crate::network::{ - lan_address::LanAddress, - origin_lookup::{OriginLookup, OriginTarget}, - proxy_protocol::ProxyProtocolHeader, + lan_address::LanAddress, origin_lookup::OriginLookup, proxy_protocol::ProxyProtocolHeader, }; use crate::stats::AgentStats; use playit_agent_proto::udp_proto::UdpFlow; @@ -33,6 +34,7 @@ pub struct UdpClients { virtual_client_lookup: HashMap, virtual_clients: Slab, + next_client_generation: AtomicU32, setup: UdpReceiverSetup, rx: Receiver, @@ -44,10 +46,12 @@ struct Client { id: u64, key: UdpClientKey, socket: Arc, + target_addr: SocketAddr, + port_offset: u16, flow: UdpFlow, /* when dropped, rx task get killed */ - _receiver: UdpReceiver, + receiver: UdpReceiver, from_tunnel_ts: u64, from_origin_ts: u64, @@ -60,41 +64,71 @@ struct UdpClientKey { } impl UdpClientKey { - pub async fn create_socket(&self, special_lan: bool) -> std::io::Result { - LanAddress::udp_socket(special_lan, self.source_addr, self.tunnel_id).await + pub async fn create_socket( + &self, + special_lan: bool, + target_addr: SocketAddr, + ) -> std::io::Result { + LanAddress::udp_socket(special_lan, self.source_addr, target_addr, self.tunnel_id).await } } +fn build_quota(settings: &UdpSettings) -> Quota { + let rate = NonZeroU32::new(settings.new_client_ratelimit).unwrap_or_else(|| { + tracing::warn!("invalid udp new client rate limit of 0, clamping to 1"); + NonZeroU32::MIN + }); + let burst = NonZeroU32::new(settings.new_client_ratelimit_burst).unwrap_or_else(|| { + tracing::warn!("invalid udp new client burst of 0, clamping to 1"); + NonZeroU32::MIN + }); + + Quota::per_second(rate).allow_burst(burst) +} + +fn pack_client_id(slot: u32, generation: u32) -> u64 { + ((generation as u64) << 32) | (slot as u64) +} + +fn unpack_slot(id: u64) -> usize { + (id as u32) as usize +} + impl UdpClients { - pub fn new(settings: UdpSettings, lookup: Arc, packets: Packets, stats: AgentStats) -> Self { + pub fn new( + settings: UdpSettings, + lookup: Arc, + packets: Packets, + stats: AgentStats, + ) -> Self { let (origin_tx, origin_rx) = channel(2048); - let quota = unsafe { - Quota::per_second(NonZeroU32::new_unchecked(settings.new_client_ratelimit)).allow_burst( - NonZeroU32::new_unchecked(settings.new_client_ratelimit_burst), - ) - }; - UdpClients { lookup, virtual_client_lookup: HashMap::new(), virtual_clients: Slab::with_capacity(2048), + next_client_generation: AtomicU32::new(1), setup: UdpReceiverSetup { output: origin_tx, packets, }, rx: origin_rx, - new_client_limiter: RateLimiter::direct(quota), + new_client_limiter: RateLimiter::direct(build_quota(&settings)), stats, } } - pub fn clear_old(&mut self, now_ms: u64) { - self.virtual_clients.retain(|slot, client| { + pub async fn clear_old(&mut self, now_ms: u64) { + let mut to_remove = Vec::new(); + + for (slot, client) in self.virtual_clients.iter_mut() { + let receiver_closed = client.receiver.is_closed(); let since_origin = now_ms.saturating_sub(client.from_origin_ts); let since_tunnel = now_ms.saturating_sub(client.from_tunnel_ts); let remove = { + receiver_closed + || /* both haven't seen action in over 1m */ 60_000 < since_tunnel && 60_000 < since_origin /* either side has no traffic in 1.5m */ @@ -103,14 +137,19 @@ impl UdpClients { }; if remove { - let removed = self.virtual_client_lookup.remove(&client.key).unwrap(); - assert_eq!(removed, slot); - - false - } else { - true + to_remove.push(slot); } - }); + } + + to_remove.sort_unstable(); + to_remove.reverse(); + + for slot in to_remove { + let client = self.virtual_clients.remove(slot); + let removed = self.virtual_client_lookup.remove(&client.key).unwrap(); + assert_eq!(removed, slot); + client.receiver.shutdown().await; + } // Update active UDP count self.stats.set_udp(self.virtual_clients.len() as u32); @@ -128,7 +167,7 @@ impl UdpClients { now_ms: u64, packet: UdpReceivedPacket, ) -> Option<(UdpFlow, Packet)> { - let Some(client) = self.virtual_clients.get_mut((packet.rx_id as u32) as usize) else { + let Some(client) = self.virtual_clients.get_mut(unpack_slot(packet.rx_id)) else { udp_errors().origin_client_missing.inc(); return None; }; @@ -138,39 +177,12 @@ impl UdpClients { return None; } - let Some(tunnel) = self.lookup.lookup(client.key.tunnel_id, false).await else { - udp_errors().origin_tunnel_not_found.inc(); - return None; - }; - - let SocketAddr::V4(source) = packet.from else { - udp_errors().origin_source_not_ip4.inc(); - return None; - }; - - let OriginTarget::Port { - ip: local_ip, - port: port_start, - } = tunnel.target - else { - return None; - }; - - if local_ip != IpAddr::V4(*source.ip()) { + if packet.from != client.target_addr { udp_errors().origin_reject_addr_differ.inc(); return None; } - if source.port() < port_start { - udp_errors().origin_reject_port_too_low.inc(); - return None; - } - - let port_offset = source.port() - port_start; - if tunnel.port_count <= port_offset { - udp_errors().origin_reject_port_too_high.inc(); - return None; - } + let port_offset = client.port_offset; client.from_origin_ts = now_ms; @@ -215,134 +227,140 @@ impl UdpClients { tunnel_id: extension.tunnel_id.get(), }; - let OriginTarget::Port { - ip: local_ip, - port: port_start, - } = origin.target - else { - return; - }; - - let target_addr = { - let IpAddr::V4(ip) = local_ip else { - return; - }; - SocketAddrV4::new(ip, port_start + extension.port_offset) - }; - // Track bytes coming in (from tunnel to origin) let packet_len = packet.len() as u64; - self.stats.add_bytes_in(packet_len); - match self.virtual_client_lookup.entry(key) { - hash_map::Entry::Occupied(o) => { - let slot = *o.get(); + if let Some(&slot) = self.virtual_client_lookup.get(&key) { + let receiver_closed = self + .virtual_clients + .get_mut(slot) + .map(|client| client.receiver.is_closed()) + .unwrap_or(true); + if !receiver_closed { let client = self.virtual_clients.get_mut(slot).unwrap(); client.from_tunnel_ts = now_ms; if client .socket - .send_to(packet.as_ref(), target_addr) + .send_to(packet.as_ref(), client.target_addr) .await .is_err() { udp_errors().origin_send_io_error.inc(); } + + self.stats.add_bytes_in(packet_len); + return; } - hash_map::Entry::Vacant(v) => { - if self.new_client_limiter.check().is_err() { - udp_errors().new_client_ratelimit.inc(); - return; - } - let special_lan = local_ip.is_loopback() && origin.proxy_protocol.is_none(); - - let socket = match v.key().create_socket(special_lan).await { - Ok(socket) => Arc::new(socket), - Err(error) => { - tracing::error!(?error, "failed to create socket"); - return; - } - }; - - let entry = self.virtual_clients.vacant_entry(); - let slot = entry.key(); - let id = slot as u64; - - let receiver = self.setup.create(id, socket.clone()); - - let mut client_flow = flow.flip(); - match &mut client_flow { - UdpFlow::V4 { - src, - extension: Some(ext), - .. - } => { - if extension.port_offset != 0 { - assert!(extension.port_offset <= src.port()); - *src = SocketAddrV4::new(*src.ip(), src.port() - extension.port_offset); - } - ext.port_offset = 0; - } - UdpFlow::V6 { - src, - extension: Some(ext), - .. - } => { - if extension.port_offset != 0 { - assert!(extension.port_offset <= src.1); - src.1 -= extension.port_offset; - } - ext.port_offset = 0; - } - _ => unreachable!(), - } + self.virtual_client_lookup.remove(&key); + if self.virtual_clients.get(slot).is_some() { + self.virtual_clients.remove(slot); + } + self.stats.set_udp(self.virtual_clients.len() as u32); + } - let key = v.key().clone(); - - let client = Client { - id, - key, - socket, - _receiver: receiver, - flow: client_flow, - from_tunnel_ts: now_ms, - from_origin_ts: now_ms, - }; - - if let Some(proto) = origin.proxy_protocol { - if proto != ProxyProtocol::ProxyProtocolV2 { - udp_errors().origin_v1_proxy_protocol.inc(); - } else { - let header = ProxyProtocolHeader::from_udp_flow(&flow); - - let mut buffer = Vec::new(); - header - .write_v2_udp(&mut buffer) - .expect("Failed to write proxy proto header to Vec"); - - if client.socket.send_to(&buffer, target_addr).await.is_err() { - udp_errors().origin_send_io_error.inc(); - } - } - } + if self.new_client_limiter.check().is_err() { + udp_errors().new_client_ratelimit.inc(); + return; + } - if client - .socket - .send_to(packet.as_ref(), target_addr) - .await - .is_err() - { - udp_errors().origin_send_io_error.inc(); + let Some(target_addr) = origin.resolve_local(extension.port_offset).await else { + return; + }; + + let special_lan = matches!(target_addr, SocketAddr::V4(addr) if addr.ip().is_loopback()) + && origin.proxy_protocol.is_none(); + + let socket = match key.create_socket(special_lan, target_addr).await { + Ok(socket) => Arc::new(socket), + Err(error) => { + tracing::error!(?error, "failed to create socket"); + return; + } + }; + + let entry = self.virtual_clients.vacant_entry(); + let slot = entry.key(); + let slot = u32::try_from(slot).expect("udp client slot overflow"); + let generation = self.next_client_generation.fetch_add(1, Ordering::Relaxed); + let id = pack_client_id(slot, generation); + + let receiver = self.setup.create(id, socket.clone()); + + let mut client_flow = flow.flip(); + match &mut client_flow { + UdpFlow::V4 { + src, + extension: Some(ext), + .. + } => { + if extension.port_offset != 0 { + assert!(extension.port_offset <= src.port()); + *src = SocketAddrV4::new(*src.ip(), src.port() - extension.port_offset); } + ext.port_offset = 0; + } + UdpFlow::V6 { + src, + extension: Some(ext), + .. + } => { + if extension.port_offset != 0 { + assert!(extension.port_offset <= src.1); + src.1 -= extension.port_offset; + } + ext.port_offset = 0; + } + _ => unreachable!(), + } + + let client = Client { + id, + key: key.clone(), + socket, + target_addr, + port_offset: extension.port_offset, + receiver, + flow: client_flow, + from_tunnel_ts: now_ms, + from_origin_ts: now_ms, + }; + + if let Some(proto) = origin.proxy_protocol { + if proto != ProxyProtocol::ProxyProtocolV2 { + udp_errors().origin_v1_proxy_protocol.inc(); + } else { + let header = ProxyProtocolHeader::from_udp_flow(&flow); - v.insert(slot); - entry.insert(client); + let mut buffer = Vec::new(); + header + .write_v2_udp(&mut buffer) + .expect("Failed to write proxy proto header to Vec"); - // Update active UDP count for new client - self.stats.set_udp(self.virtual_clients.len() as u32); + if client.socket.send_to(&buffer, target_addr).await.is_err() { + udp_errors().origin_send_io_error.inc(); + } } } + + if client + .socket + .send_to(packet.as_ref(), target_addr) + .await + .is_err() + { + udp_errors().origin_send_io_error.inc(); + } + + self.virtual_client_lookup.insert( + key, + usize::try_from(slot).expect("udp client slot overflow"), + ); + entry.insert(client); + + // Update active UDP count for new client + self.stats.set_udp(self.virtual_clients.len() as u32); } } diff --git a/packages/agent_core/src/network/udp/udp_receiver.rs b/packages/agent_core/src/network/udp/udp_receiver.rs index 4f76ce5d..8542a84c 100644 --- a/packages/agent_core/src/network/udp/udp_receiver.rs +++ b/packages/agent_core/src/network/udp/udp_receiver.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::{io::ErrorKind, net::SocketAddr, time::Duration}; use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; @@ -112,10 +112,17 @@ impl Task { from: source, } } - Err(error) => { - tracing::error!(?error, id = self.id, "failed to receive UDP packet"); - break; - } + Err(error) => match error.kind() { + ErrorKind::Interrupted | ErrorKind::WouldBlock | ErrorKind::TimedOut => { + tracing::warn!(?error, id = self.id, "transient UDP receive error"); + tokio::time::sleep(Duration::from_millis(20)).await; + continue; + } + _ => { + tracing::error!(?error, id = self.id, "failed to receive UDP packet"); + break; + } + }, }; let result = self diff --git a/packages/agent_core/src/playit_agent.rs b/packages/agent_core/src/playit_agent.rs index 25d4ca52..29cc2fe3 100644 --- a/packages/agent_core/src/playit_agent.rs +++ b/packages/agent_core/src/playit_agent.rs @@ -1,9 +1,12 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; use std::time::Duration; use tokio::sync::mpsc::channel; use tokio::time::Instant; +use tokio_util::sync::CancellationToken; use tracing::Instrument; use crate::agent_control::errors::SetupError; @@ -26,7 +29,7 @@ pub struct PlayitAgent { udp_channel: UdpChannel, tcp_clients: TcpClients, - keep_running: Arc, + cancel_token: CancellationToken, stats: AgentStats, } @@ -47,27 +50,39 @@ impl PlayitAgent { let auth = AuthApi::new(settings.api_url, settings.secret_key); let control = MaintainedControl::setup(io, auth).await?; - let packets = Packets::new(1024 * 16); - let udp_channel = UdpChannel::new(packets.clone()) + let tunnel_packets = Packets::new(1024 * 8); + let origin_packets = Packets::new(1024 * 8); + let udp_channel = UdpChannel::new(tunnel_packets) .await .map_err(SetupError::IoError)?; let stats = AgentStats::new(); - let udp_clients = UdpClients::new(settings.udp_settings, lookup.clone(), packets.clone(), stats.clone()); - let tcp_clients = TcpClients::new(settings.tcp_settings, lookup.clone(), stats.clone()); + let udp_clients = UdpClients::new( + settings.udp_settings, + lookup.clone(), + origin_packets, + stats.clone(), + ); + let cancel_token = CancellationToken::new(); + let tcp_clients = TcpClients::new( + settings.tcp_settings, + lookup.clone(), + stats.clone(), + cancel_token.child_token(), + ); Ok(PlayitAgent { control, udp_clients, udp_channel, tcp_clients, - keep_running: Arc::new(AtomicBool::new(true)), + cancel_token, stats, }) } - pub fn keep_running(&self) -> Arc { - self.keep_running.clone() + pub fn cancellation_token(&self) -> CancellationToken { + self.cancel_token.clone() } /// Get a handle to the agent stats @@ -76,43 +91,64 @@ impl PlayitAgent { } pub async fn run(self) { - let mut control = self.control; - let tunnel_run = self.keep_running.clone(); + let PlayitAgent { + mut control, + udp_clients, + udp_channel, + tcp_clients, + cancel_token, + .. + } = self; let (udp_session_tx, mut udp_session_rx) = channel(8); let udp_session_should_renew = Arc::new(AtomicBool::new(false)); + let tunnel_cancel = cancel_token.child_token(); let should_renew_udp = udp_session_should_renew.clone(); - let tunnel_task = tokio::spawn(async move { + let mut tunnel_task = tokio::spawn(async move { let mut last_control_addr_check = now_milli(); - while tunnel_run.load(Ordering::SeqCst) { - tokio::task::yield_now().await; + loop { + // Keep the control loop cooperative when updates are continuously ready. + tokio::select! { + _ = tunnel_cancel.cancelled() => break, + _ = tokio::task::yield_now() => {} + } - if should_renew_udp.load(Ordering::Acquire) - && control.send_udp_session_auth(now_milli(), 5_000).await - { - tracing::info!("udp channel requires auth, sent auth request"); + if should_renew_udp.load(Ordering::Acquire) { + let Some(sent) = tunnel_cancel + .run_until_cancelled(control.send_udp_session_auth(now_milli(), 5_000)) + .await + else { + break; + }; + if sent { + tracing::info!("udp channel requires auth, sent auth request"); + } } - /* refresh control address every 30s */ - { - let now = now_milli(); - if 30_000 < now_milli() - last_control_addr_check { - last_control_addr_check = now; - - if let Err(error) = control - .reload_control_addr(async { DualStackUdpSocket::new().await }) - .await - { - tracing::error!(?error, "failed to reload_control_addr"); - } + let now = now_milli(); + if 30_000 < now.saturating_sub(last_control_addr_check) { + last_control_addr_check = now; + + let reload = + control.reload_control_addr(async { DualStackUdpSocket::new().await }); + if let Some(Err(error)) = tunnel_cancel.run_until_cancelled(reload).await { + tracing::error!(?error, "failed to reload_control_addr"); } } - match control.update().await { + let update = tokio::select! { + _ = tunnel_cancel.cancelled() => break, + update = control.update() => update, + }; + + match update { Some(TunnelControlEvent::NewClient(new_client)) => { - self.tcp_clients.handle_new_client(new_client).await; + tokio::select! { + _ = tunnel_cancel.cancelled() => break, + _ = tcp_clients.handle_new_client(new_client) => {} + } } Some(TunnelControlEvent::UdpChannelDetails(udp_details)) => { tracing::info!("udp session details received"); @@ -123,18 +159,22 @@ impl PlayitAgent { } }); - let udp_run = self.keep_running.clone(); - - let mut udp_channel = self.udp_channel; - let mut udp_clients = self.udp_clients; + let udp_cancel = cancel_token.child_token(); + let mut udp_channel = udp_channel; + let mut udp_clients = udp_clients; - let udp_task = tokio::spawn(async move { + let mut udp_task = tokio::spawn(async move { let mut next_clear = Instant::now() + Duration::from_secs(16); - while udp_run.load(Ordering::SeqCst) { - tokio::task::yield_now().await; + loop { + // Keep the UDP packet loop cooperative under sustained bidirectional traffic. + tokio::select! { + _ = udp_cancel.cancelled() => break, + _ = tokio::task::yield_now() => {} + } tokio::select! { + _ = udp_cancel.cancelled() => break, recv = udp_clients.recv_origin_packet() => { let Some((flow, packet)) = udp_clients.dispatch_origin_packet(now_milli(), recv).await else { continue }; udp_channel.send(flow, packet).await; @@ -151,7 +191,7 @@ impl PlayitAgent { } _ = tokio::time::sleep_until(next_clear) => { next_clear = Instant::now() + Duration::from_secs(16); - udp_clients.clear_old(now_milli()); + udp_clients.clear_old(now_milli()).await; } _ = tokio::time::sleep(Duration::from_secs(3)) => {} } @@ -166,7 +206,43 @@ impl PlayitAgent { } }.instrument(tracing::info_span!("udp_session"))); - tunnel_task.await.unwrap(); - udp_task.await.unwrap(); + let mut tunnel_done = false; + let mut udp_done = false; + tokio::select! { + result = &mut tunnel_task => { + tunnel_done = true; + if let Err(error) = result { + tracing::error!(?error, "tunnel task failed"); + } + } + result = &mut udp_task => { + udp_done = true; + if let Err(error) = result { + tracing::error!(?error, "udp task failed"); + } + } + _ = cancel_token.cancelled() => {} + } + + cancel_token.cancel(); + + if !tunnel_done { + if tokio::time::timeout(Duration::from_secs(5), &mut tunnel_task) + .await + .is_err() + { + tunnel_task.abort(); + let _ = tunnel_task.await; + } + } + if !udp_done { + if tokio::time::timeout(Duration::from_secs(5), &mut udp_task) + .await + .is_err() + { + udp_task.abort(); + let _ = udp_task.await; + } + } } } diff --git a/packages/agent_core/src/stats.rs b/packages/agent_core/src/stats.rs index f9bce98c..fb86be53 100644 --- a/packages/agent_core/src/stats.rs +++ b/packages/agent_core/src/stats.rs @@ -1,5 +1,5 @@ -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; /// Shared statistics for the agent #[derive(Debug, Default, Clone)] diff --git a/packages/agent_core/src/utils/id_slab.rs b/packages/agent_core/src/utils/id_slab.rs index 204c0949..c1815691 100644 --- a/packages/agent_core/src/utils/id_slab.rs +++ b/packages/agent_core/src/utils/id_slab.rs @@ -1,229 +1,130 @@ -use std::{ - mem::{ManuallyDrop, MaybeUninit}, - u32, -}; +use slotmap::{DefaultKey, Key, KeyData, SlotMap}; pub struct IdSlab { - entries: Vec>, - free_slots: Vec, + entries: SlotMap>, + capacity: usize, } -struct Entry { - id: u64, - value: MaybeUninit, +enum Entry { + Reserved, + Occupied(T), } pub struct IdSlabVacantEntry<'a, T> { - slab: &'a mut IdSlab, - id: u64, - slot: usize, + slab: Option<&'a mut IdSlab>, + key: DefaultKey, } -const EMPTY_BIT: u64 = 1u64 << 63; -const EMPTY_BIT_NEG: u64 = !EMPTY_BIT; -const USE_NUM: u64 = (u32::MAX as u64) + 1; -const SLOT_MASK: u64 = 0x00000000FFFFFFFF; - impl IdSlab { pub fn with_capacity(capacity: usize) -> Self { - let mut slab = IdSlab { - entries: Vec::with_capacity(capacity), - free_slots: Vec::with_capacity(capacity), - }; - - for pos in 0..capacity { - slab.entries.push(Entry { - id: EMPTY_BIT | (pos as u64), - value: MaybeUninit::uninit(), - }); - - slab.free_slots.push(capacity - (pos + 1)); + Self { + entries: SlotMap::with_capacity(capacity), + capacity, } - - slab } pub fn capacity(&self) -> usize { - self.entries.len() + self.capacity } pub fn len(&self) -> usize { - self.entries.len() - self.free_slots.len() + self.entries.len() } pub fn available(&self) -> usize { - self.free_slots.len() + self.capacity.saturating_sub(self.entries.len()) } pub fn get(&self, id: u64) -> Option<&T> { - let slot = self.slot(id)?; - - let entry = &self.entries[slot]; - if (entry.id & EMPTY_BIT) == EMPTY_BIT { - return None; + match self.entries.get(key_from_id(id))? { + Entry::Reserved => None, + Entry::Occupied(value) => Some(value), } - - unsafe { Some(entry.value.assume_init_ref()) } } pub fn get_mut(&mut self, id: u64) -> Option<&mut T> { - let slot = self.slot(id)?; - - let entry = &mut self.entries[slot]; - if (entry.id & EMPTY_BIT) == EMPTY_BIT { - return None; + match self.entries.get_mut(key_from_id(id))? { + Entry::Reserved => None, + Entry::Occupied(value) => Some(value), } - - unsafe { Some(entry.value.assume_init_mut()) } } pub fn remove(&mut self, id: u64) -> Option { - let slot = self.slot(id)?; - - let entry = &mut self.entries[slot]; - if (entry.id & EMPTY_BIT) == EMPTY_BIT { - return None; - } - - entry.id = EMPTY_BIT | (entry.id + USE_NUM); - assert_eq!(entry.id & EMPTY_BIT, EMPTY_BIT); - - self.free_slots.push(slot); - - Some(unsafe { std::mem::replace(&mut entry.value, MaybeUninit::uninit()).assume_init() }) - } - - fn slot(&self, id: u64) -> Option { - let slot = (id & SLOT_MASK) as usize; - if self.entries.len() <= slot { - return None; + match self.entries.remove(key_from_id(id))? { + Entry::Reserved => None, + Entry::Occupied(value) => Some(value), } - Some(slot) } pub fn insert(&mut self, value: T) -> Result { - let slot = match self.free_slots.pop() { - Some(v) => v, - None => return Err(value), - }; - - let entry = &mut self.entries[slot]; - assert!((entry.id & EMPTY_BIT) == EMPTY_BIT); - - entry.id = EMPTY_BIT_NEG & entry.id; - assert!((entry.id & EMPTY_BIT) == 0); + if self.entries.len() >= self.capacity { + return Err(value); + } - entry.value.write(value); - Ok(entry.id) + let key = self.entries.insert(Entry::Occupied(value)); + Ok(id_from_key(key)) } pub fn vacant_entry(&mut self) -> Option> { - let slot = self.free_slots.pop()?; - let id = self.entries[slot].id & EMPTY_BIT_NEG; + if self.entries.len() >= self.capacity { + return None; + } + let key = self.entries.insert(Entry::Reserved); Some(IdSlabVacantEntry { - slab: self, - id, - slot, + slab: Some(self), + key, }) } - pub fn iter(&self) -> IdSlabIter<'_, T> { - IdSlabIter { - slab: self, - slot: 0, - remaining: self.len(), - } + pub fn iter(&self) -> impl Iterator { + self.entries.values().filter_map(|entry| match entry { + Entry::Reserved => None, + Entry::Occupied(value) => Some(value), + }) } - pub fn iter_mut(&mut self) -> IdSlabIterMut<'_, T> { - let remaining = self.len(); - IdSlabIterMut { - slab: self, - slot: 0, - remaining, - } + pub fn iter_mut(&mut self) -> impl Iterator { + self.entries.values_mut().filter_map(|entry| match entry { + Entry::Reserved => None, + Entry::Occupied(value) => Some(value), + }) } } impl<'a, T> IdSlabVacantEntry<'a, T> { pub fn id(&self) -> u64 { - self.id + id_from_key(self.key) } - pub fn insert(self, value: T) -> u64 { - let entry = &mut self.slab.entries[self.slot]; - assert!(entry.id & EMPTY_BIT == EMPTY_BIT); - - let id = EMPTY_BIT_NEG & entry.id; - assert!((id & EMPTY_BIT) == 0); - - entry.id = id; - entry.value.write(value); - - let _ = ManuallyDrop::new(self); - id + pub fn insert(mut self, value: T) -> u64 { + let slab = self + .slab + .take() + .expect("vacant entry must always own its slab reference"); + let entry = slab + .entries + .get_mut(self.key) + .expect("reserved slot must exist"); + *entry = Entry::Occupied(value); + id_from_key(self.key) } } impl<'a, T> Drop for IdSlabVacantEntry<'a, T> { fn drop(&mut self) { - self.slab.free_slots.push(self.slot); - } -} - -pub struct IdSlabIter<'a, T> { - slab: &'a IdSlab, - slot: usize, - remaining: usize, -} - -impl<'a, T> Iterator for IdSlabIter<'a, T> { - type Item = &'a T; - - fn next(&mut self) -> Option { - while self.slot < self.slab.entries.len() && self.remaining > 0 { - let entry = &self.slab.entries[self.slot]; - self.slot += 1; - - if (entry.id & EMPTY_BIT) == EMPTY_BIT { - continue; - } - - self.remaining -= 1; - return Some(unsafe { entry.value.assume_init_ref() }); + if let Some(slab) = self.slab.take() { + let _ = slab.entries.remove(self.key); } - - None } } -pub struct IdSlabIterMut<'a, T> { - slab: &'a mut IdSlab, - slot: usize, - remaining: usize, +fn id_from_key(key: DefaultKey) -> u64 { + key.data().as_ffi() } -impl<'a, T> Iterator for IdSlabIterMut<'a, T> { - type Item = &'a mut T; - - fn next(&mut self) -> Option { - let len = self.slab.entries.len(); - - while self.slot < len && self.remaining > 0 { - let entry = &mut self.slab.entries[self.slot]; - self.slot += 1; - - if (entry.id & EMPTY_BIT) == EMPTY_BIT { - continue; - } - - self.remaining -= 1; - return Some(unsafe { std::mem::transmute(entry.value.assume_init_mut()) }); - } - - None - } +fn key_from_id(id: u64) -> DefaultKey { + KeyData::from_ffi(id).into() } #[cfg(test)] @@ -265,4 +166,16 @@ mod test { slab.remove(id).unwrap(); } } + + #[test] + fn dropped_vacant_entry_releases_capacity() { + let mut slab = IdSlab::::with_capacity(1); + + { + let _entry = slab.vacant_entry().unwrap(); + } + + assert_eq!(slab.available(), 1); + assert!(slab.insert("value".to_string()).is_ok()); + } } diff --git a/packages/agent_core/src/utils/key_to_id.rs b/packages/agent_core/src/utils/key_to_id.rs index 1df6e5d5..cf24110e 100644 --- a/packages/agent_core/src/utils/key_to_id.rs +++ b/packages/agent_core/src/utils/key_to_id.rs @@ -1,5 +1,5 @@ use std::{ - collections::{hash_map, HashMap}, + collections::{HashMap, hash_map}, hash::Hash, }; diff --git a/packages/agent_core/src/utils/name_lookup.rs b/packages/agent_core/src/utils/name_lookup.rs index cdb502da..fa3227da 100644 --- a/packages/agent_core/src/utils/name_lookup.rs +++ b/packages/agent_core/src/utils/name_lookup.rs @@ -36,18 +36,3 @@ async fn ip_lookup(name: &str) -> Vec { iter.collect() } - -#[cfg(test)] -mod test { - use super::*; - use tracing::Level; - - #[tokio::test] - async fn test_lookup() { - let _ = tracing_subscriber::fmt() - .with_max_level(Level::INFO) - .try_init(); - assert!(!address_lookup("control.playit.gg", 5523).await.is_empty()); - assert!(!address_lookup("ping.playit.gg", 5523).await.is_empty()); - } -} diff --git a/packages/agent_core/tests/udp_tunnel_integration.rs b/packages/agent_core/tests/udp_tunnel_integration.rs new file mode 100644 index 00000000..e9c99c1f --- /dev/null +++ b/packages/agent_core/tests/udp_tunnel_integration.rs @@ -0,0 +1,798 @@ +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}, + num::NonZeroU64, + sync::Arc, + time::{Duration, Instant}, +}; + +use futures_util::future::join_all; +use playit_agent_core::{ + network::{ + origin_lookup::{OriginIp, OriginLookup, OriginResource, OriginTarget}, + udp::{ + packets::Packets, udp_channel::UdpChannel, udp_clients::UdpClients, + udp_settings::UdpSettings, + }, + }, + stats::AgentStats, +}; +use playit_agent_proto::{ + PortProto, + control_messages::UdpChannelDetails, + udp_proto::{UDP_CHANNEL_ESTABLISH_ID, UdpFlow, UdpFlowExtension}, +}; +use tokio::{net::UdpSocket, time::timeout}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(3); +const STRESS_TIMEOUT: Duration = Duration::from_secs(30); +const UDP_STRESS_PACKET_COUNT: usize = 100_000; +const UDP_STRESS_PACKET_SIZES: [usize; 4] = [32, 128, 512, 1300]; +const UDP_STRESS_BATCH_SIZE: usize = 32; + +#[tokio::test] +async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flow_after_clear() { + let tunnel_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind tunnel server"); + let origin_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind origin server"); + + let tunnel_addr = tunnel_server.local_addr().expect("tunnel addr"); + let origin_addr = origin_server.local_addr().expect("origin addr"); + + let lookup = Arc::new(OriginLookup::default()); + lookup + .update(std::iter::once(OriginResource { + tunnel_id: 42, + proto: PortProto::Udp, + target: OriginTarget::Port { + ip: OriginIp::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)), + port: origin_addr.port(), + }, + port_count: 0, + proxy_protocol: None, + })) + .await; + + let stats = AgentStats::new(); + let mut udp_clients = UdpClients::new( + UdpSettings::default(), + lookup, + Packets::new(64), + stats.clone(), + ); + let mut udp_channel = UdpChannel::new(Packets::new(64)) + .await + .expect("create udp channel"); + + udp_channel + .update_session(UdpChannelDetails { + tunnel_addr, + token: Arc::new(b"test-session-token".to_vec()), + }) + .await; + + let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; + assert_eq!(&token_bytes[..token_len], b"test-session-token"); + + tunnel_server + .send_to(&UDP_CHANNEL_ESTABLISH_ID.to_be_bytes(), channel_addr) + .await + .expect("send establish ack"); + + let flow = test_flow(); + + let origin_payload_1 = b"packet to local origin"; + send_tunneled_packet(&tunnel_server, channel_addr, flow, origin_payload_1).await; + + let (recv_flow_1, recv_packet_1) = timeout(TEST_TIMEOUT, udp_channel.recv()) + .await + .expect("recv tunneled packet"); + assert_eq!(recv_flow_1, flow); + udp_clients + .handle_tunneled_packet(1_000, recv_flow_1, recv_packet_1) + .await; + + let (origin_len_1, virtual_addr_1, origin_bytes_1) = recv_from_socket(&origin_server).await; + assert_eq!(&origin_bytes_1[..origin_len_1], origin_payload_1); + assert_eq!(stats.active_udp(), 1); + + let tunnel_reply_1 = b"reply from origin"; + origin_server + .send_to(tunnel_reply_1, virtual_addr_1) + .await + .expect("origin send reply"); + + let reply_1 = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) + .await + .expect("recv origin reply"); + let (reply_flow_1, reply_packet_1) = udp_clients + .dispatch_origin_packet(2_000, reply_1) + .await + .expect("dispatch origin reply"); + udp_channel.send(reply_flow_1, reply_packet_1).await; + + let (encap_flow_1, encap_payload_1, encap_source_1) = + recv_tunneled_packet(&tunnel_server).await; + assert_eq!(encap_source_1, channel_addr); + assert_eq!(encap_flow_1, flow.flip()); + assert_eq!(encap_payload_1, tunnel_reply_1); + + udp_clients.clear_old(100_000).await; + assert_eq!(stats.active_udp(), 0); + + let origin_payload_2 = b"packet after clear"; + send_tunneled_packet(&tunnel_server, channel_addr, flow, origin_payload_2).await; + + let (recv_flow_2, recv_packet_2) = timeout(TEST_TIMEOUT, udp_channel.recv()) + .await + .expect("recv tunneled packet after clear"); + assert_eq!(recv_flow_2, flow); + udp_clients + .handle_tunneled_packet(101_000, recv_flow_2, recv_packet_2) + .await; + + let (origin_len_2, virtual_addr_2, origin_bytes_2) = recv_from_socket(&origin_server).await; + assert_eq!(&origin_bytes_2[..origin_len_2], origin_payload_2); + assert!(virtual_addr_2.ip().is_loopback()); + assert_eq!(stats.active_udp(), 1); + + let tunnel_reply_2 = b"reply after clear"; + origin_server + .send_to(tunnel_reply_2, virtual_addr_2) + .await + .expect("origin send second reply"); + + let reply_2 = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) + .await + .expect("recv origin reply after clear"); + let (reply_flow_2, reply_packet_2) = udp_clients + .dispatch_origin_packet(102_000, reply_2) + .await + .expect("dispatch origin reply after clear"); + udp_channel.send(reply_flow_2, reply_packet_2).await; + + let (encap_flow_2, encap_payload_2, encap_source_2) = + recv_tunneled_packet(&tunnel_server).await; + assert_eq!(encap_source_2, channel_addr); + assert_eq!(encap_flow_2, flow.flip()); + assert_eq!(encap_payload_2, tunnel_reply_2); +} + +#[tokio::test] +async fn encapsulated_udp_tunnel_supports_ipv6_origin_addresses() { + let tunnel_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind tunnel server"); + let origin_server = UdpSocket::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)) + .await + .expect("bind origin server"); + + let tunnel_addr = tunnel_server.local_addr().expect("tunnel addr"); + let origin_addr = origin_server.local_addr().expect("origin addr"); + + let lookup = Arc::new(OriginLookup::default()); + lookup + .update(std::iter::once(OriginResource { + tunnel_id: 42, + proto: PortProto::Udp, + target: OriginTarget::Port { + ip: OriginIp::IpAddress(IpAddr::V6(Ipv6Addr::LOCALHOST)), + port: origin_addr.port(), + }, + port_count: 0, + proxy_protocol: None, + })) + .await; + + let stats = AgentStats::new(); + let mut udp_clients = UdpClients::new( + UdpSettings::default(), + lookup, + Packets::new(64), + stats.clone(), + ); + let mut udp_channel = UdpChannel::new(Packets::new(64)) + .await + .expect("create udp channel"); + + udp_channel + .update_session(UdpChannelDetails { + tunnel_addr, + token: Arc::new(b"test-session-token".to_vec()), + }) + .await; + + let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; + assert_eq!(&token_bytes[..token_len], b"test-session-token"); + + tunnel_server + .send_to(&UDP_CHANNEL_ESTABLISH_ID.to_be_bytes(), channel_addr) + .await + .expect("send establish ack"); + + let flow = test_flow(); + let origin_payload = b"packet to ipv6 origin"; + send_tunneled_packet(&tunnel_server, channel_addr, flow, origin_payload).await; + + let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) + .await + .expect("recv tunneled packet"); + assert_eq!(recv_flow, flow); + udp_clients + .handle_tunneled_packet(1_000, recv_flow, recv_packet) + .await; + + let (origin_len, virtual_addr, origin_bytes) = recv_from_socket(&origin_server).await; + assert_eq!(&origin_bytes[..origin_len], origin_payload); + assert!(virtual_addr.is_ipv6()); + assert_eq!(stats.active_udp(), 1); + + let tunnel_reply = b"reply from ipv6 origin"; + origin_server + .send_to(tunnel_reply, virtual_addr) + .await + .expect("origin send reply"); + + let reply = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) + .await + .expect("recv origin reply"); + let (reply_flow, reply_packet) = udp_clients + .dispatch_origin_packet(2_000, reply) + .await + .expect("dispatch origin reply"); + udp_channel.send(reply_flow, reply_packet).await; + + let (encap_flow, encap_payload, encap_source) = recv_tunneled_packet(&tunnel_server).await; + assert_eq!(encap_source, channel_addr); + assert_eq!(encap_flow, flow.flip()); + assert_eq!(encap_payload, tunnel_reply); +} + +#[tokio::test] +async fn encapsulated_udp_tunnel_isolates_multiple_parallel_flows_and_recovers_them_after_clear() { + let tunnel_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind tunnel server"); + let origin_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind origin server"); + + let tunnel_addr = tunnel_server.local_addr().expect("tunnel addr"); + let origin_addr = origin_server.local_addr().expect("origin addr"); + + let lookup = Arc::new(OriginLookup::default()); + lookup + .update(std::iter::once(OriginResource { + tunnel_id: 42, + proto: PortProto::Udp, + target: OriginTarget::Port { + ip: OriginIp::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)), + port: origin_addr.port(), + }, + port_count: 0, + proxy_protocol: None, + })) + .await; + + let stats = AgentStats::new(); + let mut udp_clients = UdpClients::new( + UdpSettings::default(), + lookup, + Packets::new(128), + stats.clone(), + ); + let mut udp_channel = UdpChannel::new(Packets::new(128)) + .await + .expect("create udp channel"); + + udp_channel + .update_session(UdpChannelDetails { + tunnel_addr, + token: Arc::new(b"test-session-token".to_vec()), + }) + .await; + + let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; + assert_eq!(&token_bytes[..token_len], b"test-session-token"); + + tunnel_server + .send_to(&UDP_CHANNEL_ESTABLISH_ID.to_be_bytes(), channel_addr) + .await + .expect("send establish ack"); + + let cases = vec![ + FlowCase::new( + 0, + flow_with_source(Ipv4Addr::new(198, 51, 100, 10), 41_000), + b"flow-0 inbound".to_vec(), + b"flow-0 outbound".to_vec(), + ), + FlowCase::new( + 1, + flow_with_source(Ipv4Addr::new(198, 51, 100, 11), 41_001), + b"flow-1 inbound".to_vec(), + b"flow-1 outbound".to_vec(), + ), + FlowCase::new( + 2, + flow_with_source(Ipv4Addr::new(198, 51, 100, 12), 41_002), + b"flow-2 inbound".to_vec(), + b"flow-2 outbound".to_vec(), + ), + ]; + + let first_virtual_addrs = drive_parallel_flows( + &tunnel_server, + &origin_server, + channel_addr, + &mut udp_channel, + &mut udp_clients, + &cases, + 1_000, + 2_000, + ) + .await; + + assert_eq!(first_virtual_addrs.len(), cases.len()); + assert_eq!(stats.active_udp(), cases.len() as u32); + assert_unique_virtual_addrs(&first_virtual_addrs); + + udp_clients.clear_old(100_000).await; + assert_eq!(stats.active_udp(), 0); + + let second_virtual_addrs = drive_parallel_flows( + &tunnel_server, + &origin_server, + channel_addr, + &mut udp_channel, + &mut udp_clients, + &cases, + 101_000, + 102_000, + ) + .await; + + assert_eq!(second_virtual_addrs.len(), cases.len()); + assert_unique_virtual_addrs(&second_virtual_addrs); + assert_eq!(stats.active_udp(), cases.len() as u32); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "stress/perf test; run with -- --ignored --nocapture"] +async fn udp_tunnel_stress_reports_bitrate_by_packet_size() { + let tunnel_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind tunnel server"); + let origin_server = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind origin server"); + + let tunnel_addr = tunnel_server.local_addr().expect("tunnel addr"); + let origin_addr = origin_server.local_addr().expect("origin addr"); + + let lookup = Arc::new(OriginLookup::default()); + lookup + .update(std::iter::once(OriginResource { + tunnel_id: 42, + proto: PortProto::Udp, + target: OriginTarget::Port { + ip: OriginIp::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)), + port: origin_addr.port(), + }, + port_count: 0, + proxy_protocol: None, + })) + .await; + + let stats = AgentStats::new(); + let mut udp_clients = UdpClients::new( + UdpSettings::default(), + lookup, + Packets::new(4096), + stats.clone(), + ); + let mut udp_channel = UdpChannel::new(Packets::new(4096)) + .await + .expect("create udp channel"); + + udp_channel + .update_session(UdpChannelDetails { + tunnel_addr, + token: Arc::new(b"test-session-token".to_vec()), + }) + .await; + + let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; + assert_eq!(&token_bytes[..token_len], b"test-session-token"); + + tunnel_server + .send_to(&UDP_CHANNEL_ESTABLISH_ID.to_be_bytes(), channel_addr) + .await + .expect("send establish ack"); + + let flow = test_flow(); + let virtual_addr = establish_virtual_client( + &tunnel_server, + &origin_server, + channel_addr, + &mut udp_channel, + &mut udp_clients, + flow, + ) + .await; + + println!( + "udp stress: packets_per_size={}, flow_footer_bytes={}", + UDP_STRESS_PACKET_COUNT, + flow.footer_len() + ); + + for size in UDP_STRESS_PACKET_SIZES { + let inbound_payload = vec![size as u8; size]; + let outbound_payload = vec![(size as u8).wrapping_add(1); size]; + + let inbound = measure_tunnel_to_origin_bitrate( + &tunnel_server, + &origin_server, + channel_addr, + virtual_addr, + &mut udp_channel, + &mut udp_clients, + &stats, + flow, + &inbound_payload, + ) + .await; + + let outbound = measure_origin_to_tunnel_bitrate( + &origin_server, + &tunnel_server, + virtual_addr, + channel_addr, + &mut udp_channel, + &mut udp_clients, + &stats, + flow, + &outbound_payload, + ) + .await; + + println!( + "udp stress size={}B tunnel->origin payload_mbps={:.2} tunnel_wire_mbps={:.2} elapsed_ms={} origin->tunnel payload_mbps={:.2} tunnel_wire_mbps={:.2} elapsed_ms={}", + size, + inbound.payload_mbps, + inbound.tunnel_wire_mbps, + inbound.elapsed.as_millis(), + outbound.payload_mbps, + outbound.tunnel_wire_mbps, + outbound.elapsed.as_millis(), + ); + } +} + +fn test_flow() -> UdpFlow { + flow_with_source(Ipv4Addr::new(198, 51, 100, 10), 41_000) +} + +fn flow_with_source(src_ip: Ipv4Addr, src_port: u16) -> UdpFlow { + UdpFlow::V4 { + src: SocketAddrV4::new(src_ip, src_port), + dst: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 25_565), + frag: None, + extension: Some(UdpFlowExtension { + client_server_id: NonZeroU64::new(7).expect("nonzero client server id"), + tunnel_id: NonZeroU64::new(42).expect("nonzero tunnel id"), + port_offset: 0, + }), + } +} + +struct FlowCase { + id: usize, + flow: UdpFlow, + inbound_payload: Vec, + outbound_payload: Vec, +} + +impl FlowCase { + fn new(id: usize, flow: UdpFlow, inbound_payload: Vec, outbound_payload: Vec) -> Self { + Self { + id, + flow, + inbound_payload, + outbound_payload, + } + } +} + +async fn drive_parallel_flows( + tunnel_server: &UdpSocket, + origin_server: &UdpSocket, + channel_addr: std::net::SocketAddr, + udp_channel: &mut UdpChannel, + udp_clients: &mut UdpClients, + cases: &[FlowCase], + tunnel_ts: u64, + origin_ts: u64, +) -> HashMap { + join_all(cases.iter().map(|case| { + send_tunneled_packet( + tunnel_server, + channel_addr, + case.flow, + &case.inbound_payload, + ) + })) + .await; + + for _ in 0..cases.len() { + let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) + .await + .expect("recv tunneled packet"); + udp_clients + .handle_tunneled_packet(tunnel_ts, recv_flow, recv_packet) + .await; + } + + let inbound_by_payload: HashMap, &FlowCase> = cases + .iter() + .map(|case| (case.inbound_payload.clone(), case)) + .collect(); + + let mut virtual_addrs = HashMap::new(); + for _ in 0..cases.len() { + let (len, virtual_addr, bytes) = recv_from_socket(origin_server).await; + let payload = bytes[..len].to_vec(); + let case = inbound_by_payload + .get(&payload) + .expect("unexpected inbound payload at origin"); + assert_eq!(virtual_addrs.insert(case.id, virtual_addr), None); + } + + join_all(cases.iter().map(|case| async { + let virtual_addr = *virtual_addrs + .get(&case.id) + .expect("missing virtual addr for case"); + origin_server + .send_to(&case.outbound_payload, virtual_addr) + .await + .expect("origin send reply"); + })) + .await; + + for _ in 0..cases.len() { + let reply = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) + .await + .expect("recv origin reply"); + let (reply_flow, reply_packet) = udp_clients + .dispatch_origin_packet(origin_ts, reply) + .await + .expect("dispatch origin reply"); + udp_channel.send(reply_flow, reply_packet).await; + } + + let cases_by_outbound: HashMap, &FlowCase> = cases + .iter() + .map(|case| (case.outbound_payload.clone(), case)) + .collect(); + + let mut seen_flow_ids = Vec::new(); + for _ in 0..cases.len() { + let (encap_flow, encap_payload, encap_source) = recv_tunneled_packet(tunnel_server).await; + assert_eq!(encap_source, channel_addr); + + let case = cases_by_outbound + .get(&encap_payload) + .expect("unexpected outbound payload at tunnel"); + assert_eq!(encap_flow, case.flow.flip()); + seen_flow_ids.push(case.id); + } + + seen_flow_ids.sort_unstable(); + assert_eq!( + seen_flow_ids, + cases.iter().map(|case| case.id).collect::>() + ); + + virtual_addrs +} + +fn assert_unique_virtual_addrs(addrs: &HashMap) { + let mut seen = Vec::new(); + for addr in addrs.values().copied() { + assert!( + !seen.contains(&addr), + "duplicate virtual client address observed: {addr}" + ); + seen.push(addr); + } +} + +async fn establish_virtual_client( + tunnel_server: &UdpSocket, + origin_server: &UdpSocket, + channel_addr: std::net::SocketAddr, + udp_channel: &mut UdpChannel, + udp_clients: &mut UdpClients, + flow: UdpFlow, +) -> std::net::SocketAddr { + let warmup_payload = b"udp-stress-warmup"; + send_tunneled_packet(tunnel_server, channel_addr, flow, warmup_payload).await; + + let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) + .await + .expect("recv warmup tunneled packet"); + assert_eq!(recv_flow, flow); + udp_clients + .handle_tunneled_packet(1_000, recv_flow, recv_packet) + .await; + + let (len, virtual_addr, bytes) = recv_from_socket(origin_server).await; + assert_eq!(&bytes[..len], warmup_payload); + virtual_addr +} + +async fn measure_tunnel_to_origin_bitrate( + tunnel_server: &UdpSocket, + origin_server: &UdpSocket, + channel_addr: std::net::SocketAddr, + virtual_addr: std::net::SocketAddr, + udp_channel: &mut UdpChannel, + udp_clients: &mut UdpClients, + stats: &AgentStats, + flow: UdpFlow, + payload: &[u8], +) -> ThroughputResult { + let expected_bytes = UDP_STRESS_PACKET_COUNT * payload.len(); + let expected_tunnel_bytes = UDP_STRESS_PACKET_COUNT * (payload.len() + flow.footer_len()); + let before = stats.snapshot(); + let start = Instant::now(); + + timeout(STRESS_TIMEOUT, async { + let mut processed = 0usize; + while processed < UDP_STRESS_PACKET_COUNT { + let batch = UDP_STRESS_BATCH_SIZE.min(UDP_STRESS_PACKET_COUNT - processed); + + for _ in 0..batch { + send_tunneled_packet(tunnel_server, channel_addr, flow, payload).await; + } + + for i in 0..batch { + let (recv_flow, recv_packet) = udp_channel.recv().await; + assert_eq!(recv_flow, flow); + udp_clients + .handle_tunneled_packet(10_000 + (processed + i) as u64, recv_flow, recv_packet) + .await; + } + + for _ in 0..batch { + let (len, source, bytes) = recv_from_socket(origin_server).await; + assert_eq!(source, virtual_addr); + assert_eq!(&bytes[..len], payload); + } + + processed += batch; + } + }) + .await + .expect("tunnel to origin stress timed out"); + + let elapsed = start.elapsed(); + let after = stats.snapshot(); + assert_eq!(after.bytes_in - before.bytes_in, expected_bytes as u64); + + ThroughputResult::new(elapsed, expected_bytes, expected_tunnel_bytes) +} + +async fn measure_origin_to_tunnel_bitrate( + origin_server: &UdpSocket, + tunnel_server: &UdpSocket, + virtual_addr: std::net::SocketAddr, + channel_addr: std::net::SocketAddr, + udp_channel: &mut UdpChannel, + udp_clients: &mut UdpClients, + stats: &AgentStats, + flow: UdpFlow, + payload: &[u8], +) -> ThroughputResult { + let expected_bytes = UDP_STRESS_PACKET_COUNT * payload.len(); + let expected_tunnel_bytes = UDP_STRESS_PACKET_COUNT * (payload.len() + flow.footer_len()); + let before = stats.snapshot(); + let start = Instant::now(); + + timeout(STRESS_TIMEOUT, async { + let mut processed = 0usize; + while processed < UDP_STRESS_PACKET_COUNT { + let batch = UDP_STRESS_BATCH_SIZE.min(UDP_STRESS_PACKET_COUNT - processed); + + for _ in 0..batch { + origin_server + .send_to(payload, virtual_addr) + .await + .expect("origin send stress packet"); + } + + for i in 0..batch { + let recv = udp_clients.recv_origin_packet().await; + let (reply_flow, reply_packet) = udp_clients + .dispatch_origin_packet(20_000 + (processed + i) as u64, recv) + .await + .expect("dispatch origin stress packet"); + assert_eq!(reply_flow, flow.flip()); + udp_channel.send(reply_flow, reply_packet).await; + } + + for _ in 0..batch { + let (encap_flow, encap_payload, encap_source) = + recv_tunneled_packet(tunnel_server).await; + assert_eq!(encap_source, channel_addr); + assert_eq!(encap_flow, flow.flip()); + assert_eq!(encap_payload, payload); + } + + processed += batch; + } + }) + .await + .expect("origin to tunnel stress timed out"); + + let elapsed = start.elapsed(); + let after = stats.snapshot(); + assert_eq!(after.bytes_out - before.bytes_out, expected_bytes as u64); + + ThroughputResult::new(elapsed, expected_bytes, expected_tunnel_bytes) +} + +struct ThroughputResult { + elapsed: Duration, + payload_mbps: f64, + tunnel_wire_mbps: f64, +} + +impl ThroughputResult { + fn new(elapsed: Duration, payload_bytes: usize, tunnel_wire_bytes: usize) -> Self { + let seconds = elapsed.as_secs_f64().max(f64::EPSILON); + + Self { + elapsed, + payload_mbps: (payload_bytes as f64 * 8.0) / seconds / 1_000_000.0, + tunnel_wire_mbps: (tunnel_wire_bytes as f64 * 8.0) / seconds / 1_000_000.0, + } + } +} + +async fn send_tunneled_packet( + socket: &UdpSocket, + target: std::net::SocketAddr, + flow: UdpFlow, + payload: &[u8], +) { + let mut packet = Vec::with_capacity(payload.len() + flow.footer_len()); + packet.extend_from_slice(payload); + packet.resize(payload.len() + flow.footer_len(), 0); + assert!(flow.write_to(&mut packet[payload.len()..])); + + socket + .send_to(&packet, target) + .await + .expect("send tunneled packet"); +} + +async fn recv_tunneled_packet(socket: &UdpSocket) -> (UdpFlow, Vec, std::net::SocketAddr) { + let (len, source, buf) = recv_from_socket(socket).await; + let flow = UdpFlow::from_tail(&buf[..len]).expect("parse flow footer"); + let payload = buf[..len - flow.footer_len()].to_vec(); + (flow, payload, source) +} + +async fn recv_from_socket(socket: &UdpSocket) -> (usize, std::net::SocketAddr, [u8; 2048]) { + let mut buf = [0u8; 2048]; + let (len, source) = timeout(TEST_TIMEOUT, socket.recv_from(&mut buf)) + .await + .expect("udp receive timeout") + .expect("udp receive"); + (len, source, buf) +} diff --git a/packages/agent_proto/src/control_feed.rs b/packages/agent_proto/src/control_feed.rs index 77883e6e..c0a61d9d 100644 --- a/packages/agent_proto/src/control_feed.rs +++ b/packages/agent_proto/src/control_feed.rs @@ -159,20 +159,3 @@ impl MessageEncoding for ClaimInstructions { }) } } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_control() { - let data = "0000000204d1198d10046804d053c766cc4904d1198c029306000000000000004c2\ - c003cd1198d100468d053c766cc49cba8329c930664e9431200000000000000010000000000298c05779c\ - 9306000000000e00000000000065b2000171012de0fdb1b6d5de58be82911b07bc00000000000065b20000000e"; - - let bytes = hex::decode(data).unwrap(); - let mut reader = &bytes[..]; - let req = ControlFeed::read_from(&mut reader).unwrap(); - println!("{:?}", req); - } -} diff --git a/packages/agent_proto/src/control_messages.rs b/packages/agent_proto/src/control_messages.rs index 8ffb09cd..5305d1d7 100644 --- a/packages/agent_proto/src/control_messages.rs +++ b/packages/agent_proto/src/control_messages.rs @@ -12,6 +12,22 @@ use serde::Serialize; use crate::hmac::HmacSha256; use crate::{AgentSessionId, PortRange}; +const MTU_PATTERN_SIZE: usize = 4; + +const MTU_TEST_PATTERN: [u8; 2048] = const { + const PATTERN: [u8; MTU_PATTERN_SIZE] = [0x0a, 0x0b, 0x0c, 0x0d]; + + let mut data = [0u8; 2048]; + + let mut i = 0; + while i < 2048 { + data[i] = PATTERN[i % 4]; + i += 1; + } + + data +}; + #[derive(Debug, Eq, PartialEq, Clone, Serialize)] pub enum ControlRequest { Ping(Ping), @@ -19,6 +35,8 @@ pub enum ControlRequest { AgentKeepAlive(AgentSessionId), SetupUdpChannel(AgentSessionId), AgentCheckPortMapping(AgentCheckPortMapping), + CheckMtuReceived(CheckMtuReceived), + SendMtuTest(SendMtuTest), } #[repr(u32)] @@ -31,6 +49,8 @@ pub enum ControlRequestId { AgentCheckPortMappingV1, PingV2, AgentRegisterV2, + CheckMtuReceivedV1, + SendMtuTestV1, END, } @@ -59,15 +79,7 @@ impl MessageEncoding for ControlRequestId { } impl MessageEncoding for ControlRequest { - const MAX_SIZE: Option = Some( - m_static::() - + m_max_list(&[ - m_max::(), - m_max::(), - m_max::(), - m_max::(), - ]), - ); + const MAX_SIZE: Option = None; fn write_to(&self, out: &mut T) -> std::io::Result { let mut sum = 0; @@ -97,6 +109,14 @@ impl MessageEncoding for ControlRequest { sum += ControlRequestId::AgentCheckPortMappingV1.write_to(out)?; sum += data.write_to(out)?; } + ControlRequest::CheckMtuReceived(data) => { + sum += ControlRequestId::CheckMtuReceivedV1.write_to(out)?; + sum += data.write_to(out)?; + } + ControlRequest::SendMtuTest(data) => { + sum += ControlRequestId::SendMtuTestV1.write_to(out)?; + sum += data.write_to(out)?; + } } Ok(sum) @@ -122,6 +142,12 @@ impl MessageEncoding for ControlRequest { ControlRequestId::AgentCheckPortMappingV1 => Ok(ControlRequest::AgentCheckPortMapping( AgentCheckPortMapping::read_from(read)?, )), + ControlRequestId::CheckMtuReceivedV1 => Ok(ControlRequest::CheckMtuReceived( + CheckMtuReceived::read_from(read)?, + )), + ControlRequestId::SendMtuTestV1 => { + Ok(ControlRequest::SendMtuTest(SendMtuTest::read_from(read)?)) + } ControlRequestId::_PingV1 => Ok(ControlRequest::Ping(Ping { now: u64::read_from(read)?, session_id: None, @@ -134,6 +160,59 @@ impl MessageEncoding for ControlRequest { } } +#[derive(Debug, Eq, PartialEq, Clone, Serialize)] +pub struct CheckMtuReceived { + pub id: u64, + pub message_size: u32, +} + +impl MessageEncoding for CheckMtuReceived { + const STATIC_SIZE: Option = None; + const MAX_SIZE: Option = None; + + fn write_to(&self, out: &mut T) -> std::io::Result { + let mut sum = 0; + sum += self.id.write_to(out)?; + sum += self.message_size.write_to(out)?; + sum += write_mtu_pattern(out, self.message_size as usize)?; + Ok(sum) + } + + fn read_from(read: &mut T) -> std::io::Result { + let id = u64::read_from(read)?; + let message_size = u32::read_from(read)?; + read_and_verify_mtu_pattern(read, message_size as usize)?; + Ok(CheckMtuReceived { id, message_size }) + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Serialize)] +pub struct SendMtuTest { + pub id: u64, + pub data_center_id: u32, + pub udp_payload_length: u32, +} + +impl MessageEncoding for SendMtuTest { + const STATIC_SIZE: Option = Some(8 + 4 + 4); + + fn write_to(&self, out: &mut T) -> std::io::Result { + let mut sum = 0; + sum += self.id.write_to(out)?; + sum += self.data_center_id.write_to(out)?; + sum += self.udp_payload_length.write_to(out)?; + Ok(sum) + } + + fn read_from(read: &mut T) -> std::io::Result { + Ok(SendMtuTest { + id: u64::read_from(read)?, + data_center_id: u32::read_from(read)?, + udp_payload_length: u32::read_from(read)?, + }) + } +} + #[derive(Debug, Eq, PartialEq, Clone, Serialize)] pub struct AgentCheckPortMapping { pub agent_session_id: AgentSessionId, @@ -373,6 +452,9 @@ pub enum ControlResponse { AgentRegistered(AgentRegistered), AgentPortMapping(AgentPortMapping), UdpChannelDetails(UdpChannelDetails), + CheckMtuReceivedAck(CheckMtuReceivedAck), + MtuTestPacket(MtuTestPacket), + MtuTestFail(MtuTestFail), } impl MessageEncoding for ControlResponse { @@ -408,6 +490,18 @@ impl MessageEncoding for ControlResponse { sum += 8u32.write_to(out)?; sum += data.write_to(out)?; } + ControlResponse::CheckMtuReceivedAck(data) => { + sum += 9u32.write_to(out)?; + sum += data.write_to(out)?; + } + ControlResponse::MtuTestPacket(data) => { + sum += 10u32.write_to(out)?; + sum += data.write_to(out)?; + } + ControlResponse::MtuTestFail(data) => { + sum += 11u32.write_to(out)?; + sum += data.write_to(out)?; + } } Ok(sum) @@ -429,11 +523,142 @@ impl MessageEncoding for ControlResponse { 8 => Ok(ControlResponse::UdpChannelDetails( UdpChannelDetails::read_from(read)?, )), + 9 => Ok(ControlResponse::CheckMtuReceivedAck( + CheckMtuReceivedAck::read_from(read)?, + )), + 10 => Ok(ControlResponse::MtuTestPacket(MtuTestPacket::read_from( + read, + )?)), + 11 => Ok(ControlResponse::MtuTestFail(MtuTestFail::read_from(read)?)), _ => Err(std::io::Error::other("invalid ControlResponse id")), } } } +#[derive(Debug, Eq, PartialEq, Clone, Serialize)] +pub struct CheckMtuReceivedAck { + pub id: u64, + pub message_length: u32, + pub ip_header_length: u32, + pub udp_payload_length: u32, +} + +impl MessageEncoding for CheckMtuReceivedAck { + const STATIC_SIZE: Option = Some(8 + 4 + 4 + 4); + + fn write_to(&self, out: &mut T) -> std::io::Result { + let mut sum = 0; + sum += self.id.write_to(out)?; + sum += self.message_length.write_to(out)?; + sum += self.ip_header_length.write_to(out)?; + sum += self.udp_payload_length.write_to(out)?; + Ok(sum) + } + + fn read_from(read: &mut T) -> std::io::Result { + Ok(CheckMtuReceivedAck { + id: u64::read_from(read)?, + message_length: u32::read_from(read)?, + ip_header_length: u32::read_from(read)?, + udp_payload_length: u32::read_from(read)?, + }) + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Serialize)] +pub struct MtuTestPacket { + pub data_center_id: u32, + pub udp_payload_length: u32, +} + +impl MessageEncoding for MtuTestPacket { + const STATIC_SIZE: Option = Some(4); + + fn write_to(&self, out: &mut T) -> std::io::Result { + self.data_center_id.write_to(out)?; + self.udp_payload_length.write_to(out) + } + + fn read_from(read: &mut T) -> std::io::Result { + Ok(MtuTestPacket { + data_center_id: u32::read_from(read)?, + udp_payload_length: u32::read_from(read)?, + }) + } +} + +#[repr(u32)] +#[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize)] +pub enum MtuTestFailCode { + InvalidUdpPayloadLength = 0, + InvalidDcId, + _Unknown, +} + +impl MessageEncoding for MtuTestFailCode { + const STATIC_SIZE: Option = Some(4); + + fn write_to(&self, out: &mut T) -> std::io::Result { + (*self as u32).write_to(out) + } + + fn read_from(read: &mut T) -> std::io::Result { + let num = u32::read_from(read)?; + if (Self::_Unknown as u32) <= num { + return Ok(Self::_Unknown); + } + + unsafe { Ok(std::mem::transmute::(num)) } + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Serialize)] +pub struct MtuTestFail { + pub error_code: MtuTestFailCode, +} + +impl MessageEncoding for MtuTestFail { + const STATIC_SIZE: Option = MtuTestFailCode::STATIC_SIZE; + + fn write_to(&self, out: &mut T) -> std::io::Result { + self.error_code.write_to(out) + } + + fn read_from(read: &mut T) -> std::io::Result { + Ok(MtuTestFail { + error_code: MtuTestFailCode::read_from(read)?, + }) + } +} + +pub fn write_mtu_pattern(out: &mut T, len: usize) -> std::io::Result { + assert!(len <= 2048); + out.write_all(&MTU_TEST_PATTERN[..len])?; + Ok(len) +} + +fn read_and_verify_mtu_pattern(read: &mut T, len: usize) -> std::io::Result<()> { + let mut buffer = [0u8; 128]; + + let mut i = 0; + while i < len { + let size = (len - i).min(128); + read.read_exact(&mut buffer[..size])?; + + let offset = &MTU_TEST_PATTERN[i % MTU_PATTERN_SIZE..]; + if !(&buffer[..size]).eq(&offset[..size]) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid mtu test pattern", + )); + } + + i += size; + } + + Ok(()) +} + #[derive(Debug, Eq, PartialEq, Clone, Serialize)] pub struct AgentPortMapping { pub range: PortRange, @@ -741,7 +966,7 @@ mod test { } pub fn rng_control_request(rng: &mut R) -> ControlRequest { - match rng.next_u32() % 5 { + match rng.next_u32() % 7 { 0 => ControlRequest::Ping(Ping { now: rng.next_u64(), current_ping: if rng.next_u32() % 2 == 0 { @@ -809,12 +1034,21 @@ mod test { }, }, }), + 5 => ControlRequest::CheckMtuReceived(CheckMtuReceived { + id: rng.next_u64(), + message_size: rng.next_u32() % 64, + }), + 6 => ControlRequest::SendMtuTest(SendMtuTest { + id: rng.next_u64(), + data_center_id: rng.next_u32(), + udp_payload_length: (rng.next_u32() % 1500) + 1, + }), _ => unreachable!(), } } pub fn rng_control_response(rng: &mut R) -> ControlResponse { - match rng.next_u32() % 8 { + match rng.next_u32() % 11 { 0 => ControlResponse::Pong(Pong { request_now: rng.next_u64(), server_now: rng.next_u64(), @@ -879,6 +1113,22 @@ mod test { Arc::new(buffer) }, }), + 8 => ControlResponse::CheckMtuReceivedAck(CheckMtuReceivedAck { + id: rng.next_u64(), + message_length: rng.next_u32(), + ip_header_length: match rng.next_u32() % 2 { + 0 => 20, + _ => 40, + }, + udp_payload_length: rng.next_u32(), + }), + 9 => ControlResponse::MtuTestPacket(MtuTestPacket { + data_center_id: rng.next_u32(), + udp_payload_length: rng.next_u32(), + }), + 10 => ControlResponse::MtuTestFail(MtuTestFail { + error_code: MtuTestFailCode::InvalidUdpPayloadLength, + }), _ => unreachable!(), } } @@ -920,7 +1170,10 @@ mod test { msg.write_to(&mut buffer).unwrap(); let hex_buffer = hex::encode(&buffer); - assert_eq!(hex_buffer, "0000000000000064000000000000002000000000000002a4000000000626ba79047f000001101b04630c223314c0767a59319b8edfcc1e6f3d3ea2d19ac74a74e5f5333c9b335adc72cda821de5f"); + assert_eq!( + hex_buffer, + "0000000000000064000000000000002000000000000002a4000000000626ba79047f000001101b04630c223314c0767a59319b8edfcc1e6f3d3ea2d19ac74a74e5f5333c9b335adc72cda821de5f" + ); } #[test] @@ -945,7 +1198,10 @@ mod test { msg.write_to(&mut buffer).unwrap(); let hex_buffer = hex::encode(&buffer); - assert_eq!(hex_buffer, "0000000000000064000000000000002000000000000002a4000000000626ba790600000000000000000000000000000088101b060000000000000000000000000000009914c0724f203e7ac2f090800dbeb68afbf184f367f9ca14d8a0082e245070c3835c4b"); + assert_eq!( + hex_buffer, + "0000000000000064000000000000002000000000000002a4000000000626ba790600000000000000000000000000000088101b060000000000000000000000000000009914c0724f203e7ac2f090800dbeb68afbf184f367f9ca14d8a0082e245070c3835c4b" + ); } #[test] @@ -961,10 +1217,71 @@ mod test { content: ControlRequest::Ping(Ping { now: 0, current_ping: None, - session_id: None, + session_id: None }), } ); println!("Got msg: {msg:?}"); } + + #[test] + fn check_mtu_received_round_trip_test() { + let mut buffer = [0u8; 256]; + test_encoding( + ControlRequest::CheckMtuReceived(CheckMtuReceived { + id: 42, + message_size: 13, + }), + &mut buffer, + ); + + let mut out = Vec::new(); + + out.clear(); + ControlRequest::CheckMtuReceived(CheckMtuReceived { + id: 42, + message_size: 0, + }) + .write_to(&mut out) + .unwrap(); + + let zero_size = out.len(); + + out.clear(); + ControlRequest::CheckMtuReceived(CheckMtuReceived { + id: 42, + message_size: 13, + }) + .write_to(&mut out) + .unwrap(); + assert_eq!(out.len(), zero_size + 13); + + out.clear(); + ControlRequest::CheckMtuReceived(CheckMtuReceived { + id: 42, + message_size: 50, + }) + .write_to(&mut out) + .unwrap(); + assert_eq!(out.len(), zero_size + 50); + + *out.last_mut().unwrap() = 0xFF; + let mut reader = &out[..]; + assert!(ControlRequest::read_from(&mut reader).is_err()); + } + + #[test] + fn check_mtu_received_invalid_pattern_test() { + let mut buffer = Vec::new(); + ControlRequestId::CheckMtuReceivedV1 + .write_to(&mut buffer) + .unwrap(); + 77u64.write_to(&mut buffer).unwrap(); + 4u32.write_to(&mut buffer).unwrap(); + buffer.extend_from_slice(&[0x0a, 0x0b, 0x00, 0x0d]); + + let err = ControlRequest::read_from(&mut &buffer[..]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + } diff --git a/packages/api_client/src/http_client.rs b/packages/api_client/src/http_client.rs index 55c2b65d..1af2f6f2 100644 --- a/packages/api_client/src/http_client.rs +++ b/packages/api_client/src/http_client.rs @@ -1,8 +1,8 @@ use std::panic::Location; use reqwest::StatusCode; -use serde::de::DeserializeOwned; use serde::Serialize; +use serde::de::DeserializeOwned; use tokio::sync::RwLock; use crate::api::{ApiResult, PlayitHttpClient}; diff --git a/packages/ping_monitor/src/lib.rs b/packages/ping_monitor/src/lib.rs deleted file mode 100644 index c2e8df0d..00000000 --- a/packages/ping_monitor/src/lib.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, -}; - -use ping_tool::PlayitPingTool; -use playit_api_client::{ - api::{ - ApiErrorNoFail, ApiResponseError, PingExperimentDetails, PingExperimentResult, PingSample, - PingTarget, ReqPingSubmit, - }, - http_client::HttpClientError, - PlayitApi, -}; -use tokio::sync::Mutex; - -pub mod ping_tool; - -pub struct PingMonitor { - api_client: PlayitApi, - tool: Arc, - senders: HashMap>, - shared: Arc, -} - -struct Shared { - results: Mutex>, - alive: AtomicBool, -} - -impl Drop for PingMonitor { - fn drop(&mut self) { - self.shared.alive.store(false, Ordering::Relaxed); - } -} - -impl PingMonitor { - pub async fn new(api_client: PlayitApi) -> Result { - let shared = Arc::new(Shared { - results: Mutex::new(Vec::new()), - alive: AtomicBool::new(true), - }); - - let tool = Arc::new(PlayitPingTool::new().await?); - - tokio::spawn( - PingReceiver { - shared: shared.clone(), - tool: tool.clone(), - } - .start(), - ); - - Ok(PingMonitor { - api_client, - tool, - senders: HashMap::new(), - shared, - }) - } - - pub async fn refresh(&mut self) -> Result<(), PingMonitorError> { - { - let mut to_send = { - let mut lock = self.shared.results.lock().await; - std::mem::take(&mut *lock) - }; - - if !to_send.is_empty() { - let og_send_len = to_send.len(); - combine_experiments(&mut to_send); - tracing::info!( - "submit {} ping results, {} entries", - og_send_len, - to_send.len() - ); - - for chunk in to_send.chunks(64) { - if let Err(error) = self - .api_client - .ping_submit(ReqPingSubmit { - results: chunk.to_vec(), - }) - .await - { - tracing::error!(?error, "failed to submit ping results"); - if let ApiErrorNoFail::ApiError(ApiResponseError::Auth(_)) = error { - tracing::warn!("auth failed, removing auth from API client"); - self.api_client.get_client().remove_auth().await; - } - }; - } - } - } - - let pings = self.api_client.ping_get().await?; - let mut keys = self.senders.keys().cloned().collect::>(); - - for exp in pings.experiments { - keys.remove(&exp.id); - - if self.senders.contains_key(&exp.id) { - continue; - } - - tracing::info!(?exp, "Add Ping Experiment"); - - let sender = Arc::new(PingSender { - experiment: exp, - run: AtomicBool::new(true), - tool: self.tool.clone(), - }); - - self.senders.insert(sender.experiment.id, sender.clone()); - tokio::spawn(sender.start_sending()); - } - - /* end old senders */ - for key in keys { - if let Some(removed) = self.senders.remove(&key) { - removed.run.store(false, Ordering::Relaxed); - tracing::info!(id = key, "ping experiment removed"); - } - } - - Ok(()) - } -} - -struct PingReceiver { - tool: Arc, - shared: Arc, -} - -impl PingReceiver { - async fn start(self) { - let mut results = Vec::new(); - - while self.shared.alive.load(Ordering::Relaxed) { - if !results.is_empty() { - if let Ok(mut lock) = self.shared.results.try_lock() { - lock.extend(results.drain(..)); - } - } - - let result = - tokio::time::timeout(Duration::from_millis(200), self.tool.read_pong()).await; - let (pong, source) = match result { - Ok(Ok(pong)) => pong, - Ok(Err(error)) => { - tracing::error!(?error, "failed to read pong"); - tokio::time::sleep(Duration::from_millis(100)).await; - continue; - } - Err(_) => continue, - }; - - let experiment_id = pong.request_id >> 8; - let sample_count = ((pong.request_id >> 4) & 0xF) as u16; - let sample_num = (pong.request_id & 0xF) as u16; - - let now = epoch_milli(); - let latency = now.max(pong.content.request_now) - pong.content.request_now; - - tracing::info!( - exp_id = experiment_id, - sample_count, - sample_num, - latency, - "got pong" - ); - - results.push(PingExperimentResult { - id: experiment_id, - target: PingTarget { - ip: source.ip(), - port: source.port(), - }, - samples: vec![PingSample { - tunnel_server_id: pong.content.server_id, - dc_id: pong.content.data_center_id as u64, - server_ts: pong.content.server_now, - latency, - count: sample_count, - num: sample_num, - }], - }); - } - } -} - -fn combine_experiments(results: &mut Vec) { - results.sort_by(cmp_result); - - /* try to group entries */ - { - let mut write = 0; - let mut read = 1; - - while read < results.len() { - if cmp_result(&results[write], &results[read]) == std::cmp::Ordering::Equal { - let sample = results[read].samples.pop().unwrap(); - results[write].samples.push(sample); - - read += 1; - } else { - write += 1; - let _ = results.drain(write..read).count(); - - read = write + 1; - } - } - - results.truncate(write + 1); - } -} - -fn cmp_result(a: &PingExperimentResult, b: &PingExperimentResult) -> std::cmp::Ordering { - match a.id.cmp(&b.id) { - std::cmp::Ordering::Equal => {} - other => return other, - } - - match a.target.ip.cmp(&b.target.ip) { - std::cmp::Ordering::Equal => {} - other => return other, - } - - match a.target.port.cmp(&b.target.port) { - std::cmp::Ordering::Equal => {} - other => return other, - } - - std::cmp::Ordering::Equal -} - -struct PingSender { - experiment: PingExperimentDetails, - run: AtomicBool, - tool: Arc, -} - -impl PingSender { - async fn start_sending(self: Arc) { - while self.run.load(Ordering::Relaxed) { - self.run_experiment().await; - - let mut wait_ms = self.experiment.test_interval.min(30_000); - wait_ms += rand::random::() % (wait_ms / 3); - - tokio::time::sleep(Duration::from_millis(wait_ms)).await; - } - } - - async fn run_experiment(&self) { - let sample_count = self.experiment.samples.min(16); - let request_id = (self.experiment.id << 8) | (sample_count << 4); - - for i in 0..sample_count { - for target in self.experiment.targets.iter() { - tracing::info!(exp_id = self.experiment.id, ?target, "send ping"); - - if let Err(error) = self.tool.send_ping(request_id + i, target).await { - tracing::error!(?error, "failed to send ping"); - } - } - - tokio::time::sleep(Duration::from_millis( - self.experiment.ping_interval.min(5_000), - )) - .await; - } - } -} - -#[derive(Debug)] -pub enum PingMonitorError { - ApiError(ApiErrorNoFail), -} - -impl From> for PingMonitorError { - fn from(value: ApiErrorNoFail) -> Self { - PingMonitorError::ApiError(value) - } -} - -fn epoch_milli() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64 -} - -#[cfg(test)] -mod test { - use std::time::Duration; - - use playit_api_client::{ - api::{PingExperimentResult, PingSample, PingTarget}, - http_client::HttpClient, - PlayitApi, - }; - - use crate::{combine_experiments, PingMonitor}; - - // #[tokio::test] - // async fn test_send_pings() { - // let _ = tracing_subscriber::fmt::try_init(); - - // let mut monitor = PingMonitor::new(PlayitApi::new(HttpClient::new( - // "https://api.playit.gg".to_string(), - // None, - // ))).await.unwrap(); - - // for _ in 0..2 { - // monitor.refresh().await.unwrap(); - // tokio::time::sleep(Duration::from_secs(1)).await; - // } - // } - - #[test] - fn test_combine() { - let target_1 = PingTarget { - ip: "127.0.0.1".parse().unwrap(), - port: 1234, - }; - let target_2 = PingTarget { - ip: "127.0.0.1".parse().unwrap(), - port: 1236, - }; - let sample = PingSample { - tunnel_server_id: 1, - dc_id: 2, - server_ts: 3, - latency: 4, - count: 5, - num: 6, - }; - - let mut items = vec![ - PingExperimentResult { - id: 32, - target: target_1.clone(), - samples: vec![sample.clone()], - }, - PingExperimentResult { - id: 32, - target: target_1.clone(), - samples: vec![sample.clone()], - }, - PingExperimentResult { - id: 32, - target: target_2.clone(), - samples: vec![sample.clone()], - }, - PingExperimentResult { - id: 32, - target: target_1.clone(), - samples: vec![sample.clone()], - }, - ]; - - combine_experiments(&mut items); - assert_eq!(items.len(), 2); - assert_eq!(items[0].samples.len(), 3); - assert_eq!(items[1].samples.len(), 1); - } -} diff --git a/packages/ping_monitor/src/main.rs b/packages/ping_monitor/src/main.rs deleted file mode 100644 index 7f9af58a..00000000 --- a/packages/ping_monitor/src/main.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::time::Duration; - -use playit_api_client::{http_client::HttpClient, PlayitApi}; -use playit_ping_monitor::PingMonitor; -use rand::random; -use serde::{Deserialize, Serialize}; - -#[tokio::main] -async fn main() { - let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stdout()); - tracing_subscriber::fmt().with_writer(non_blocking).init(); - - let playit_secret = { - let content = 'load_secret: { - if let Ok(secret) = tokio::fs::read_to_string("playit.toml").await { - break 'load_secret Some(secret); - } - - let config_path = dirs::config_local_dir(); - if let Some(path) = config_path { - let config_root = path.to_string_lossy(); - let config_file = format!("{}/playit_gg/playit.toml", config_root); - - if let Ok(secret) = tokio::fs::read_to_string(&config_file).await { - break 'load_secret Some(secret); - } - } - - #[cfg(target_os = "linux")] - { - let old_path = "/etc/playit/playit.toml"; - if let Ok(secret) = tokio::fs::read_to_string(old_path).await { - break 'load_secret Some(secret); - } - } - - None - }; - - content - .and_then(|s| toml::from_str::(&s).ok()) - .and_then(|c| { - hex::decode(&c.secret_key).ok()?; - Some(c.secret_key) - }) - }; - - let mut ping_monitor = PingMonitor::new(PlayitApi::new(HttpClient::new( - "https://api.playit.gg".to_string(), - playit_secret, - ))) - .await - .unwrap(); - - loop { - if let Err(error) = ping_monitor.refresh().await { - tracing::error!(?error, "error running ping monitor"); - } - tokio::time::sleep(Duration::from_millis(3_000 + (random::() % 5_000))).await; - } -} - -#[derive(Deserialize, Serialize)] -struct Config { - secret_key: String, -} diff --git a/packages/ping_monitor/src/ping_tool.rs b/packages/ping_monitor/src/ping_tool.rs deleted file mode 100644 index 5c2d3a5b..00000000 --- a/packages/ping_monitor/src/ping_tool.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; - -use message_encoding::MessageEncoding; -use playit_agent_proto::{ - control_feed::ControlFeed, - control_messages::{ControlRequest, ControlResponse, Ping, Pong}, - rpc::ControlRpcMessage, -}; -use playit_api_client::api::PingTarget; -use tokio::net::UdpSocket; - -use crate::epoch_milli; - -pub struct PlayitPingTool { - udp6: Option, - udp4: UdpSocket, -} - -impl PlayitPingTool { - pub async fn new() -> Result { - Ok(PlayitPingTool { - udp6: UdpSocket::bind(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0)) - .await - .ok(), - udp4: UdpSocket::bind(SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0)).await?, - }) - } - - pub async fn send_ping(&self, id: u64, target: &PingTarget) -> Result { - let udp = if target.ip.is_ipv4() { - &self.udp4 - } else { - let Some(udp) = &self.udp6 else { - return Ok(false); - }; - udp - }; - - let now = epoch_milli(); - let msg = ControlRpcMessage { - request_id: id, - content: ControlRequest::Ping(Ping { - now, - current_ping: None, - session_id: None, - }), - }; - - let mut buffer = Vec::new(); - msg.write_to(&mut buffer).unwrap(); - - udp.send_to(&buffer, SocketAddr::new(target.ip, target.port)) - .await?; - Ok(true) - } - - pub async fn read_pong(&self) -> Result<(ControlRpcMessage, SocketAddr), std::io::Error> { - let mut response = Vec::with_capacity(1024); - response.resize(1024, 0u8); - - loop { - tokio::task::yield_now().await; - - /* Important: no more asyn after recv_from to ensure timeout doesn't drop data */ - let (bytes, source) = if let Some(udp6) = &self.udp6 { - let (a, b) = response.split_at_mut(512); - - tokio::select! { - res = udp6.recv_from(a) => { - let (bytes, source) = res?; - (&a[..bytes], source) - } - res = self.udp4.recv_from(b) => { - let (bytes, source) = res?; - (&b[..bytes], source) - } - } - } else { - let (bytes, source) = self.udp4.recv_from(&mut response).await?; - (&response[..bytes], source) - }; - - let mut reader = bytes; - if let Ok(data) = ControlFeed::read_from(&mut reader) { - let ControlFeed::Response(ControlRpcMessage { - request_id, - content: ControlResponse::Pong(pong), - }) = data - else { - continue; - }; - return Ok(( - ControlRpcMessage { - request_id, - content: pong, - }, - source, - )); - } - } - } -} - -#[cfg(test)] -mod test { - use std::{ - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, - }; - - use playit_api_client::api::PingTarget; - - use crate::ping_tool::PlayitPingTool; - - #[tokio::test] - async fn test_ping() { - let ping = Arc::new(PlayitPingTool::new().await.unwrap()); - let run = Arc::new(AtomicBool::new(true)); - - let send_join = { - let ping = ping.clone(); - let run = run.clone(); - - tokio::spawn(async move { - while run.load(Ordering::Relaxed) { - ping.send_ping( - 32, - &PingTarget { - ip: "209.25.140.1".parse().unwrap(), - port: 5525, - }, - ) - .await - .unwrap(); - tokio::time::sleep(Duration::from_secs(1)).await; - } - }) - }; - - let mut result = None; - for _ in 0..5 { - match tokio::time::timeout(Duration::from_secs(1), ping.read_pong()).await { - Err(_) => continue, - Ok(Err(error)) => { - eprintln!("Error: {:?}", error); - } - Ok(Ok(pong)) => { - result.replace(pong); - break; - } - } - } - - run.store(false, Ordering::Relaxed); - send_join.await.unwrap(); - - let result = result.unwrap(); - println!("Got result: {:?}", result); - } -} diff --git a/packages/agent_cli/Cargo.toml b/packages/playit-cli/Cargo.toml similarity index 78% rename from packages/agent_cli/Cargo.toml rename to packages/playit-cli/Cargo.toml index ec58e152..8e139a4f 100644 --- a/packages/agent_cli/Cargo.toml +++ b/packages/playit-cli/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/playit-cloud/playit-agent" [dependencies] tokio = { workspace = true } +tokio-util = { workspace = true } rand = { workspace = true } hex = { workspace = true } toml = { workspace = true } @@ -22,17 +23,23 @@ tracing-appender = { workspace = true } uuid = { workspace = true } dirs = { workspace = true } +chrono = "0.4" clap = { version = "4.5", features = ["derive"] } urlencoding = "2.1" -serde_yaml = "0.9" +serde_yml = "0.0.12" crossterm = "0.28" ratatui = "0.29" dotenv = "0.15.0" -playit-agent-core = { path = "../agent_core", version = "0.17.1" } +playit-agent-core = { path = "../agent_core", version = "1.0.0" } +playit-ipc = { path = "../playit-ipc", version = "1.0.0" } +playitd = { path = "../playitd", version = "1.0.0" } playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } playit-api-client = { path = "../api_client", version = "0.2.0" } # playit-ping-monitor = { path = "../ping_monitor" } +[target.'cfg(windows)'.dependencies] +whoami = "1.5" + [target.'cfg(windows)'.build-dependencies] winres = "0.1" diff --git a/packages/agent_cli/build.rs b/packages/playit-cli/build.rs similarity index 100% rename from packages/agent_cli/build.rs rename to packages/playit-cli/build.rs diff --git a/packages/playit-cli/src/client.rs b/packages/playit-cli/src/client.rs new file mode 100644 index 00000000..83fa13be --- /dev/null +++ b/packages/playit-cli/src/client.rs @@ -0,0 +1,746 @@ +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use playit_ipc::ipc::{IpcClient, get_default_socket_path}; +use playit_ipc::model::{ + AgentLifecycle, LogLevel as ServiceLogLevel, ServicePhase, ServiceUpdate, SubscribeResponse, +}; +use playitd::manager::{ensure_installed_service_running, stop_installed_service}; + +#[cfg(target_os = "linux")] +use crate::linux; +use crate::ui::{ConnectionStats, ConsoleUi, TuiApp}; +use crate::{CliError, run_setup_flow}; + +const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; +const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttachMode { + Interactive, + Stdout, +} + +enum AttachErrorContext { + Standard, + AutoCommand { + start_attempt_failed: Option, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstalledServiceStartState { + AlreadyRunning, + Started, +} + +#[derive(Debug, Clone)] +pub enum CliTarget { + InstalledService, + ExplicitSocket(String), +} + +impl CliTarget { + pub fn from_socket_path(socket_path: Option) -> Self { + match socket_path { + Some(socket_path) => Self::ExplicitSocket(socket_path), + None => Self::InstalledService, + } + } + + pub fn socket_path(&self) -> &str { + match self { + Self::InstalledService => get_default_socket_path(), + Self::ExplicitSocket(path) => path.as_str(), + } + } +} + +pub async fn run_attach_command(target: &CliTarget, mode: AttachMode) -> Result<(), CliError> { + run_attach_command_with_context(target, mode, AttachErrorContext::Standard).await +} + +pub async fn run_auto_command( + console: &mut ConsoleUi, + target: &CliTarget, + attach_mode: AttachMode, +) -> Result<(), CliError> { + let start_attempt_failed = match target { + CliTarget::InstalledService => ensure_installed_service_running_for_cli(Some(console)) + .await + .err() + .map(|error| error.to_string()), + CliTarget::ExplicitSocket(_) => None, + }; + + let mut client = connect_target(target).await.map_err(|_| { + initial_attach_error( + target, + &AttachErrorContext::AutoCommand { + start_attempt_failed: start_attempt_failed.clone(), + }, + ) + })?; + + match wait_for_auto_lifecycle(&mut client).await? { + AgentLifecycle::Running(_) => {} + AgentLifecycle::WaitingForSecret => { + run_setup_flow(console, target).await?; + } + AgentLifecycle::HasInvalidSecret(error) => { + let should_reset = console + .yn_question( + format!( + "The playit service has an invalid secret: {}.\nReset it now and run setup again?", + error.message + ), + Some(false), + ) + .await?; + + if !should_reset { + return Err(CliError::ServiceError( + "The playit service has an invalid secret. Run `playit reset`, then run `playit` again to set up this agent." + .to_string(), + )); + } + + reset_service_secret_for_setup(target).await?; + wait_for_service_waiting_for_secret(target).await?; + run_setup_flow(console, target).await?; + } + AgentLifecycle::DisabledOverLimit(_) => { + return Err(CliError::ServiceError(format!( + "{}\n{}", + agent_over_limit_title(), + agent_over_limit_guidance() + ))); + } + AgentLifecycle::Starting => { + return Err(CliError::ServiceError( + "Timed out while waiting for the playit service to finish starting. Try `playit status` to check its current state." + .to_string(), + )); + } + AgentLifecycle::Stopping => { + return Err(CliError::ServiceError( + "The playit service is stopping. Try again in a few seconds.".to_string(), + )); + } + AgentLifecycle::Error(error) => { + return Err(CliError::ServiceError(format!( + "The playit service reported an error and cannot continue: {}", + error.message + ))); + } + } + + run_attach_command_with_context( + target, + attach_mode, + AttachErrorContext::AutoCommand { + start_attempt_failed, + }, + ) + .await +} + +async fn wait_for_auto_lifecycle(client: &mut IpcClient) -> Result { + for _ in 0..50 { + let lifecycle = client.lifecycle().await.map_err(|error| { + CliError::IpcError(format!("Failed to read playitd lifecycle: {error}")) + })?; + + if !matches!(lifecycle, AgentLifecycle::Starting) { + return Ok(lifecycle); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Ok(AgentLifecycle::Starting) +} + +async fn reset_service_secret_for_setup(target: &CliTarget) -> Result<(), CliError> { + let mut client = connect_target(target).await?; + let response = client + .reset_secret() + .await + .map_err(|error| CliError::IpcError(format!("Failed to reset secret: {error}")))?; + + if !response.accepted { + return Err(CliError::IpcError(response.message.unwrap_or_else(|| { + "playitd rejected the reset request".to_string() + }))); + } + + Ok(()) +} + +async fn wait_for_service_waiting_for_secret(target: &CliTarget) -> Result<(), CliError> { + for _ in 0..50 { + let mut client = match connect_target(target).await { + Ok(client) => client, + Err(_) => { + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + + let lifecycle = client.lifecycle().await.map_err(|error| { + CliError::IpcError(format!("Failed to read playitd lifecycle: {error}")) + })?; + + match lifecycle { + AgentLifecycle::WaitingForSecret => return Ok(()), + AgentLifecycle::HasInvalidSecret(_) + | AgentLifecycle::DisabledOverLimit(_) + | AgentLifecycle::Running(_) + | AgentLifecycle::Starting => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + AgentLifecycle::Stopping => { + return Err(CliError::ServiceError( + "The playit service is stopping and did not become ready for setup after the reset." + .to_string(), + )); + } + AgentLifecycle::Error(error) => { + return Err(CliError::ServiceError(format!( + "The playit service reported an error after the reset: {}", + error.message + ))); + } + } + } + + Err(CliError::ServiceError( + "Timed out while waiting for the playit service to become ready for setup after the reset." + .to_string(), + )) +} + +async fn run_attach_command_with_context( + target: &CliTarget, + mode: AttachMode, + error_context: AttachErrorContext, +) -> Result<(), CliError> { + let mut client = connect_target(target) + .await + .map_err(|_| initial_attach_error(target, &error_context))?; + + let subscribe = client + .subscribe() + .await + .map_err(|_| initial_attach_error(target, &error_context))?; + + match mode { + AttachMode::Interactive => run_attach_tui_session(client, target, subscribe).await, + AttachMode::Stdout => run_attach_stdout_session(client, target).await, + } +} + +async fn run_attach_tui_session( + mut client: IpcClient, + target: &CliTarget, + subscribe: SubscribeResponse, +) -> Result<(), CliError> { + let mut tui = TuiApp::new(); + tui.apply_status(subscribe.snapshot.status); + tui.apply_lifecycle(subscribe.snapshot.lifecycle); + tui.set_stats(ConnectionStats::from(subscribe.snapshot.stats)); + + let _close_guard = crate::signal_handle::get_signal_handle().close_guard(); + + loop { + tokio::select! { + update_result = client.recv_update() => { + match update_result { + Ok(update) => apply_tui_update(&mut tui, update), + Err(error) => { + tui.shutdown()?; + println!("{}", attach_lost_message(target, &error.to_string())); + break; + } + } + } + _ = tokio::time::sleep(Duration::from_millis(50)) => { + match tui.tick() { + Ok(true) => {} + Ok(false) => { + tui.shutdown()?; + print_detach_message(); + break; + } + Err(error) => { + tui.shutdown()?; + return Err(error); + } + } + } + } + } + + Ok(()) +} + +async fn run_attach_stdout_session( + mut client: IpcClient, + target: &CliTarget, +) -> Result<(), CliError> { + loop { + tokio::select! { + update_result = client.recv_update() => { + match update_result { + Ok(update) => apply_stdout_update(update), + Err(error) => { + eprintln!("{}", attach_lost_message(target, &error.to_string())); + break; + } + } + } + _ = tokio::signal::ctrl_c() => { + println!(); + print_detach_message(); + break; + } + } + } + + Ok(()) +} + +fn apply_tui_update(tui: &mut TuiApp, update: ServiceUpdate) { + match update { + ServiceUpdate::Lifecycle(state) => tui.apply_lifecycle(state), + ServiceUpdate::Status(status) => tui.apply_status(status), + ServiceUpdate::Stats(stats) => tui.set_stats(stats.into()), + ServiceUpdate::Log(entry) => tui.push_service_log(entry), + } +} + +fn apply_stdout_update(update: ServiceUpdate) { + if let ServiceUpdate::Log(entry) = update { + println!( + "{} {:>5} {}: {}", + format_timestamp_millis(entry.timestamp), + format_log_level(&entry.level), + entry.target, + entry.message + ); + } +} + +fn print_detach_message() { + println!("Detached from service. Service continues running in background."); + println!("Use 'playit stop' to stop the service."); +} + +pub async fn run_start_command( + console: &mut ConsoleUi, + target: &CliTarget, +) -> Result<(), CliError> { + if let CliTarget::ExplicitSocket(path) = target { + return Err(CliError::ServiceError(format!( + "`playit start` only manages the installed background service. Remove `--socket-path {path}` or start that daemon manually." + ))); + } + + match ensure_installed_service_running_for_cli(Some(console)).await? { + InstalledServiceStartState::AlreadyRunning => { + println!("The playit service is already running.") + } + InstalledServiceStartState::Started => println!("The playit service started."), + } + println!("Run \"playit attach\" to view logs and tunnel status."); + Ok(()) +} + +pub async fn run_stop_command(target: &CliTarget) -> Result<(), CliError> { + match target { + CliTarget::InstalledService => { + let mut direct_stop_fallback = true; + + match connect_target(target).await { + Ok(mut client) => match client.stop().await { + Ok(response) if response.accepted => { + direct_stop_fallback = false; + println!("Asked the playit service to stop."); + tokio::time::sleep(Duration::from_secs(1)).await; + } + Ok(response) => { + tracing::warn!( + "playitd rejected stop request: {}", + response + .message + .unwrap_or_else(|| "service rejected stop request".to_string()) + ); + } + Err(error) => { + tracing::warn!("Failed to send stop via IPC: {error}"); + eprintln!( + "Could not reach the playit service over IPC. Trying the system service manager instead." + ); + } + }, + Err(error) => { + tracing::warn!("Failed to connect to installed service over IPC: {error}"); + eprintln!( + "Could not reach the playit service over IPC. Trying the system service manager instead." + ); + } + } + + if direct_stop_fallback { + #[cfg(target_os = "linux")] + if !linux::installed_service_is_active()? { + println!("The playit service is already stopped."); + return Ok(()); + } + + if let Err(error) = stop_installed_service() { + tracing::warn!("Failed to stop installed service: {error}"); + } + } + + tokio::time::sleep(Duration::from_millis(500)).await; + if !IpcClient::is_running(get_default_socket_path()).await { + println!("The playit service stopped."); + } else { + println!("The playit service may still be running. Run `playit status` to check."); + } + + Ok(()) + } + CliTarget::ExplicitSocket(path) => { + let mut client = connect_target(target).await?; + let response = client.stop().await.map_err(|error| { + CliError::IpcError(format!("Failed to stop daemon at {path}: {error}")) + })?; + + if !response.accepted { + return Err(CliError::IpcError(response.message.unwrap_or_else(|| { + format!("playitd rejected stop request for {path}") + }))); + } + + println!("playitd stop requested for socket {path}"); + tokio::time::sleep(Duration::from_secs(1)).await; + + if !IpcClient::is_running(path.as_str()).await { + println!("playitd daemon stopped"); + } else { + println!( + "The playit daemon may still be running. Check the daemon process for socket {path}." + ); + } + + Ok(()) + } + } +} + +pub async fn run_status_command(target: &CliTarget) -> Result<(), CliError> { + if !IpcClient::is_running(target.socket_path()).await { + match target { + CliTarget::InstalledService => println!("The playit service is not running."), + CliTarget::ExplicitSocket(path) => { + println!("The playit daemon is not reachable at socket {path}.") + } + } + return Ok(()); + } + + let mut client = connect_target(target).await?; + + match client.status().await { + Ok(status) => { + match target { + CliTarget::InstalledService => println!("playit service status:"), + CliTarget::ExplicitSocket(path) => { + println!("playitd daemon status for socket {path}:") + } + } + println!(" Phase: {}", format_service_phase(&status.phase)); + println!(" PID: {}", status.pid); + println!(" Uptime: {} seconds", status.uptime_secs); + println!(" Version: {}", status.version); + println!(" Socket: {}", status.socket_path); + match &status.secret_path { + Some(secret_path) => println!(" Secret path: {}", secret_path), + None => println!(" Secret path: "), + } + println!(" Secret configured: {}", status.has_secret); + println!(" IPC version: {}", status.protocol.ipc_version); + if !status.protocol.capabilities.is_empty() { + println!(" Capabilities: {:?}", status.protocol.capabilities); + } + if matches!(status.phase, ServicePhase::DisabledOverLimit) { + println!(" Message:"); + for line in agent_over_limit_title().lines() { + println!(" {line}"); + } + for line in agent_over_limit_guidance().lines() { + println!(" {line}"); + } + } + if let Some(error) = status.last_error { + println!(" Last error: {}", error.message); + } + } + Err(_) => return Err(ipc_connection_error()), + } + + Ok(()) +} + +pub async fn ensure_service_waiting_for_secret( + console: &mut ConsoleUi, + target: &CliTarget, +) -> Result<(), CliError> { + if matches!(target, CliTarget::InstalledService) { + ensure_installed_service_running_for_cli(Some(console)).await?; + } + + let mut client = connect_target(target).await?; + let lifecycle = client.lifecycle().await.map_err(|error| { + CliError::IpcError(format!("Failed to read playitd lifecycle: {error}")) + })?; + + match lifecycle { + AgentLifecycle::WaitingForSecret => Ok(()), + AgentLifecycle::HasInvalidSecret(error) => Err(CliError::ServiceError(format!( + "Setup cannot continue because the playit service has an invalid secret: {}. Run `playit reset`, then run `playit setup` again.", + error.message + ))), + AgentLifecycle::DisabledOverLimit(_) => Err(CliError::ServiceError(format!( + "{}\n{}", + "Setup is unavailable because this account is over the agent limit.", + agent_over_limit_guidance() + ))), + AgentLifecycle::Starting => Err(CliError::ServiceError( + "The playit service is still starting. Try setup again in a few seconds.".to_string(), + )), + AgentLifecycle::Running(_) => Err(CliError::ServiceError( + "The playit service already has a configured secret. Run `playit reset` before claiming a new agent." + .to_string(), + )), + AgentLifecycle::Stopping => Err(CliError::ServiceError( + "The playit service is stopping. Try setup again after it stops.".to_string(), + )), + AgentLifecycle::Error(error) => Err(CliError::ServiceError(format!( + "The playit service reported an error and is not ready for setup: {}", + error.message + ))), + } +} + +pub async fn provision_service_secret( + console: &mut ConsoleUi, + target: &CliTarget, + secret: &str, +) -> Result<(), CliError> { + if matches!(target, CliTarget::InstalledService) { + ensure_installed_service_running_for_cli(Some(console)).await?; + } + + let mut client = connect_target(target).await?; + let response = client + .set_secret(secret) + .await + .map_err(|error| CliError::IpcError(format!("Failed to provision secret: {error}")))?; + + if !response.accepted { + return Err(CliError::IpcError( + response + .message + .unwrap_or_else(|| "playitd rejected the secret".to_string()), + )); + } + + Ok(()) +} + +pub async fn run_reset_command(target: &CliTarget) -> Result<(), CliError> { + let mut client = connect_target(target).await?; + let reset_response = client + .reset_secret() + .await + .map_err(|error| CliError::IpcError(format!("Failed to reset secret: {error}")))?; + + if !reset_response.accepted { + return Err(CliError::IpcError(reset_response.message.unwrap_or_else( + || "playitd rejected the reset request".to_string(), + ))); + } + + let stop_response = client.stop().await.map_err(|error| { + CliError::IpcError(format!( + "Secret was reset, but failed to stop playitd: {error}" + )) + })?; + + if !stop_response.accepted { + return Err(CliError::IpcError(stop_response.message.unwrap_or_else( + || "Secret was reset, but playitd rejected the stop request".to_string(), + ))); + } + + let reset_message = reset_response + .message + .unwrap_or_else(|| "playitd reset the secret file".to_string()); + let stop_message = stop_response + .message + .unwrap_or_else(|| "shutdown requested".to_string()); + + println!("{reset_message}"); + println!("playitd stop requested: {stop_message}"); + Ok(()) +} + +pub async fn run_secret_path_command(target: &CliTarget) -> Result<(), CliError> { + let mut client = connect_target(target).await?; + let response = client + .get_secret_path() + .await + .map_err(|error| CliError::IpcError(format!("Failed to read secret path: {error}")))?; + + let Some(secret_path) = response.secret_path else { + return Err(CliError::IpcError( + "playitd is using an inline --secret, so no secret file path is available".to_string(), + )); + }; + + println!("{secret_path}"); + Ok(()) +} + +pub async fn run_account_login_url_command(target: &CliTarget) -> Result<(), CliError> { + let mut client = connect_target(target).await?; + let response = client.get_account_login_url().await.map_err(|error| { + CliError::IpcError(format!("Failed to create account login URL: {error}")) + })?; + + println!("{}", response.login_url); + Ok(()) +} + +async fn connect_target(target: &CliTarget) -> Result { + IpcClient::connect_with_path(target.socket_path()) + .await + .map_err(|_| ipc_connection_error()) +} + +async fn ensure_installed_service_running_for_cli( + console: Option<&mut ConsoleUi>, +) -> Result { + if IpcClient::is_running(get_default_socket_path()).await { + return Ok(InstalledServiceStartState::AlreadyRunning); + } + + #[cfg(target_os = "linux")] + { + if linux::prepare_installed_service_for_cli(console).await? { + return Ok(InstalledServiceStartState::AlreadyRunning); + } + } + + ensure_installed_service_running() + .await + .map_err(|error| CliError::ServiceError(format!("Failed to start service: {error}")))?; + + Ok(InstalledServiceStartState::Started) +} + +fn ipc_connection_error() -> CliError { + CliError::IpcError( + "Could not connect to the playit service. Start it with `playit start`, then try again." + .to_string(), + ) +} + +fn initial_attach_error(target: &CliTarget, error_context: &AttachErrorContext) -> CliError { + match error_context { + AttachErrorContext::Standard => ipc_connection_error(), + AttachErrorContext::AutoCommand { + start_attempt_failed, + } => auto_attach_error(target, start_attempt_failed.as_deref()), + } +} + +pub(crate) fn auto_attach_error( + target: &CliTarget, + start_attempt_failed: Option<&str>, +) -> CliError { + match target { + CliTarget::InstalledService => match start_attempt_failed { + #[cfg(target_os = "linux")] + Some(error) if linux::is_linux_socket_access_message(error) => { + CliError::IpcError(error.to_string()) + } + Some(error) if error.starts_with("The playit service is running, but") => { + CliError::IpcError(error.to_string()) + } + Some(error) => CliError::IpcError(format!( + "Could not connect to the playit service. playit also tried to start it first, but startup failed: {error}" + )), + None => CliError::IpcError( + "Could not connect to the playit service. playit tried to start it first, but it is still not reachable." + .to_string(), + ), + }, + CliTarget::ExplicitSocket(_) => ipc_connection_error(), + } +} + +fn attach_lost_message(target: &CliTarget, error: &str) -> String { + match target { + CliTarget::InstalledService => { + format!( + "Connection to the playit service was lost: {error}. Run \"playit attach\" to reconnect." + ) + } + CliTarget::ExplicitSocket(path) => format!( + "Connection to the playit daemon was lost: {error}. Reattach with \"playit attach --socket-path {}\" once the daemon is reachable again.", + path + ), + } +} + +fn format_timestamp_millis(millis: u64) -> String { + DateTime::::from_timestamp_millis(millis as i64) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()) + .unwrap_or_else(|| format!("{millis}ms")) +} + +fn format_service_phase(phase: &ServicePhase) -> &'static str { + match phase { + ServicePhase::WaitingForSecret => "waiting for secret", + ServicePhase::HasInvalidSecret => "invalid secret", + ServicePhase::DisabledOverLimit => "disabled over limit", + ServicePhase::Starting => "starting", + ServicePhase::Running => "running", + ServicePhase::Stopping => "stopping", + ServicePhase::Error => "error", + } +} + +fn agent_over_limit_guidance() -> String { + format!( + "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" + ) +} + +fn agent_over_limit_title() -> &'static str { + "The playit service cannot start because this account is over the agent limit." +} + +fn format_log_level(level: &ServiceLogLevel) -> &'static str { + match level { + ServiceLogLevel::Trace => "TRACE", + ServiceLogLevel::Debug => "DEBUG", + ServiceLogLevel::Info => "INFO", + ServiceLogLevel::Warn => "WARN", + ServiceLogLevel::Error => "ERROR", + } +} diff --git a/packages/playit-cli/src/linux.rs b/packages/playit-cli/src/linux.rs new file mode 100644 index 00000000..c01c0b86 --- /dev/null +++ b/packages/playit-cli/src/linux.rs @@ -0,0 +1,250 @@ +use std::{ + fs, + os::unix::fs::{FileTypeExt, MetadataExt}, + path::Path, + time::Duration, +}; + +use playit_ipc::ipc::{IpcClient, get_default_socket_path}; +use playitd::manager::is_systemd_service_active; +use playitd::unix_account::{ + current_process_has_group, current_user_account, current_user_is_root, effective_gid, + effective_uid, group_info_by_gid, +}; + +use crate::{CliError, ui::ConsoleUi}; + +const PLAYIT_GROUP_NAME: &str = "playit"; + +pub(crate) async fn prepare_installed_service_for_cli( + console: Option<&mut ConsoleUi>, +) -> Result { + if installed_service_is_active()? { + for _ in 0..20 { + if IpcClient::is_running(get_default_socket_path()).await { + return Ok(true); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + + return Err(CliError::ServiceError( + installed_service_unreachable_message(), + )); + } + + if let Some(console) = console { + let should_start = console + .yn_question(service_start_prompt(current_user_is_root()), Some(true)) + .await?; + + if !should_start { + return Err(CliError::ServiceError( + "The playit service is not running. Start it with `sudo systemctl start playit`, then run `playit` again." + .to_string(), + )); + } + } + + Ok(false) +} + +pub(crate) fn installed_service_is_active() -> Result { + is_systemd_service_active() + .map_err(|error| CliError::ServiceError(format!("Failed to check service status: {error}"))) +} + +pub(crate) fn is_linux_socket_access_message(message: &str) -> bool { + message.starts_with("The playit service is running, but") +} + +fn service_start_prompt(is_root: bool) -> String { + let command = if is_root { + "systemctl start playit" + } else { + "sudo systemctl start playit" + }; + let mut prompt = format!( + "The playit service is not running.\nStart it now so playit can run in the background?\n\nCommand: {command}", + ); + + if !is_root { + prompt.push_str("\nYou may be asked for your password."); + } + + prompt +} + +fn installed_service_unreachable_message() -> String { + let socket_path = get_default_socket_path(); + + match socket_access_issue(socket_path) { + Some(issue) => format_socket_access_issue(socket_path, &issue), + None => format!( + "The playit service is running, but this shell cannot reach its IPC socket:\n {socket_path}\n\nTry running `playit status` again in a few seconds. If it still fails, restart the service with:\n sudo systemctl restart playit" + ), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LinuxSocketAccessIssue { + MissingSocket, + InspectFailed(String), + NotASocket, + PlayitGroupJoinRequired, + PlayitGroupRefreshRequired, + GenericPermissionDenied { + current_uid: u32, + current_gid: u32, + socket_uid: u32, + socket_gid: u32, + socket_mode: u32, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LinuxGroupInfo { + name: String, + members: Vec, +} + +fn socket_access_issue(socket_path: &str) -> Option { + let path = Path::new(socket_path); + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Some(LinuxSocketAccessIssue::MissingSocket); + } + Err(error) => return Some(LinuxSocketAccessIssue::InspectFailed(error.to_string())), + }; + + if !metadata.file_type().is_socket() { + return Some(LinuxSocketAccessIssue::NotASocket); + } + + let current_uid = effective_uid(); + let current_gid = effective_gid(); + let socket_uid = metadata.uid(); + let socket_gid = metadata.gid(); + let socket_mode = metadata.mode() & 0o777; + let socket_group = lookup_group_info(socket_gid); + let socket_group_name = socket_group.as_ref().map(|group| group.name.as_str()); + + if current_user_can_write_socket(&metadata) { + return None; + } + + if socket_group_name == Some(PLAYIT_GROUP_NAME) { + match current_user_account_is_configured_for_group(socket_gid, socket_group.as_ref()) { + Some(true) => return Some(LinuxSocketAccessIssue::PlayitGroupRefreshRequired), + Some(false) => return Some(LinuxSocketAccessIssue::PlayitGroupJoinRequired), + None => {} + } + } + + Some(LinuxSocketAccessIssue::GenericPermissionDenied { + current_uid, + current_gid, + socket_uid, + socket_gid, + socket_mode, + }) +} + +fn format_socket_access_issue(socket_path: &str, issue: &LinuxSocketAccessIssue) -> String { + match issue { + LinuxSocketAccessIssue::MissingSocket => { + format!( + "The playit service is running, but its IPC socket does not exist yet:\n {socket_path}\n\nRestart the service, then try again:\n sudo systemctl restart playit" + ) + } + LinuxSocketAccessIssue::InspectFailed(error) => { + format!( + "The playit service is running, but playit could not inspect its IPC socket:\n {socket_path}\n\nError: {error}" + ) + } + LinuxSocketAccessIssue::NotASocket => { + format!( + "The playit service is running, but this path is not a Unix socket:\n {socket_path}\n\nRemove or rename that file, then restart the service:\n sudo systemctl restart playit" + ) + } + LinuxSocketAccessIssue::PlayitGroupJoinRequired => { + format_playit_group_join_message(socket_path) + } + LinuxSocketAccessIssue::PlayitGroupRefreshRequired => { + format_playit_group_refresh_message(socket_path) + } + LinuxSocketAccessIssue::GenericPermissionDenied { + current_uid, + current_gid, + socket_uid, + socket_gid, + socket_mode, + } => format!( + "The playit service is running, but this user cannot access its IPC socket:\n {socket_path}\n\nCurrent user uid={current_uid}, gid={current_gid}\nSocket owner uid={socket_uid}, gid={socket_gid}, mode={socket_mode:o}\n\nCheck the socket permissions or run playit from a user that can access this socket." + ), + } +} + +fn format_playit_group_join_message(socket_path: &str) -> String { + format!( + "The playit service is running, but this shell cannot access its IPC socket:\n {socket_path}\n\nThe socket is restricted to the `playit` group. Add your user to that group:\n sudo usermod -aG playit $USER\n\nThen refresh group membership for this shell:\n newgrp playit\n\nAfter that, run:\n playit" + ) +} + +fn format_playit_group_refresh_message(socket_path: &str) -> String { + format!( + "The playit service is running, but this shell cannot access its IPC socket:\n {socket_path}\n\nYour user is already in the `playit` group, but this shell has not picked up that membership yet.\n\nRefresh group membership for this shell:\n newgrp playit\n\nThen run:\n playit" + ) +} + +fn current_user_account_is_configured_for_group( + target_gid: u32, + group: Option<&LinuxGroupInfo>, +) -> Option { + let account = current_user_account()?; + + if account.primary_gid == target_gid { + return Some(true); + } + + let group = group?; + Some( + group + .members + .iter() + .any(|member| member == &account.username), + ) +} + +fn current_user_can_write_socket(metadata: &fs::Metadata) -> bool { + let mode = metadata.mode(); + let uid = metadata.uid(); + let gid = metadata.gid(); + let current_uid = effective_uid(); + + if current_uid == 0 { + return true; + } + + if current_uid == uid { + return mode & 0o200 != 0; + } + + if current_user_in_group(gid) { + return mode & 0o020 != 0; + } + + mode & 0o002 != 0 +} + +fn current_user_in_group(target_gid: u32) -> bool { + current_process_has_group(target_gid) +} + +fn lookup_group_info(group_gid: u32) -> Option { + group_info_by_gid(group_gid).map(|group| LinuxGroupInfo { + name: group.name, + members: group.members, + }) +} diff --git a/packages/playit-cli/src/main.rs b/packages/playit-cli/src/main.rs new file mode 100644 index 00000000..fe8c249d --- /dev/null +++ b/packages/playit-cli/src/main.rs @@ -0,0 +1,460 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::sync::LazyLock; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use client::{ + AttachMode, CliTarget, ensure_service_waiting_for_secret, provision_service_secret, + run_account_login_url_command, run_attach_command, run_auto_command, run_reset_command, + run_secret_path_command, run_start_command, run_status_command, run_stop_command, +}; +use playit_agent_core::agent_control::platform::current_platform; +use playit_agent_core::agent_control::version::{help_register_version, register_platform}; +use rand::Rng; +use tracing_subscriber::EnvFilter; +use uuid::Uuid; + +use playit_agent_core::agent_control::errors::SetupError; +use playit_agent_core::utils::now_milli; +use playit_api_client::http_client::HttpClientError; +use playit_api_client::{PlayitApi, api::*}; + +use crate::signal_handle::get_signal_handle; +use crate::ui::{ConsoleUi, UISettings}; + +pub static API_BASE: LazyLock = + LazyLock::new(|| dotenv::var("API_BASE").unwrap_or("https://api.playit.gg".to_string())); + +mod client; +#[cfg(target_os = "linux")] +mod linux; +pub mod signal_handle; +pub mod ui; +pub mod util; + +#[derive(Parser)] +#[command(name = "playit-cli")] +struct Cli { + /// Prints logs to stdout + #[arg(short = 's', long)] + stdout: bool, + + /// Override the IPC socket or named pipe used to reach playitd + #[arg(long)] + socket_path: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Print version information + Version, + + /// Attach to a running playitd service + Attach { + /// Print logs to stdout instead of using TUI + #[arg(short = 's', long)] + stdout: bool, + }, + + /// Start the installed playitd service + Start, + + /// Stop the installed playitd service + Stop, + + /// Show the status of the installed playitd service + Status, + + /// Removes the secret key on your system so the playit agent can be re-claimed + Reset, + + /// Shows the file path where the playit secret can be found + SecretPath, + + /// Setup playit by provisioning a new secret to playitd + Setup, + + /// Account management commands + Account { + #[command(subcommand)] + command: AccountCommands, + }, + + /// Setting up a new playit agent + #[command( + about = "Setting up a new playit agent", + long_about = "Provides a URL that can be visited to claim the agent and generate a secret key" + )] + Claim { + #[command(subcommand)] + command: ClaimCommands, + }, +} + +#[derive(Subcommand)] +enum AccountCommands { + /// Generates a link to allow user to login + LoginUrl, +} + +#[derive(Subcommand)] +enum ClaimCommands { + /// Generates a random claim code + Generate, + + /// Print a claim URL given the code and options + Url { + /// Claim code + claim_code: String, + + /// Name for the agent + #[arg(long, default_value = "from-cli")] + name: String, + + /// The agent type + #[arg(long, default_value = "self-managed")] + r#type: String, + }, + + /// Exchanges the claim for the secret key + Exchange { + /// Claim code (see "claim generate") + claim_code: String, + + /// Number of seconds to wait (0=infinite) + #[arg(long, default_value = "0")] + wait: u32, + }, +} + +#[tokio::main] +async fn main() -> std::process::ExitCode { + match run_cli().await { + Ok(code) => code, + Err(error) => { + eprintln!("{error}"); + std::process::ExitCode::FAILURE + } + } +} + +async fn run_cli() -> Result { + let cli = Cli::parse(); + + /* register docker */ + { + let platform = current_platform(); + + register_platform(platform); + + help_register_version( + env!("CARGO_PKG_VERSION"), + "308943e8-faef-4835-a2ba-270351f72aa3", + ); + } + + let target = CliTarget::from_socket_path(cli.socket_path.clone()); + let attach_stdout = matches!(&cli.command, Some(Commands::Attach { stdout: true, .. })); + let stdout_mode = cli.stdout || attach_stdout; + let attach_mode = if stdout_mode { + AttachMode::Stdout + } else { + AttachMode::Interactive + }; + + let _guard = if stdout_mode { + Some(init_stdout_tracing()) + } else { + None + }; + + let mut console = ConsoleUi::new(UISettings { auto_answer: None }); + + match cli.command { + None => { + run_auto_command(&mut console, &target, attach_mode).await?; + } + Some(Commands::Attach { stdout }) => { + let attach_mode = if cli.stdout || stdout { + AttachMode::Stdout + } else { + AttachMode::Interactive + }; + run_attach_command(&target, attach_mode).await?; + } + Some(Commands::Start) => { + run_start_command(&mut console, &target).await?; + } + Some(Commands::Stop) => { + run_stop_command(&target).await?; + } + Some(Commands::Status) => { + run_status_command(&target).await?; + } + Some(Commands::Version) => println!("{}", env!("CARGO_PKG_VERSION")), + Some(Commands::Setup) => { + run_setup_flow(&mut console, &target).await?; + } + Some(Commands::Reset) => { + run_reset_command(&target).await?; + } + Some(Commands::SecretPath) => { + run_secret_path_command(&target).await?; + } + Some(Commands::Account { ref command }) => match command { + AccountCommands::LoginUrl => { + run_account_login_url_command(&target).await?; + } + }, + Some(Commands::Claim { command }) => match command { + ClaimCommands::Generate => { + console.write_screen(claim_generate()).await; + } + ClaimCommands::Url { claim_code, .. } => { + console + .write_screen(claim_url(&claim_code)?.to_string()) + .await; + } + ClaimCommands::Exchange { claim_code, wait } => { + let secret_key = + claim_exchange(&mut console, &claim_code, ClaimAgentType::SelfManaged, wait) + .await?; + console.write_screen(secret_key).await; + } + }, + } + + Ok(std::process::ExitCode::SUCCESS) +} + +fn init_stdout_tracing() -> tracing_appender::non_blocking::WorkerGuard { + let log_filter = + EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); + tracing_subscriber::fmt() + .with_ansi(current_platform() == Platform::Linux) + .with_writer(non_blocking) + .with_env_filter(log_filter) + .init(); + guard +} + +pub async fn run_setup_flow(console: &mut ConsoleUi, target: &CliTarget) -> Result<(), CliError> { + ensure_service_waiting_for_secret(console, target).await?; + + let claim_code = claim_generate(); + console + .write_screen(format!( + "Open this link to finish setting up playit:\n{}", + claim_url(&claim_code)? + )) + .await; + + let key = claim_exchange(console, &claim_code, ClaimAgentType::Assignable, 0).await?; + provision_service_secret(console, target, &key).await?; + + let api = PlayitApi::create(API_BASE.to_string(), Some(key)); + if let Ok(session) = api.login_guest().await { + console + .write_screen(format!( + "Guest login:\nhttps://playit.gg/login/guest-account/{}", + session.session_key + )) + .await; + tokio::time::sleep(Duration::from_secs(10)).await; + } + + console + .write_screen("playit setup is complete. The background service is ready.") + .await; + Ok(()) +} + +pub fn claim_generate() -> String { + let mut buffer = [0u8; 5]; + rand::rng().fill(&mut buffer); + hex::encode(&buffer) +} + +pub fn claim_url(code: &str) -> Result { + if hex::decode(code).is_err() { + return Err(CliError::InvalidClaimCode); + } + + Ok(format!("https://playit.gg/claim/{}", code,)) +} + +pub async fn claim_exchange( + console: &mut ConsoleUi, + claim_code: &str, + agent_type: ClaimAgentType, + wait_sec: u32, +) -> Result { + let api = PlayitApi::create(API_BASE.to_string(), None); + + let end_at = if wait_sec == 0 { + u64::MAX + } else { + now_milli() + (wait_sec as u64) * 1000 + }; + + { + let _close_guard = get_signal_handle().close_guard(); + let mut last_message = "Preparing setup...".to_string(); + + loop { + let setup_res = api + .claim_setup(ReqClaimSetup { + code: claim_code.to_string(), + agent_type, + version: format!("playit {}", env!("CARGO_PKG_VERSION")), + }) + .await; + + let setup = match setup_res { + Ok(v) => v, + Err(error) => { + tracing::error!(?error, "Failed loading claim setup"); + console + .write_screen(format!("{}\n\nError: {:?}", last_message, error)) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + last_message = match setup { + ClaimSetupResponse::WaitingForUserVisit => { + format!( + "Open this link to finish setting up playit:\n{}", + claim_url(claim_code)? + ) + } + ClaimSetupResponse::WaitingForUser => { + format!( + "Approve this program in your browser:\n{}", + claim_url(claim_code)? + ) + } + ClaimSetupResponse::UserAccepted => { + console + .write_screen("Program approved. Finishing setup...") + .await; + break; + } + ClaimSetupResponse::UserRejected => { + console + .write_screen("Setup was not approved in the browser.") + .await; + tokio::time::sleep(Duration::from_secs(3)).await; + return Err(CliError::AgentClaimRejected); + } + }; + + console.write_screen(&last_message).await; + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + let secret_key = loop { + match api + .claim_exchange(ReqClaimExchange { + code: claim_code.to_string(), + }) + .await + { + Ok(res) => break res.secret_key, + Err(ApiError::Fail(status)) => { + let msg = format!( + "Waiting for claim code \"{}\" to be approved: {:?}", + claim_code, status + ); + console.write_screen(msg).await; + } + Err(error) => return Err(error.into()), + }; + + if now_milli() > end_at { + console + .write_screen("Setup timed out before the program was approved.") + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + return Err(CliError::TimedOut); + } + + tokio::time::sleep(Duration::from_secs(2)).await; + }; + + Ok(secret_key) +} + +#[derive(Debug)] +pub enum CliError { + InvalidClaimCode, + NotImplemented, + MissingSecret, + MalformedSecret, + InvalidSecret, + RenderError(std::io::Error), + SecretFileLoadError, + SecretFileWriteError(std::io::Error), + SecretFilePathMissing, + InvalidPortType, + InvalidPortCount, + InvalidMappingOverride, + AgentClaimRejected, + InvalidConfigFile, + TunnelNotFound(Uuid), + TimedOut, + AnswerNotProvided, + TunnelOverwrittenAlready(Uuid), + ResourceNotFoundAfterCreate(Uuid), + RequestError(HttpClientError), + ApiError(ApiResponseError), + ApiFail(String), + TunnelSetupError(SetupError), + ServiceError(String), + IpcError(String), +} + +impl Error for CliError {} + +impl Display for CliError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ServiceError(message) | Self::IpcError(message) | Self::ApiFail(message) => { + write!(f, "{message}") + } + _ => write!(f, "{:?}", self), + } + } +} + +impl From> for CliError { + fn from(e: ApiError) -> Self { + match e { + ApiError::ApiError(e) => CliError::ApiError(e), + ApiError::ClientError(e) => CliError::RequestError(e), + ApiError::Fail(fail) => CliError::ApiFail(serde_json::to_string(&fail).unwrap()), + } + } +} + +impl From> for CliError { + fn from(e: ApiErrorNoFail) -> Self { + match e { + ApiErrorNoFail::ApiError(e) => CliError::ApiError(e), + ApiErrorNoFail::ClientError(e) => CliError::RequestError(e), + } + } +} + +impl From for CliError { + fn from(e: SetupError) -> Self { + CliError::TunnelSetupError(e) + } +} diff --git a/packages/agent_cli/src/signal_handle.rs b/packages/playit-cli/src/signal_handle.rs similarity index 100% rename from packages/agent_cli/src/signal_handle.rs rename to packages/playit-cli/src/signal_handle.rs diff --git a/packages/playit-cli/src/ui/mod.rs b/packages/playit-cli/src/ui/mod.rs new file mode 100644 index 00000000..aa9cce76 --- /dev/null +++ b/packages/playit-cli/src/ui/mod.rs @@ -0,0 +1,116 @@ +use std::io::{Write, stdin, stdout}; + +use playit_agent_core::utils::now_milli; + +use crate::CliError; +use crate::signal_handle::get_signal_handle; + +pub mod tui_app; +pub mod widgets; + +pub use tui_app::{AgentData, ConnectionStats, TuiApp}; + +#[derive(Default, Clone)] +pub struct UISettings { + pub auto_answer: Option, +} + +pub struct ConsoleUi { + auto_answer: Option, + last_display: Option<(u64, String)>, +} + +impl ConsoleUi { + pub fn new(settings: UISettings) -> Self { + Self { + auto_answer: settings.auto_answer, + last_display: None, + } + } + + pub async fn write_screen(&mut self, content: T) { + let signal = get_signal_handle(); + if signal.is_confirming_close() { + match self + .yn_question( + format!("{content}\nClose requested, close program?"), + Some(true), + ) + .await + { + Ok(true) => std::process::exit(0), + Ok(false) => signal.decline_close(), + Err(error) => { + eprintln!("failed to ask close signal question: {error}"); + } + } + return; + } + + self.write_screen_inner(content.to_string()); + } + + fn write_screen_inner(&mut self, content: String) { + if let Some((ts, last_render)) = &self.last_display { + if now_milli() - *ts < 10_000 && content == *last_render { + return; + } + } + + println!("{content}"); + self.last_display = Some((now_milli(), content)); + } + + pub async fn yn_question( + &mut self, + question: T, + default_yes: Option, + ) -> Result { + if let Some(auto) = self.auto_answer { + return Ok(auto); + } + + let prompt = question.to_string(); + tokio::task::spawn_blocking(move || -> Result { + let prompt_suffix = match default_yes { + Some(true) => "Y/n", + Some(false) => "y/N", + None => "y/n", + }; + + loop { + print!("{prompt} ({prompt_suffix})? "); + stdout().flush().map_err(CliError::RenderError)?; + + let mut line = String::new(); + stdin() + .read_line(&mut line) + .map_err(CliError::RenderError)?; + let input = line.trim().to_lowercase(); + + if input.is_empty() { + if let Some(default) = default_yes { + return Ok(default); + } + } + + match input.as_str() { + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => println!("Please answer y or n."), + } + } + }) + .await + .map_err(|_| CliError::AnswerNotProvided)? + } + + pub async fn write_error( + &mut self, + msg: M, + error: E, + ) { + self.write_screen(format!("Got Error\nMSG: {msg}\nError: {error:?}\n")) + .await; + } +} diff --git a/packages/playit-cli/src/ui/tui_app.rs b/packages/playit-cli/src/ui/tui_app.rs new file mode 100644 index 00000000..bfc7e887 --- /dev/null +++ b/packages/playit-cli/src/ui/tui_app.rs @@ -0,0 +1,601 @@ +use std::collections::VecDeque; +use std::io::{self, Stdout, stdout}; +use std::time::Duration; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode}, +}; +use playit_ipc::model::{ + AccountStatus as ServiceAccountStatus, AgentLifecycle, AgentState as ServiceAgentState, + ConnectionStats as ServiceConnectionStats, LogEntry, LogLevel, ServiceStatus, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; + +use super::widgets::{render_header, render_help_bar, render_stats_bar}; +use crate::CliError; +use crate::signal_handle::get_signal_handle; + +const SERVICE_LOG_CAPACITY: usize = 500; +const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; +const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; + +/// Data about the running agent +#[derive(Clone, Default)] +pub struct AgentData { + pub version: String, + pub tunnels: Vec, + pub pending_tunnels: Vec, + pub notices: Vec, + pub account_status: AccountStatusInfo, + pub agent_id: String, + pub login_link: Option, + /// Start time of the agent/service in milliseconds since epoch + pub start_time: u64, +} + +#[derive(Clone, Debug)] +pub struct TunnelInfo { + pub display_address: String, + pub destination: String, + pub is_disabled: bool, + pub disabled_reason: Option, +} + +#[derive(Clone, Debug)] +pub struct PendingTunnelInfo { + pub id: String, + pub status_msg: String, +} + +#[derive(Clone, Debug)] +pub struct NoticeInfo { + pub priority: String, + pub message: String, + pub resolve_link: Option, +} + +#[derive(Clone, Default, Debug, PartialEq)] +pub enum AccountStatusInfo { + #[default] + Unknown, + Guest, + EmailNotVerified, + Verified, +} + +/// Connection statistics +#[derive(Clone, Default)] +pub struct ConnectionStats { + pub bytes_in: u64, + pub bytes_out: u64, + pub active_tcp: u32, + pub active_udp: u32, +} + +/// UI mode for TuiApp +#[derive(Clone, Debug, PartialEq)] +pub enum TuiMode { + Message { message: String }, + Running, +} + +/// Main TUI application state +pub struct TuiApp { + service_logs: VecDeque, + agent_data: AgentData, + stats: ConnectionStats, + + // UI state + mode: TuiMode, + should_quit: bool, + quit_confirm: bool, + + // Terminal + terminal: Option>>, +} + +impl TuiApp { + pub fn new() -> Self { + Self { + service_logs: VecDeque::with_capacity(SERVICE_LOG_CAPACITY), + agent_data: AgentData::default(), + stats: ConnectionStats::default(), + mode: TuiMode::Message { + message: "Initializing...".to_string(), + }, + should_quit: false, + quit_confirm: false, + terminal: None, + } + } + + pub fn set_message>(&mut self, message: T) { + self.mode = TuiMode::Message { + message: message.into(), + }; + } + + pub fn set_agent_data(&mut self, data: AgentData) { + self.agent_data = data; + self.mode = TuiMode::Running; + } + + pub fn set_stats(&mut self, stats: ConnectionStats) { + self.stats = stats; + } + + pub fn push_service_log(&mut self, entry: LogEntry) { + if self.service_logs.len() >= SERVICE_LOG_CAPACITY { + self.service_logs.pop_front(); + } + self.service_logs.push_back(entry); + } + + pub fn apply_lifecycle(&mut self, lifecycle: AgentLifecycle) { + match lifecycle { + AgentLifecycle::Running(state) => self.set_agent_data(state.into()), + AgentLifecycle::WaitingForSecret => { + self.set_message("The playit service is waiting for setup to finish."); + } + AgentLifecycle::HasInvalidSecret(error) => self.set_message(format!( + "The playit service has an invalid secret: {}", + error.message + )), + AgentLifecycle::DisabledOverLimit(_) => self.set_message(format!( + "{}\n{}", + agent_over_limit_title(), + agent_over_limit_guidance() + )), + AgentLifecycle::Starting => { + self.set_message("The playit service is starting..."); + } + AgentLifecycle::Stopping => { + self.set_message("The playit service is stopping..."); + } + AgentLifecycle::Error(error) => { + self.set_message(format!( + "The playit service reported an error: {}", + error.message + )); + } + } + } + + pub fn apply_status(&mut self, status: ServiceStatus) { + if let Some(message) = status_message(&status) { + self.set_message(message); + } + } + + /// Initialize the terminal for TUI mode + fn init_terminal(&mut self) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + self.terminal = Some(Terminal::new(backend)?); + Ok(()) + } + + /// Restore the terminal to normal mode + fn restore_terminal(&mut self) -> io::Result<()> { + if let Some(mut terminal) = self.terminal.take() { + crossterm::terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + } + Ok(()) + } + + pub fn init(&mut self) -> Result<(), CliError> { + if self.terminal.is_none() { + self.init_terminal().map_err(CliError::RenderError)?; + } + Ok(()) + } + + pub fn shutdown(&mut self) -> Result<(), CliError> { + self.restore_terminal().map_err(CliError::RenderError) + } + + /// Run one iteration of the TUI (draw + handle events) + /// Returns Ok(true) if should continue, Ok(false) if should quit + pub fn tick(&mut self) -> Result { + if self.terminal.is_none() { + self.init()?; + } + + self.draw().map_err(CliError::RenderError)?; + + if event::poll(Duration::from_millis(50)).map_err(CliError::RenderError)? { + if let Event::Key(key) = event::read().map_err(CliError::RenderError)? { + self.handle_key_event(key); + } + } + + let signal = get_signal_handle(); + if signal.is_confirming_close() && !self.quit_confirm { + self.quit_confirm = true; + } + + Ok(!self.should_quit) + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if self.quit_confirm { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.should_quit = true; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.quit_confirm = false; + get_signal_handle().decline_close(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('q') => { + self.quit_confirm = true; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.quit_confirm = true; + } + _ => {} + } + } + + fn draw(&mut self) -> io::Result<()> { + let terminal = self.terminal.as_mut().unwrap(); + + let mode = self.mode.clone(); + let agent_data = self.agent_data.clone(); + let stats = self.stats.clone(); + let start_time = agent_data.start_time; + let quit_confirm = self.quit_confirm; + let log_entries: Vec<_> = self.service_logs.iter().cloned().collect(); + + terminal.draw(|frame| { + let area = frame.area(); + + match &mode { + TuiMode::Message { message } => { + Self::render_message_screen(frame, area, message, quit_confirm); + return; + } + TuiMode::Running => {} + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(3), + Constraint::Length(10), + Constraint::Length(1), + ]) + .split(area); + + render_header(frame, chunks[0], &agent_data, start_time); + + Self::render_tunnels(frame, chunks[1], &agent_data); + + render_stats_bar(frame, chunks[2], &stats); + + Self::render_logs(frame, chunks[3], &log_entries); + + render_help_bar(frame, chunks[4], quit_confirm); + })?; + + Ok(()) + } + + fn render_tunnels(frame: &mut Frame, area: Rect, agent_data: &AgentData) { + let block = Block::default() + .title(" Tunnels ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + if agent_data.tunnels.is_empty() && agent_data.pending_tunnels.is_empty() { + let msg = if agent_data.agent_id.is_empty() { + "Loading tunnels..." + } else { + "No tunnels configured. Add one at playit.gg" + }; + let paragraph = Paragraph::new(msg) + .style(Style::default().fg(Color::Yellow)) + .block(block) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); + return; + } + + let items: Vec = agent_data + .tunnels + .iter() + .map(|tunnel| { + let (style, prefix) = if tunnel.is_disabled { + (Style::default().fg(Color::Red), "✗ ") + } else { + (Style::default().fg(Color::Green), "● ") + }; + + let content = if let Some(reason) = &tunnel.disabled_reason { + format!( + "{}{} => (disabled: {})", + prefix, tunnel.display_address, reason + ) + } else { + format!( + "{}{} => {}", + prefix, tunnel.display_address, tunnel.destination + ) + }; + + ListItem::new(content).style(style) + }) + .chain(agent_data.pending_tunnels.iter().map(|pending| { + let content = format!("◐ {} ({})", pending.id, pending.status_msg); + ListItem::new(content).style(Style::default().fg(Color::Yellow)) + })) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray), + ) + .highlight_symbol("▶ "); + + frame.render_widget(list, area); + } + + fn render_logs(frame: &mut Frame, area: Rect, log_entries: &[LogEntry]) { + let title = format!(" Service Logs ({}) [following] ", log_entries.len()); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let inner_height = area.height.saturating_sub(2) as usize; + let start = log_entries.len().saturating_sub(inner_height); + let visible_entries = log_entries.iter().skip(start).take(inner_height); + + let lines: Vec = visible_entries + .map(|entry| { + let level_style = match entry.level { + LogLevel::Error => Style::default().fg(Color::Red).bold(), + LogLevel::Warn => Style::default().fg(Color::Yellow).bold(), + LogLevel::Info => Style::default().fg(Color::Green), + LogLevel::Debug => Style::default().fg(Color::Blue), + LogLevel::Trace => Style::default().fg(Color::DarkGray), + }; + + Line::from(vec![ + Span::styled(format!("[{}] ", level_label(&entry.level)), level_style), + Span::styled( + format!( + "{}: ", + entry.target.split("::").last().unwrap_or(&entry.target) + ), + Style::default().fg(Color::DarkGray), + ), + Span::raw(&entry.message), + ]) + }) + .collect(); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + } + + fn render_message_screen(frame: &mut Frame, area: Rect, message: &str, quit_confirm: bool) { + use ratatui::layout::Alignment; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Min(10), + Constraint::Length(1), + ]) + .split(area); + + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(" playit.gg "); + + let lines: Vec = message + .lines() + .map(|line| { + if line.starts_with("http://") || line.starts_with("https://") { + Line::from(Span::styled( + line, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) + } else if line.contains("https://") || line.contains("http://") { + let mut spans = Vec::new(); + let mut remaining = line; + while let Some(pos) = remaining + .find("https://") + .or_else(|| remaining.find("http://")) + { + if pos > 0 { + spans.push(Span::styled( + &remaining[..pos], + Style::default().fg(Color::White), + )); + } + + let url_end = remaining[pos..] + .find(' ') + .map(|offset| pos + offset) + .unwrap_or(remaining.len()); + spans.push(Span::styled( + &remaining[pos..url_end], + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + remaining = &remaining[url_end..]; + } + if !remaining.is_empty() { + spans.push(Span::styled(remaining, Style::default().fg(Color::White))); + } + Line::from(spans) + } else { + Line::from(Span::styled(line, Style::default().fg(Color::White))) + } + }) + .collect(); + + let paragraph = Paragraph::new(lines) + .block(title_block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, chunks[1]); + render_help_bar(frame, chunks[2], quit_confirm); + } +} + +fn status_message(status: &ServiceStatus) -> Option { + if matches!( + status.phase, + playit_ipc::model::ServicePhase::DisabledOverLimit + ) { + return Some(format!( + "{}\n{}", + agent_over_limit_title(), + agent_over_limit_guidance() + )); + } + + if let Some(error) = &status.last_error { + return Some(format!( + "playit service status: {} ({})", + service_phase_label(status), + error.message + )); + } + + if matches!(status.phase, playit_ipc::model::ServicePhase::Running) { + None + } else { + Some(format!( + "playit service status: {}", + service_phase_label(status) + )) + } +} + +fn service_phase_label(status: &ServiceStatus) -> &'static str { + match status.phase { + playit_ipc::model::ServicePhase::WaitingForSecret => "waiting for secret", + playit_ipc::model::ServicePhase::HasInvalidSecret => "invalid secret", + playit_ipc::model::ServicePhase::DisabledOverLimit => "disabled over limit", + playit_ipc::model::ServicePhase::Starting => "starting", + playit_ipc::model::ServicePhase::Running => "running", + playit_ipc::model::ServicePhase::Stopping => "stopping", + playit_ipc::model::ServicePhase::Error => "error", + } +} + +fn agent_over_limit_guidance() -> String { + format!( + "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" + ) +} + +fn agent_over_limit_title() -> &'static str { + "The playit service cannot start because this account is over the agent limit." +} + +fn level_label(level: &LogLevel) -> &'static str { + match level { + LogLevel::Trace => "TRACE", + LogLevel::Debug => "DEBUG", + LogLevel::Info => "INFO", + LogLevel::Warn => "WARN", + LogLevel::Error => "ERROR", + } +} + +impl From for AgentData { + fn from(data: ServiceAgentState) -> Self { + Self { + version: data.version, + tunnels: data + .tunnels + .into_iter() + .map(|tunnel| TunnelInfo { + display_address: tunnel.display_address, + destination: tunnel.destination, + is_disabled: tunnel.is_disabled, + disabled_reason: tunnel.disabled_reason, + }) + .collect(), + pending_tunnels: data + .pending_tunnels + .into_iter() + .map(|pending| PendingTunnelInfo { + id: pending.id, + status_msg: pending.status_msg, + }) + .collect(), + notices: data + .notices + .into_iter() + .map(|notice| NoticeInfo { + priority: notice.priority, + message: notice.message, + resolve_link: notice.resolve_link, + }) + .collect(), + account_status: match data.account_status { + ServiceAccountStatus::Guest => AccountStatusInfo::Guest, + ServiceAccountStatus::EmailNotVerified => AccountStatusInfo::EmailNotVerified, + ServiceAccountStatus::Verified => AccountStatusInfo::Verified, + ServiceAccountStatus::Unknown => AccountStatusInfo::Unknown, + }, + agent_id: data.agent_id, + login_link: data.login_link, + start_time: data.start_time, + } + } +} + +impl From for ConnectionStats { + fn from(stats: ServiceConnectionStats) -> Self { + Self { + bytes_in: stats.bytes_in, + bytes_out: stats.bytes_out, + active_tcp: stats.active_tcp, + active_udp: stats.active_udp, + } + } +} + +impl Drop for TuiApp { + fn drop(&mut self) { + let _ = self.restore_terminal(); + } +} diff --git a/packages/agent_cli/src/ui/widgets.rs b/packages/playit-cli/src/ui/widgets.rs similarity index 87% rename from packages/agent_cli/src/ui/widgets.rs rename to packages/playit-cli/src/ui/widgets.rs index 3a421265..53186ff6 100644 --- a/packages/agent_cli/src/ui/widgets.rs +++ b/packages/playit-cli/src/ui/widgets.rs @@ -1,10 +1,10 @@ use playit_agent_core::utils::now_milli; use ratatui::{ + Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, - Frame, }; use super::tui_app::{AccountStatusInfo, AgentData, ConnectionStats}; @@ -131,7 +131,10 @@ pub fn render_stats_bar(frame: &mut Frame, area: Rect, stats: &ConnectionStats) // Bytes In let bytes_in = Paragraph::new(Line::from(vec![ Span::styled("↓ In: ", Style::default().fg(Color::Green)), - Span::styled(format_bytes(stats.bytes_in), Style::default().fg(Color::White)), + Span::styled( + format_bytes(stats.bytes_in), + Style::default().fg(Color::White), + ), ])) .alignment(Alignment::Center); frame.render_widget(bytes_in, chunks[0]); @@ -139,7 +142,10 @@ pub fn render_stats_bar(frame: &mut Frame, area: Rect, stats: &ConnectionStats) // Bytes Out let bytes_out = Paragraph::new(Line::from(vec![ Span::styled("↑ Out: ", Style::default().fg(Color::Blue)), - Span::styled(format_bytes(stats.bytes_out), Style::default().fg(Color::White)), + Span::styled( + format_bytes(stats.bytes_out), + Style::default().fg(Color::White), + ), ])) .alignment(Alignment::Center); frame.render_widget(bytes_out, chunks[1]); @@ -147,7 +153,10 @@ pub fn render_stats_bar(frame: &mut Frame, area: Rect, stats: &ConnectionStats) // TCP Connections let tcp = Paragraph::new(Line::from(vec![ Span::styled("TCP: ", Style::default().fg(Color::Cyan)), - Span::styled(stats.active_tcp.to_string(), Style::default().fg(Color::White)), + Span::styled( + stats.active_tcp.to_string(), + Style::default().fg(Color::White), + ), ])) .alignment(Alignment::Center); frame.render_widget(tcp, chunks[2]); @@ -155,7 +164,10 @@ pub fn render_stats_bar(frame: &mut Frame, area: Rect, stats: &ConnectionStats) // UDP Flows let udp = Paragraph::new(Line::from(vec![ Span::styled("UDP: ", Style::default().fg(Color::Magenta)), - Span::styled(stats.active_udp.to_string(), Style::default().fg(Color::White)), + Span::styled( + stats.active_udp.to_string(), + Style::default().fg(Color::White), + ), ])) .alignment(Alignment::Center); frame.render_widget(udp, chunks[3]); @@ -173,13 +185,12 @@ pub fn render_help_bar(frame: &mut Frame, area: Rect, quit_confirm: bool) { ]) } else { Line::from(vec![ - Span::styled("j/k", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - Span::raw(" Scroll "), - Span::styled("Tab", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - Span::raw(" Switch Panel "), - Span::styled("g/G", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - Span::raw(" Top/Bottom "), - Span::styled("q", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + "q", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Quit"), ]) }; diff --git a/packages/agent_cli/src/util.rs b/packages/playit-cli/src/util.rs similarity index 89% rename from packages/agent_cli/src/util.rs rename to packages/playit-cli/src/util.rs index 0463392d..5adc3431 100644 --- a/packages/agent_cli/src/util.rs +++ b/packages/playit-cli/src/util.rs @@ -12,7 +12,7 @@ pub async fn load_config(path: &str) -> Option { } if path.ends_with(".yaml") || path.ends_with(".yml") { - return serde_yaml::from_str(&data).ok(); + return serde_yml::from_str(&data).ok(); } None diff --git a/packages/agent_cli/wix/Banner.bmp b/packages/playit-cli/wix/Banner.bmp similarity index 100% rename from packages/agent_cli/wix/Banner.bmp rename to packages/playit-cli/wix/Banner.bmp diff --git a/packages/agent_cli/wix/Product.ico b/packages/playit-cli/wix/Product.ico similarity index 100% rename from packages/agent_cli/wix/Product.ico rename to packages/playit-cli/wix/Product.ico diff --git a/packages/agent_cli/wix/main.wxs b/packages/playit-cli/wix/main.wxs similarity index 64% rename from packages/agent_cli/wix/main.wxs rename to packages/playit-cli/wix/main.wxs index d346e8ad..b12cc351 100644 --- a/packages/agent_cli/wix/main.wxs +++ b/packages/playit-cli/wix/main.wxs @@ -80,9 +80,20 @@ + + @@ -97,6 +108,22 @@ + + + + + + + + + + + + + + + + @@ -139,6 +166,61 @@ Source="$(var.CargoTargetBinDir)\playit-cli.exe" KeyPath="yes"/> + + + + + + + + + + + + + + + + @@ -154,6 +236,7 @@ + + + + + + + - + + + + + + + + REMOVE="ALL" + NOT REMOVE="ALL" + NOT REMOVE="ALL" + NOT Installed AND UILevel >= 5 + - + - +