├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── release.md │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── android ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── app │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── zoxc │ │ │ └── crusader │ │ │ └── MainActivity.java │ │ └── res │ │ └── values │ │ ├── colors.xml │ │ └── themes.xml ├── build.gradle ├── debugInstall.ps1 ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src │ └── lib.rs ├── data ├── v0.crr ├── v1.crr └── v2.crr ├── docker ├── README.md ├── remote-static.Dockerfile └── server-static.Dockerfile ├── docs ├── BUILDING.md ├── CLI.md ├── LOCAL_TESTS.md ├── RESULTS.md └── TROUBLESHOOTING.md ├── media ├── Crusader Screen Shots.md ├── Crusader-Client.png ├── Crusader-Latency.png ├── Crusader-Loss.png ├── Crusader-Monitor.png ├── Crusader-Remote.png ├── Crusader-Result-with-stats.png ├── Crusader-Result.png ├── Crusader-Server.png ├── Crusader-Throughput.png └── batch_add_border.sh └── src ├── Cargo.lock ├── Cargo.toml ├── crusader-gui-lib ├── Cargo.toml └── src │ ├── client.rs │ └── lib.rs ├── crusader-gui ├── Cargo.toml └── src │ └── main.rs ├── crusader-lib ├── Cargo.toml ├── UFL.txt ├── Ubuntu-Light.ttf ├── assets │ ├── vue.js │ └── vue.prod.js ├── build.rs └── src │ ├── common.rs │ ├── discovery.rs │ ├── file_format.rs │ ├── latency.rs │ ├── lib.rs │ ├── peer.rs │ ├── plot.rs │ ├── protocol.rs │ ├── remote.html │ ├── remote.rs │ ├── serve.rs │ └── test.rs └── crusader ├── Cargo.toml └── src └── main.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | /src/target -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/crusader-lib/assets/* linguist-vendored -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | env: 9 | CARGO_INCREMENTAL: 0 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: stable 20 | override: true 21 | components: rustfmt, clippy 22 | 23 | - name: Build server-only binary 24 | run: cargo build -p crusader --no-default-features 25 | working-directory: src 26 | 27 | - name: Build 28 | run: cargo build 29 | working-directory: src 30 | 31 | - name: Lint 32 | run: cargo clippy --all -- -D warnings 33 | working-directory: src 34 | 35 | - name: Format 36 | run: cargo fmt --all -- --check 37 | working-directory: src 38 | 39 | android: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | toolchain: stable 47 | override: true 48 | components: rustfmt, clippy 49 | 50 | - name: Install Rust targets 51 | run: > 52 | rustup target add 53 | aarch64-linux-android 54 | armv7-linux-androideabi 55 | x86_64-linux-android 56 | i686-linux-android 57 | 58 | - name: Install cargo-ndk 59 | run: cargo install cargo-ndk 60 | 61 | - name: Setup Java 62 | uses: actions/setup-java@v3 63 | with: 64 | distribution: 'temurin' 65 | java-version: '17' 66 | 67 | - name: Setup Android SDK 68 | uses: android-actions/setup-android@v2 69 | 70 | - name: Build Android Rust crates 71 | working-directory: android 72 | run: cargo ndk -t arm64-v8a -o app/src/main/jniLibs/ -- build 73 | 74 | - name: Build Android APK 75 | working-directory: android 76 | run: ./gradlew buildDebug 77 | 78 | # Wait for a new cargo ndk release for better clippy support 79 | #- name: Lint 80 | # run: cargo ndk -t arm64-v8a -- clippy --all -- -D warnings 81 | # working-directory: android 82 | 83 | - name: Format 84 | run: cargo fmt --all -- --check 85 | working-directory: android 86 | -------------------------------------------------------------------------------- /.github/workflows/release.md: -------------------------------------------------------------------------------- 1 | Crusader has pre-built binaries for a number of operating systems. Download the appropriate binary below for your OS. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | env: 8 | CARGO_INCREMENTAL: 0 9 | 10 | jobs: 11 | create-release: 12 | name: create-release 13 | runs-on: ubuntu-latest 14 | outputs: 15 | upload_url: ${{ steps.release.outputs.upload_url }} 16 | permissions: 17 | contents: write 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Get the release version from the tag 22 | shell: bash 23 | run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 24 | 25 | - name: Create GitHub release 26 | id: release 27 | uses: actions/create-release@v1.1.4 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | tag_name: ${{ env.TAG_NAME }} 32 | release_name: Automated build of ${{ env.TAG_NAME }} 33 | prerelease: true 34 | body_path: .github/workflows/release.md 35 | 36 | release-assets: 37 | name: Release assets 38 | needs: create-release 39 | runs-on: ${{ matrix.build.os }} 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | build: 44 | - os: ubuntu-latest 45 | target: arm-unknown-linux-musleabihf 46 | friendly: Linux-ARM-32-bit 47 | exe_postfix: 48 | cargo: cross 49 | gui: false 50 | 51 | - os: ubuntu-latest 52 | target: aarch64-unknown-linux-musl 53 | friendly: Linux-ARM-64-bit 54 | exe_postfix: 55 | cargo: cross 56 | gui: false 57 | 58 | - os: ubuntu-latest 59 | target: x86_64-unknown-linux-musl 60 | friendly: Linux-X86-64-bit 61 | exe_postfix: 62 | cargo: cargo 63 | gui: false 64 | 65 | - os: macos-latest 66 | target: aarch64-apple-darwin 67 | friendly: macOS-ARM-64-bit 68 | exe_postfix: 69 | cargo: cargo 70 | gui: true 71 | 72 | - os: macos-latest 73 | target: x86_64-apple-darwin 74 | friendly: macOS-X86-64-bit 75 | exe_postfix: 76 | cargo: cargo 77 | gui: true 78 | 79 | - os: windows-latest 80 | target: i686-pc-windows-msvc 81 | friendly: Windows-X86-32-bit 82 | exe_postfix: .exe 83 | cargo: cargo 84 | gui: true 85 | 86 | - os: windows-latest 87 | target: x86_64-pc-windows-msvc 88 | friendly: Windows-X86-64-bit 89 | exe_postfix: .exe 90 | cargo: cargo 91 | gui: true 92 | steps: 93 | - uses: actions/checkout@v2 94 | 95 | - uses: actions-rs/toolchain@v1 96 | with: 97 | toolchain: stable 98 | override: true 99 | target: ${{ matrix.build.target }} 100 | 101 | - name: Install cross 102 | if: matrix.build.cargo == 'cross' 103 | run: cargo install cross 104 | 105 | - name: Install and use musl 106 | if: matrix.build.os == 'ubuntu-latest' && matrix.build.cargo != 'cross' 107 | run: | 108 | sudo apt-get install -y --no-install-recommends musl-tools 109 | echo "CC=musl-gcc" >> $GITHUB_ENV 110 | echo "AR=ar" >> $GITHUB_ENV 111 | 112 | - name: Build command line binary 113 | if: ${{ !matrix.build.gui }} 114 | run: ${{ matrix.build.cargo }} build -p crusader --target ${{ matrix.build.target }} --release 115 | working-directory: src 116 | env: 117 | RUSTFLAGS: "-C target-feature=+crt-static" 118 | 119 | - name: Build 120 | if: matrix.build.gui 121 | run: ${{ matrix.build.cargo }} build --target ${{ matrix.build.target }} --release 122 | working-directory: src 123 | env: 124 | RUSTFLAGS: "-C target-feature=+crt-static" 125 | 126 | - name: Build output 127 | shell: bash 128 | run: | 129 | staging="Crusader-${{ matrix.build.friendly }}" 130 | mkdir -p "$staging" 131 | cp src/target/${{ matrix.build.target }}/release/crusader${{ matrix.build.exe_postfix }} "$staging/" 132 | 133 | - name: Copy GUI binary 134 | if: matrix.build.gui 135 | shell: bash 136 | run: | 137 | cp src/target/${{ matrix.build.target }}/release/crusader-gui${{ matrix.build.exe_postfix }} "crusader-${{ matrix.build.friendly }}/" 138 | 139 | - name: Archive output 140 | if: matrix.build.os == 'windows-latest' 141 | shell: bash 142 | run: | 143 | staging="Crusader-${{ matrix.build.friendly }}" 144 | 7z a "$staging.zip" "$staging" 145 | echo "ASSET=$staging.zip" >> $GITHUB_ENV 146 | 147 | - name: Archive output 148 | if: matrix.build.os != 'windows-latest' 149 | shell: bash 150 | run: | 151 | staging="Crusader-${{ matrix.build.friendly }}" 152 | tar czf "$staging.tar.gz" "$staging" 153 | echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV 154 | 155 | - name: Upload archive 156 | uses: actions/upload-release-asset@v1.0.2 157 | env: 158 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 159 | with: 160 | upload_url: ${{ needs.create-release.outputs.upload_url }} 161 | asset_name: ${{ env.ASSET }} 162 | asset_path: ${{ env.ASSET }} 163 | asset_content_type: application/octet-stream 164 | 165 | release-android-assets: 166 | name: Android APK 167 | needs: create-release 168 | runs-on: ubuntu-latest 169 | steps: 170 | - uses: actions/checkout@v2 171 | 172 | - uses: actions-rs/toolchain@v1 173 | with: 174 | toolchain: stable 175 | override: true 176 | 177 | - name: Install Rust targets 178 | run: > 179 | rustup target add 180 | aarch64-linux-android 181 | armv7-linux-androideabi 182 | x86_64-linux-android 183 | i686-linux-android 184 | 185 | - name: Install cargo-ndk 186 | run: cargo install cargo-ndk 187 | 188 | - name: Setup Java 189 | uses: actions/setup-java@v3 190 | with: 191 | distribution: 'temurin' 192 | java-version: '17' 193 | 194 | - name: Setup Android SDK 195 | uses: android-actions/setup-android@v2 196 | 197 | - name: Build Android Rust crates 198 | working-directory: android 199 | run: > 200 | cargo ndk 201 | -t arm64-v8a 202 | -t armeabi-v7a 203 | -t x86_64 204 | -t x86 205 | -o app/src/main/jniLibs/ -- build --release 206 | 207 | - name: Decode Keystore 208 | env: 209 | ENCODED_STRING: ${{ secrets.KEYSTORE }} 210 | run: echo "$ENCODED_STRING" | base64 -di > ../android.keystore 211 | 212 | - name: Build Android APK 213 | working-directory: android 214 | run: ./gradlew build 215 | env: 216 | SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} 217 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 218 | SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} 219 | 220 | - name: Upload APK 221 | uses: actions/upload-release-asset@v1.0.2 222 | env: 223 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 224 | with: 225 | upload_url: ${{ needs.create-release.outputs.upload_url }} 226 | asset_name: Crusader-Android.apk 227 | asset_path: android/app/build/outputs/apk/release/app-release.apk 228 | asset_content_type: application/octet-stream 229 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /src/target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The **Crusader Network Tester** measures network rates and latency 4 | in the presence of upload and download traffic. 5 | It produces plots of the traffic rates, 6 | latency and packet loss. 7 | 8 | This file lists the changes that have occurred since January 2024 in the project. 9 | 10 | ## Unreleased 11 | 12 | ## 0.3.2 - 2024-10-03 13 | 14 | * Fix saved raw data path printed after a test 15 | * Avoid duplicate legends when plotting transferred bytes 16 | * Make `--plot-transferred` increase default plot height 17 | * Fix unique output path generation 18 | 19 | ## 0.3.1 - 2024-09-30 20 | 21 | * Increase samples used for clock synchronization and idle latency measurement 22 | * Clock synchronization now uses the average of the lowest 1/3rd of samples 23 | * Adjust for clock drift in tests 24 | * Fix connecting to servers on non-standard port with peers 25 | * Make discovery more robust by sending multiple packets 26 | 27 | ## 0.3 - 2024-09-16 28 | 29 | * Show throughput, latency, and packet loss summaries in plots and with the `test` command 30 | * Rename both option to bidirectional 31 | * Rename `--latency-peer-server` to `--latency-peer-address` 32 | * Continuous clock synchronization with the latency monitor 33 | * Support opening result files in the GUI by drag and drop 34 | * Add `--out-name` command line option to specify result filename prefix 35 | * Change filename prefix for both raw result and plots to `test` 36 | * Add file dialog to save options in GUI 37 | * Add buttons to save and load from the `crusader-results` folder in GUI 38 | * Add an `export` command line command to convert result files to JSON 39 | * Change timeout when connecting a peer to the server to 8 seconds 40 | * Hide advanced parameters in GUI 41 | * Add a reset parameters button in GUI 42 | * Add an option to measure latency-only for the client in the GUI 43 | * Don't allow peers to connect with the regular server 44 | * Added average lines in GUI 45 | 46 | ## 0.2 - 2024-08-29 47 | 48 | * Added support for local discovery of server and peers using UDP port 35483 49 | * The `test` command line option `--latency-peer` is renamed to `--latency-peer-server`. 50 | A new flag `--latency-peer` will instead search for a local peer. 51 | * Improved error messages 52 | * Fix date/time display in remote web page 53 | * Rename the `Latency` tab to `Monitor` 54 | * Change default streams from 16 to 8. 55 | * Change default throughput sample interval from 20 ms to 60 ms. 56 | * Change default load duration from 5 s to 10 s. 57 | * Change default grace duration from 5 s to 10 s. 58 | * Fix serving from link-local interfaces on Linux 59 | * Fix peers on link-local interfaces 60 | * Show download and upload plots for aggregate tests in the GUI 61 | * Added a shortcut (space) to stop the latency monitor 62 | * Change timeout when connecting to servers and peers to 8 seconds 63 | * Added average lines to the plot output 64 | * Show interface IPs when starting servers 65 | 66 | ## 0.1 - 2024-08-21 67 | 68 | * Added `crusader remote` command to start a web server listening on port 35482. 69 | It allows starting tests on a separate machine and 70 | displays the resulting charts in the web page. 71 | * Use system fonts in GUI 72 | * Improved error handling and error messages 73 | * Added `--idle` option to the client to test without traffic 74 | * Save results in a `crusader-results` folder 75 | * Allow building of a server-only binary 76 | * Generated files will use a YYYY-MM-DD HH.MM.SS format 77 | * Rename bandwidth to throughput 78 | * Rename sample rate to sample interval 79 | * Rename `Both` to `Aggregate` and `Total` to `Round-trip` in plots 80 | 81 | ## 0.0.12 - 2024-07-31 82 | 83 | * Create UDP server for each server IP (fixes #22) 84 | * Improved error handling for log messages 85 | * Changed date format to use YYYY-MM-DD in logs 86 | 87 | ## 0.0.11 - 2024-07-29 88 | 89 | * Log file includes timestamps and version number 90 | * Added peer latency measurements 91 | * Added version to title bar of GUI 92 | * Added `plot_max_bandwidth` and `plot_max_latency` command line options 93 | 94 | ## 0.0.10 - 2024-01-09 95 | 96 | * Specify plot title 97 | * Ignore ENOBUFS error 98 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crusader Network Tester 2 | 3 | [![GitHub Release](https://img.shields.io/github/v/release/Zoxc/crusader)](https://github.com/Zoxc/crusader/releases) 4 | [![Docker Hub](https://img.shields.io/badge/container-dockerhub-blue)](https://hub.docker.com/r/zoxc/crusader) 5 | [![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Zoxc/crusader/blob/master/LICENSE-MIT) 6 | [![Apache](https://img.shields.io/badge/license-Apache-blue.svg)](https://github.com/Zoxc/crusader/blob/master/LICENSE-APACHE) 7 | 8 | ![Crusader Results Screenshot](./media/Crusader-Result.png) 9 | 10 | The **Crusader Network Tester** measures network throughput, latency and packet loss 11 | in the presence of upload and download traffic. 12 | It also incorporates a continuous latency tester for 13 | monitoring background responsiveness. 14 | 15 | Crusader makes throughput measurements using TCP on port 35481 16 | and latency tests using UDP port 35481. 17 | The remote web server option uses TCP port 35482. 18 | Local server discovery uses UDP port 35483. 19 | 20 | **Pre-built binaries** for Windows, Mac, Linux, 21 | and Android are available on the 22 | [Releases](https://github.com/Zoxc/crusader/releases) page. 23 | The GUI is not prebuilt for Linux and must be built from source. 24 | 25 | **Documentation** See the [Documentation](#documentation) 26 | section below. 27 | 28 | **Status:** The latest Crusader release version is shown above. 29 | The [pre-built binaries](https://github.com/Zoxc/crusader/releases) 30 | always provide the latest version. 31 | See the [CHANGELOG.md](./CHANGELOG.md) file for details. 32 | 33 | ## Crusader GUI 34 | 35 | A test run requires two separate computers, 36 | both running Crusader: 37 | a **server** that listens for connections, and 38 | a **client** that initiates the test. 39 | 40 | The Crusader GUI incorporates both the server and 41 | the client and allows you to interact with results. 42 | To use it, download the proper binary from the 43 | [Releases](https://github.com/Zoxc/crusader/releases) page. 44 | 45 | When you open the `crusader-gui` you see this window. 46 | Enter the address of another computer that's 47 | running the Crusader server, then click **Start test**. 48 | When the test is complete, the **Result** tab shows a 49 | chart like the second image below. 50 | 51 | An easy way to use Crusader is to download 52 | the Crusader GUI onto two computers, then 53 | start the server on one computer, and the client on the other. 54 | 55 | ![Crusader Client Screenshot](./media/Crusader-Client.png) 56 | 57 | The Crusader GUI has five tabs: 58 | 59 | * **Client tab** 60 | Runs the Crusader client program. 61 | The options shown above are described in the 62 | [Command-line options](./docs/CLI.md) page. 63 | 64 | * **Server tab** 65 | Runs the Crusader server, listening for connections from other clients 66 | 67 | * **Remote tab** 68 | Starts a webserver (default port 35482). 69 | A browser that connects to that port can initiate 70 | a test to a Crusader server. 71 | 72 | * **Monitor tab** 73 | Continually displays the latency to the selected 74 | Crusader server until stopped. 75 | 76 | * **Result tab** 77 | Displays the result of the most recent client run 78 | 79 | ## The Result Tab 80 | 81 | ![Crusader Results Screenshot](./media/Crusader-Result.png) 82 | 83 | A Crusader test creates three bursts of traffic. 84 | By default, it generates ten seconds each of 85 | download only, upload only, then bi-directional traffic. 86 | Each burst is separated by several seconds of idle time. 87 | 88 | The Crusader Result tab displays the results of the test with 89 | three plots (see image above): 90 | 91 | * The **Throughput** plot shows the bursts of traffic. 92 | Green is download (from server to client), 93 | blue is upload, and 94 | the purple line is the instantaneous 95 | sum of the download plus upload. 96 | 97 | * The **Latency** plot shows the corresponding latency. 98 | Green shows the (uni-directional) time from the server to the client. 99 | Blue is the (uni-directional) time from the client to the server. 100 | Black shows the sum from the client to the server 101 | and back (round-trip time). 102 | 103 | * The **Packet Loss** plot has green and blue marks 104 | that indicate times when packets were lost. 105 | 106 | For more details, see the 107 | [Understanding Crusader Results](./docs/RESULTS.md) page. 108 | 109 | ## Documentation 110 | 111 | * [This README](./README.md) 112 | * [Understanding Crusader Results](./docs/RESULTS.md) 113 | * [Local Testing](./docs/LOCAL_TESTS.md) 114 | * [Command-line Options](./docs/CLI.md) 115 | * [Building Crusader from source](./docs/BUILDING.md) 116 | * [Troubleshooting](./docs/TROUBLESHOOTING.md) 117 | * [Docker container](https://hub.docker.com/r/zoxc/crusader) 118 | for the server is available on 119 | [dockerhub](https://hub.docker.com/r/zoxc/crusader). 120 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /target 3 | /app/build 4 | /app/src/main/jniLibs -------------------------------------------------------------------------------- /android/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crusader-android" 3 | version = "0.1.0" 4 | edition = "2021" 5 | resolver = "2" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | log = "0.4" 11 | eframe = { version = "0.28.1", features = ["wgpu"] } 12 | crusader-gui-lib = { path = "../src/crusader-gui-lib" } 13 | crusader-lib = { path = "../src/crusader-lib" } 14 | winit = "0.29.15" 15 | jni = "0.19.0" 16 | ndk-context = "0.1" 17 | 18 | [target.'cfg(target_os = "android")'.dependencies] 19 | android_logger = "0.11.0" 20 | android-activity = { version = "0.5", features = ["game-activity"] } 21 | 22 | [patch.crates-io] 23 | winit = { git = "https://github.com/Zoxc/winit", branch = "crusader2" } 24 | egui = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } 25 | epaint = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } 26 | emath = { git = "https://github.com/Zoxc/egui", branch = "crusader2" } 27 | egui_plot = { git = "https://github.com/Zoxc/egui_plot", branch = "crusader" } 28 | 29 | [lib] 30 | name = "main" 31 | crate-type = ["cdylib"] 32 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | } 4 | 5 | android { 6 | compileSdk 31 7 | 8 | defaultConfig { 9 | applicationId "zoxc.crusader" 10 | minSdk 28 11 | targetSdk 31 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | 18 | signingConfigs { 19 | release { 20 | storeFile = file("../../../android.keystore") 21 | storePassword System.getenv("SIGNING_STORE_PASSWORD") 22 | keyAlias System.getenv("SIGNING_KEY_ALIAS") 23 | keyPassword System.getenv("SIGNING_KEY_PASSWORD") 24 | } 25 | } 26 | 27 | buildTypes { 28 | release { 29 | minifyEnabled false 30 | signingConfig signingConfigs.release 31 | } 32 | debug { 33 | minifyEnabled false 34 | //packagingOptions { 35 | // doNotStrip '**/*.so' 36 | //} 37 | //debuggable true 38 | } 39 | } 40 | compileOptions { 41 | sourceCompatibility JavaVersion.VERSION_1_8 42 | targetCompatibility JavaVersion.VERSION_1_8 43 | } 44 | } 45 | 46 | dependencies { 47 | implementation 'com.google.android.material:material:1.5.0' 48 | implementation "androidx.games:games-activity:2.0.2" 49 | 50 | // To use the Games Controller Library 51 | //implementation "androidx.games:games-controller:1.1.0" 52 | 53 | // To use the Games Text Input Library 54 | //implementation "androidx.games:games-text-input:1.1.0" 55 | } 56 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/java/zoxc/crusader/MainActivity.java: -------------------------------------------------------------------------------- 1 | package zoxc.crusader; 2 | 3 | import androidx.appcompat.app.AppCompatActivity; 4 | import androidx.core.view.WindowCompat; 5 | import androidx.core.view.WindowInsetsCompat; 6 | import androidx.core.view.WindowInsetsControllerCompat; 7 | 8 | import com.google.androidgamesdk.GameActivity; 9 | 10 | import android.os.Bundle; 11 | import android.content.pm.PackageManager; 12 | import android.os.Build.VERSION; 13 | import android.os.Build.VERSION_CODES; 14 | import android.os.Bundle; 15 | import android.view.View; 16 | import android.view.WindowManager; 17 | import android.util.Log; 18 | import android.content.Intent; 19 | import android.net.Uri; 20 | import android.app.Activity; 21 | import android.view.inputmethod.InputMethodManager; 22 | import android.provider.OpenableColumns; 23 | import android.database.Cursor; 24 | import android.content.Context; 25 | 26 | import java.io.ByteArrayOutputStream; 27 | import java.io.InputStream; 28 | import java.io.OutputStream; 29 | 30 | public class MainActivity extends GameActivity { 31 | static { 32 | System.loadLibrary("main"); 33 | } 34 | 35 | public void showKeyboard(boolean show) { 36 | InputMethodManager input = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); 37 | if (show) { 38 | input.showSoftInput(getWindow().getDecorView().getRootView(), 0); 39 | } else { 40 | input.hideSoftInputFromWindow(getWindow().getDecorView().getRootView().getWindowToken(), 0); 41 | } 42 | } 43 | 44 | public void loadFile() { 45 | Intent intent = new Intent(Intent.ACTION_GET_CONTENT); 46 | intent.addCategory(Intent.CATEGORY_OPENABLE); 47 | intent.setType("*/*"); 48 | startActivityForResult(intent, ACTIVITY_LOAD_FILE); 49 | } 50 | 51 | public void saveFile(boolean image, String name, byte[] data) { 52 | Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); 53 | intent.addCategory(Intent.CATEGORY_OPENABLE); 54 | if (image) { 55 | intent.setType("image/png"); 56 | } else { 57 | intent.setType("application/octet-stream"); 58 | } 59 | intent.putExtra(Intent.EXTRA_TITLE, name); 60 | saveFileData = data; 61 | saveImage = image; 62 | startActivityForResult(intent, ACTIVITY_CREATE_FILE); 63 | } 64 | 65 | private byte[] saveFileData = null; 66 | private boolean saveImage; 67 | 68 | private static final int ACTIVITY_LOAD_FILE = 1; 69 | private static final int ACTIVITY_CREATE_FILE = 2; 70 | 71 | static native void fileLoaded(String name, byte[] data); 72 | 73 | static native void fileSaved(boolean image, String name); 74 | 75 | @Override 76 | public void onActivityResult(int requestCode, int resultCode, 77 | Intent resultData) { 78 | super.onActivityResult(requestCode, resultCode, resultData); 79 | 80 | if (requestCode == ACTIVITY_CREATE_FILE) { 81 | if (resultCode == Activity.RESULT_OK && resultData != null) { 82 | Uri uri = resultData.getData(); 83 | String name = getName(uri); 84 | try { 85 | OutputStream stream = getContentResolver().openOutputStream(uri); 86 | stream.write(saveFileData); 87 | stream.close(); 88 | fileSaved(saveImage, name); 89 | } 90 | catch(Exception e) {} 91 | } 92 | saveFileData = null; 93 | } 94 | 95 | if (requestCode == ACTIVITY_LOAD_FILE 96 | && resultCode == Activity.RESULT_OK 97 | && resultData != null) { 98 | Uri uri = resultData.getData(); 99 | String name = getName(uri); 100 | 101 | try { 102 | InputStream stream = getContentResolver().openInputStream(uri); 103 | ByteArrayOutputStream buffer = new ByteArrayOutputStream(); 104 | int read; 105 | byte[] byte_buffer = new byte[0x1000]; 106 | while ((read = stream.read(byte_buffer, 0, byte_buffer.length)) != -1) { 107 | buffer.write(byte_buffer, 0, read); 108 | } 109 | stream.close(); 110 | byte[] data = buffer.toByteArray(); 111 | fileLoaded(name, data); 112 | } 113 | catch(Exception e) {} 114 | } 115 | } 116 | 117 | public String getName(Uri uri) { 118 | Cursor cursor = getContentResolver().query(uri, null, null, null, null, null); 119 | String name = ""; 120 | try { 121 | if (cursor != null && cursor.moveToFirst()) { 122 | int column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); 123 | if (column != -1) { 124 | name = cursor.getString(column); 125 | } 126 | return name; 127 | } 128 | } finally { 129 | cursor.close(); 130 | } 131 | return name; 132 | } 133 | } -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #FFE6E6E6 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id 'com.android.application' version '7.1.2' apply false 4 | id 'com.android.library' version '7.1.2' apply false 5 | } 6 | 7 | task clean(type: Delete) { 8 | delete rootProject.buildDir 9 | } 10 | -------------------------------------------------------------------------------- /android/debugInstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | cargo ndk -t arm64-v8a -o app/src/main/jniLibs/ -- build --release 3 | if ($lastexitcode -ne 0) { 4 | throw "Error" 5 | } 6 | 7 | ./gradlew.bat buildDebug 8 | if ($lastexitcode -ne 0) { 9 | throw "Error" 10 | } 11 | 12 | ./gradlew.bat installDebug 13 | if ($lastexitcode -ne 0) { 14 | throw "Error" 15 | } 16 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Enables namespacing of each library's R class so that its R class includes only the 19 | # resources declared in the library itself and none from the library's dependencies, 20 | # thereby reducing the size of the R class for that library 21 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon May 02 15:39:12 BST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /android/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | include ':app' 17 | -------------------------------------------------------------------------------- /android/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow( 2 | clippy::field_reassign_with_default, 3 | clippy::option_map_unit_fn, 4 | clippy::missing_safety_doc 5 | )] 6 | 7 | use crusader_gui_lib::Tester; 8 | use crusader_lib::file_format::RawResult; 9 | use eframe::egui::{self, vec2, Align, FontFamily, Layout}; 10 | use jni::{ 11 | objects::{JClass, JObject, JString}, 12 | sys::{jboolean, jbyteArray}, 13 | JNIEnv, 14 | }; 15 | use std::{ 16 | error::Error, 17 | io::Cursor, 18 | path::Path, 19 | sync::{Arc, Mutex}, 20 | }; 21 | 22 | #[cfg(target_os = "android")] 23 | use { 24 | android_activity::AndroidApp, 25 | crusader_lib::test::PlotConfig, 26 | eframe::{NativeOptions, Renderer, Theme}, 27 | log::Level, 28 | std::fs, 29 | winit::platform::android::EventLoopBuilderExtAndroid, 30 | }; 31 | 32 | struct App { 33 | tester: Tester, 34 | keyboard_shown: bool, 35 | } 36 | 37 | impl eframe::App for App { 38 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 39 | use eframe::egui::FontFamily::Proportional; 40 | use eframe::egui::FontId; 41 | use eframe::egui::TextStyle::*; 42 | 43 | let mut style = ctx.style(); 44 | let style_ = Arc::make_mut(&mut style); 45 | style_.spacing.button_padding = vec2(10.0, 0.0); 46 | style_.spacing.interact_size.y = 40.0; 47 | style_.spacing.item_spacing = vec2(10.0, 10.0); 48 | 49 | style_.text_styles = [ 50 | (Heading, FontId::new(26.0, Proportional)), 51 | (Body, FontId::new(16.0, Proportional)), 52 | (Monospace, FontId::new(16.0, FontFamily::Monospace)), 53 | (Button, FontId::new(16.0, Proportional)), 54 | (Small, FontId::new(16.0, Proportional)), 55 | ] 56 | .into(); 57 | 58 | ctx.set_style(style); 59 | 60 | egui::CentralPanel::default().show(ctx, |ui| { 61 | let mut rect = ui.max_rect(); 62 | rect.set_top(rect.top() + 40.0); 63 | rect.set_height(rect.height() - 60.0); 64 | let mut ui = ui.child_ui(rect, Layout::left_to_right(Align::Center), None); 65 | ui.vertical(|ui| { 66 | ui.heading("Crusader Network Benchmark"); 67 | ui.separator(); 68 | 69 | SAVED_FILE.lock().unwrap().take().map(|(image, name)| { 70 | if !image { 71 | self.tester.save_raw(Path::new(&name).to_owned()); 72 | } 73 | }); 74 | 75 | LOADED_FILE.lock().unwrap().take().map(|(name, data)| { 76 | RawResult::load_from_reader(Cursor::new(data)) 77 | .map(|data| self.tester.load_file(Path::new(&name).to_owned(), data)); 78 | }); 79 | 80 | self.tester.show(ctx, ui); 81 | }); 82 | }); 83 | 84 | if ctx.wants_keyboard_input() != self.keyboard_shown { 85 | show_keyboard(ctx.wants_keyboard_input()).unwrap(); 86 | self.keyboard_shown = ctx.wants_keyboard_input(); 87 | } 88 | } 89 | } 90 | 91 | fn show_keyboard(show: bool) -> Result<(), Box> { 92 | let context = ndk_context::android_context(); 93 | 94 | let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; 95 | 96 | let activity: JObject = (context.context() as jni::sys::jobject).into(); 97 | 98 | let env = vm.attach_current_thread()?; 99 | 100 | env.call_method(activity, "showKeyboard", "(Z)V", &[show.into()])? 101 | .v()?; 102 | Ok(()) 103 | } 104 | 105 | fn save_file(image: bool, name: String, data: Vec) -> Result<(), Box> { 106 | let context = ndk_context::android_context(); 107 | let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; 108 | let activity: JObject = (context.context() as jni::sys::jobject).into(); 109 | let env = vm.attach_current_thread()?; 110 | env.call_method( 111 | activity, 112 | "saveFile", 113 | "(ZLjava/lang/String;[B)V", 114 | &[ 115 | image.into(), 116 | env.new_string(name).unwrap().into(), 117 | env.byte_array_from_slice(&data).unwrap().into(), 118 | ], 119 | )? 120 | .v()?; 121 | Ok(()) 122 | } 123 | 124 | static SAVED_FILE: Mutex> = Mutex::new(None); 125 | 126 | #[no_mangle] 127 | pub unsafe extern "C" fn Java_zoxc_crusader_MainActivity_fileSaved( 128 | env: JNIEnv, 129 | _: JClass, 130 | image: jboolean, 131 | name: JString, 132 | ) { 133 | let name: String = env.get_string(name).unwrap().into(); 134 | *SAVED_FILE.lock().unwrap() = Some((image != 0, name)); 135 | } 136 | 137 | fn load_file() -> Result<(), Box> { 138 | let context = ndk_context::android_context(); 139 | let vm = unsafe { jni::JavaVM::from_raw(context.vm().cast())? }; 140 | let activity: JObject = (context.context() as jni::sys::jobject).into(); 141 | let env = vm.attach_current_thread()?; 142 | env.call_method(activity, "loadFile", "()V", &[])?.v()?; 143 | Ok(()) 144 | } 145 | 146 | static LOADED_FILE: Mutex)>> = Mutex::new(None); 147 | 148 | #[no_mangle] 149 | pub unsafe extern "C" fn Java_zoxc_crusader_MainActivity_fileLoaded( 150 | env: JNIEnv, 151 | _: JClass, 152 | name: JString, 153 | data: jbyteArray, 154 | ) { 155 | let name: String = env.get_string(name).unwrap().into(); 156 | let data = env.convert_byte_array(data).unwrap(); 157 | *LOADED_FILE.lock().unwrap() = Some((name, data)); 158 | } 159 | 160 | #[cfg(target_os = "android")] 161 | #[no_mangle] 162 | fn android_main(app: AndroidApp) { 163 | android_logger::init_once(android_logger::Config::default().with_min_level(Level::Trace)); 164 | 165 | crusader_lib::plot::register_fonts(); 166 | 167 | let settings = app 168 | .internal_data_path() 169 | .map(|path| path.join("settings.toml")); 170 | 171 | let temp_plot = app.internal_data_path().map(|path| path.join("plot.png")); 172 | 173 | let mut options = NativeOptions::default(); 174 | options.follow_system_theme = false; 175 | options.default_theme = Theme::Light; 176 | options.renderer = Renderer::Wgpu; 177 | options.event_loop_builder = Some(Box::new(move |builder| { 178 | builder.with_android_app(app.clone()); 179 | })); 180 | let mut tester = Tester::new(settings); 181 | tester.file_loader = Some(Box::new(|_| load_file().unwrap())); 182 | tester.plot_saver = Some(Box::new(move |result| { 183 | let path = temp_plot.as_deref().unwrap(); 184 | crusader_lib::plot::save_graph_to_path(path, &PlotConfig::default(), result).unwrap(); 185 | let data = fs::read(path).unwrap(); 186 | fs::remove_file(path).unwrap(); 187 | let name = format!("{}.png", crusader_lib::test::timed("plot")); 188 | save_file(true, name, data).unwrap(); 189 | })); 190 | tester.raw_saver = Some(Box::new(|result| { 191 | let mut writer = Cursor::new(Vec::new()); 192 | result.save_to_writer(&mut writer).unwrap(); 193 | let data = writer.into_inner(); 194 | let name = format!("{}.crr", crusader_lib::test::timed("data")); 195 | save_file(false, name, data).unwrap(); 196 | })); 197 | eframe::run_native( 198 | "Crusader Network Tester", 199 | options, 200 | Box::new(|_cc| { 201 | Ok(Box::new(App { 202 | tester, 203 | keyboard_shown: false, 204 | })) 205 | }), 206 | ) 207 | .unwrap(); 208 | } 209 | -------------------------------------------------------------------------------- /data/v0.crr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/data/v0.crr -------------------------------------------------------------------------------- /data/v1.crr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/data/v1.crr -------------------------------------------------------------------------------- /data/v2.crr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/data/v2.crr -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | To build a statically linked server image: 2 | ``` 3 | docker build .. -t crusader -f server-static.Dockerfile 4 | ``` 5 | 6 | To build a statically linked remote image: 7 | ``` 8 | docker build .. -t crusader -f remote-static.Dockerfile 9 | ``` 10 | This image allow initiation of tests using the web application running on port 35482. 11 | 12 | Supported platforms: 13 | - `linux/i386` 14 | - `linux/x86_64` 15 | - `linux/arm/v7` 16 | - `linux/arm64` 17 | 18 | Available profiles: 19 | - `--build-arg PROFILE=release` (default) 20 | - `--build-arg PROFILE=speed` 21 | - `--build-arg PROFILE=size` -------------------------------------------------------------------------------- /docker/remote-static.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust AS build 2 | ARG TARGETARCH 3 | ARG PROFILE=release 4 | 5 | COPY src /src 6 | WORKDIR /src 7 | 8 | RUN echo no-target-detected > /target 9 | 10 | RUN if [ "$TARGETARCH" = "386" ]; then\ 11 | echo i686-unknown-linux-musl > /target; fi 12 | RUN if [ "$TARGETARCH" = "amd64" ]; then\ 13 | echo x86_64-unknown-linux-musl > /target; fi 14 | RUN if [ "$TARGETARCH" = "arm" ]; then\ 15 | echo arm-unknown-linux-musleabihf > /target; fi 16 | RUN if [ "$TARGETARCH" = "arm64" ]; then\ 17 | echo aarch64-unknown-linux-musl > /target; fi 18 | 19 | ENV RUSTFLAGS="-C target-feature=+crt-static" 20 | 21 | RUN rustup target add $(cat /target) 22 | 23 | RUN cargo build -p crusader --profile=$PROFILE --target $(cat /target) 24 | 25 | RUN cp target/$(cat /target)/$PROFILE/crusader / 26 | 27 | FROM scratch 28 | COPY --from=build /crusader / 29 | 30 | EXPOSE 35482/tcp 31 | ENTRYPOINT [ "/crusader", "remote" ] 32 | -------------------------------------------------------------------------------- /docker/server-static.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust AS build 2 | ARG TARGETARCH 3 | ARG PROFILE=release 4 | 5 | COPY src /src 6 | WORKDIR /src 7 | 8 | RUN echo no-target-detected > /target 9 | 10 | RUN if [ "$TARGETARCH" = "386" ]; then\ 11 | echo i686-unknown-linux-musl > /target; fi 12 | RUN if [ "$TARGETARCH" = "amd64" ]; then\ 13 | echo x86_64-unknown-linux-musl > /target; fi 14 | RUN if [ "$TARGETARCH" = "arm" ]; then\ 15 | echo arm-unknown-linux-musleabihf > /target; fi 16 | RUN if [ "$TARGETARCH" = "arm64" ]; then\ 17 | echo aarch64-unknown-linux-musl > /target; fi 18 | 19 | ENV RUSTFLAGS="-C target-feature=+crt-static" 20 | 21 | RUN rustup target add $(cat /target) 22 | 23 | RUN cargo build -p crusader --no-default-features --profile=$PROFILE --target $(cat /target) 24 | 25 | RUN cp target/$(cat /target)/$PROFILE/crusader / 26 | 27 | FROM scratch 28 | COPY --from=build /crusader / 29 | 30 | EXPOSE 35481/tcp 35481/udp 35483/udp 31 | ENTRYPOINT [ "/crusader", "serve" ] 32 | -------------------------------------------------------------------------------- /docs/BUILDING.md: -------------------------------------------------------------------------------- 1 | # Building Crusader from source 2 | 3 | Reminder: [Pre-built binaries](https://github.com/Zoxc/crusader/releases) 4 | are available for everyday tests. 5 | 6 | ## Required dependencies 7 | 8 | * A Rust and C toolchain. 9 | * `fontconfig` (optional, required for `crusader-gui`) 10 | 11 | _Note:_ To install `fontconfig` on Ubuntu: 12 | 13 | ```sh 14 | sudo apt install libfontconfig1-dev 15 | ``` 16 | 17 | ## Building Crusader 18 | 19 | To develop or debug Crusader, use the commands below 20 | to build the full set of binaries. 21 | Executables are placed in _src/target/release_ 22 | 23 | To build the `crusader` command line executable: 24 | 25 | ```sh 26 | cd src 27 | cargo build -p crusader --release 28 | ``` 29 | 30 | To build both command line and GUI executables: 31 | 32 | ```sh 33 | cd src 34 | cargo build --release 35 | ``` 36 | 37 | ## Debug build 38 | 39 | Create a debug build by using `cargo build` 40 | (instead of `cargo build --release`). 41 | Binaries are saved in the _src/target/debug_ directory 42 | 43 | ## Docker 44 | 45 | To build a docker container that runs the server: 46 | 47 | ```sh 48 | cd docker 49 | docker build .. -t crusader -f server-static.Dockerfile 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/CLI.md: -------------------------------------------------------------------------------- 1 | # Running Crusader from the command line 2 | 3 | ## Server 4 | 5 | To host a Crusader server, run this on the _server_ machine: 6 | 7 | ```sh 8 | crusader serve 9 | ``` 10 | 11 | ## Client 12 | 13 | To start a test, run this on the _client machine_. 14 | See the [command-line options](#options-for-the-test-command) below for details. 15 | 16 | ```sh 17 | crusader test 18 | ``` 19 | 20 | ## Remote 21 | 22 | To host a web server that provides remote control of a Crusader client, 23 | run the command below, then connect to 24 | `http://ip-of-the-crusader-device:35482` 25 | 26 | ```sh 27 | crusader remote 28 | ``` 29 | 30 | ## Plot 31 | 32 | Crusader creates a `.png` file from a `.crr` file using `crusader plot path-to-crr-file` 33 | The resulting `.png` is saved in the same directory as the input file. 34 | 35 | ## Export 36 | 37 | Crusader exports raw data samples from a `.crr` file 38 | into a `.json` file using `crusader export path-to-crr-file` 39 | The resulting `.json` is saved in the same directory as the input file. 40 | 41 | ## Options for the `test` command 42 | 43 | **Usage: `crusader test [OPTIONS] `** 44 | 45 | **Arguments:** `` address of a Crusader server 46 | 47 | **Options:** 48 | 49 | * **`--download`** 50 | Run a download test 51 | * **`--upload`** 52 | Run an upload test 53 | * **`--bidirectional`** 54 | Run a test doing both download and upload 55 | * **`--idle`** 56 | Run a test, only measuring latency without inducing traffic. 57 | The duration is specified by `grace_duration` 58 | * **`--port `** 59 | Specify the TCP and UDP port used by the server 60 | [default: 35481] 61 | * **`--streams `** 62 | The number of TCP connections used to generate 63 | traffic in a single direction 64 | [default: 8] 65 | * **`--stream-stagger `** 66 | The delay between the start of each stream 67 | [default: 0.0] 68 | * **`--load-duration `** 69 | The duration in which traffic is generated 70 | [default: 10.0] 71 | * **`--grace-duration `** 72 | The idle time between each test 73 | [default: 2.0] 74 | * **`--latency-sample-interval `** 75 | [default: 5.0] 76 | * **`--throughput-sample-interval `** 77 | [default: 60.0] 78 | * **`--plot-transferred`** 79 | Plot transferred bytes 80 | * **`--plot-split-throughput`** 81 | Plot upload and download separately and plot streams 82 | * **`--plot-max-throughput `** 83 | Set the axis for throughput to at least this value. 84 | SI units are supported so `100M` would specify 100 Mbps 85 | * **`--plot-max-latency `** 86 | Set the axis for latency to at least this value 87 | * **`--plot-width `** 88 | * **`--plot-height `** 89 | * **`--plot-title `** 90 | * **`--latency-peer-address `** 91 | Address of another Crusader server (the "peer") which 92 | concurrently measures the latency to the server and reports 93 | the values to the client 94 | * **`--latency-peer`** 95 | Trigger the client to instruct a peer (another Crusader server) 96 | to begin measuring the latency to the main server 97 | and report the latency back 98 | * **`--out-name `** 99 | The filename prefix used for the raw data and plot filenames 100 | * **`-h, --help`** 101 | Print help (see a summary with '-h') 102 | -------------------------------------------------------------------------------- /docs/LOCAL_TESTS.md: -------------------------------------------------------------------------------- 1 | # Local network testing with Crusader 2 | 3 | **Background:** 4 | The Crusader Network Tester measures network throughput 5 | and latency in the presence of upload and download traffic 6 | and produces plots of the traffic rates, latency and packet loss. 7 | 8 | ## Making local tests - Wifi or Wired 9 | 10 | To test the equipment between two points on your network, 11 | Crusader requires two computers, 12 | one acting as a server, the other as a client. 13 | 14 | The Crusader program can act as both client and server. 15 | Install the latest pre-built binary of 16 | [Crusader](https://github.com/Zoxc/crusader/releases) 17 | on two separate computers. Then: 18 | 19 | 1. Connect one of those computers to your router's LAN port using an 20 | Ethernet cable. 21 | Start the Crusader program on it, then click the **Server** tab. See the 22 | [screen shot](../media/Crusader-Server.png). 23 | Click **Start server** and look for the 24 | address(es) where the Crusader Server is listening. 25 | (Note: The Crusader binary can also run on a small 26 | computer such as a Raspberry Pi. 27 | A Pi4 acting as a server can easily support 1Gbps speeds.) 28 | 2. Connect the other computer either by Ethernet, Wi-fi, 29 | or some other adapter. 30 | Run the Crusader program and click the Client tab. See the 31 | [screen shot](../media/Crusader-Client.png). 32 | Enter the address of a Crusader Server into the GUI, 33 | and click **Start test** 34 | 3. When the test completes, you'll see charts of three bursts of traffic: 35 | Download, Upload, and Bidirectional, 36 | along with the latency during that activity. 37 | See the 38 | [Crusader README](../README.md) and 39 | [Understanding Crusader Results](./RESULTS.md) 40 | for details. 41 | 42 | **Note:** If both the computers (client and server) are on the LAN 43 | side of the router (whether they are on Wi-Fi or Ethernet), 44 | the results will reflect the _switching_ capability, not the 45 | _routing_ capability of the router. 46 | To test the routing capability, test against a server that's 47 | on the WAN side of the router or the broader Internet. 48 | 49 | **Note:** The Crusader program has both a GUI and a command-line binary. 50 | Both act as a client or a server. 51 | These instructions tell how to run the client on a laptop. 52 | You may find it convenient to run the server on a remote computer. 53 | Get both binaries from the 54 | [Releases page](https://github.com/Zoxc/crusader/releases). 55 | 56 | ## What can we learn? 57 | 58 | Your local router, Wi-fi, and other network equipment 59 | all affect your total performance. 60 | Even with very high speed internet service, 61 | if the router is max'ed out it can hold back your throughput. 62 | And you're stuck with whatever latency the router and 63 | other local equipment create - it's added to any latency from your ISP network. 64 | In particular: 65 | 66 | * Ethernet-to-Ethernet connections tend to be good: 67 | high throughput with low latency. 68 | But you should always check your network. 69 | On a 1Gbps network, typical results are above 950 Mbps, 70 | and less than a dozen milliseconds of latency. 71 | * Wi-fi has a reputation for "being slow". 72 | This often is a result of the Wi-fi drivers injecting 73 | hundreds of milliseconds of latency. 74 | In addition, wireless connections will always have lower 75 | throughput than wired connections. 76 | * Many powerline adapters (that provide an Ethernet connection 77 | between two AC outlets) may have high specifications, 78 | but in practice are known to give limited throughput 79 | and have high latency. 80 | 81 | Running a Crusader test between computers on the local network measures 82 | the performance of that portion of the network. 83 | 84 | ## Why is this important? 85 | 86 | These test results - the actual throughput or latency numbers - 87 | are not very important. 88 | If you are satisfied with you network's performance, 89 | then these numerical results don't matter. 90 | 91 | But if you are experiencing problems, 92 | these tests help divide the troubleshooting problem in two. 93 | 94 | * If the local network is running fine, 95 | performance problems must be elsewhere 96 | (in your ISP or their upstream service, 97 | which likely is out of your control). 98 | * But if the local performance is not what you expected, 99 | you can start investigating your router, switch, etc. 100 | -------------------------------------------------------------------------------- /docs/RESULTS.md: -------------------------------------------------------------------------------- 1 | # Understanding Crusader Results 2 | 3 | The Crusader GUI provides a compact summary of the test data. 4 | Here are some hints for evaluating the results. 5 | 6 | ## Result window 7 | 8 | ![Result with statistics](../media/Crusader-Result-with-stats.png) 9 | 10 | Crusader tests the connection using three bursts of traffic. 11 | The Throughput, Latency, and Packet loss are shown in the charts. 12 | In the image above notice: 13 | 14 | * **Hovering over a chart** shows crosshairs that give the throughput 15 | or latency of that point in the chart. 16 | In the screen shot above, the Down latency 17 | peaks around 250 ms. 18 | * **Hovering over, or clicking the ⓘ symbol** opens a window that displays 19 | a summary of the measurements. 20 | See the description below for details. 21 | * **Clicking a legend** ("color") in the charts shows/hides 22 | the associated graph. 23 | In the screen shot above, the Latency's "Round-trip" legend has been clicked, 24 | hiding the (black) round-trip trace, 25 | and showing only the Up and Down latency plots. 26 | * The **Save to results** button saves two files: a plot (as `.png`) 27 | and the data (as `.crr`) to the _crusader-results_ directory 28 | in the _current directory_. 29 | * The **Open from results** button opens a `.crr` file 30 | from the _crusader-results_ directory. 31 | * The **Save** button opens a file dialog to save the current `.crr` file. 32 | * The **Open** button opens a file dialog to select a `.crr` file to open. 33 | * The **Export plot** button opens a file dialog to save a `.png` file. 34 | 35 | ## Numerical Summary Windows 36 | 37 | The Crusader GUI displays charts showing Throughput, Latency, 38 | and Packet loss. 39 | The ⓘ symbol opens a window showing a numerical summary of the charted data. 40 | 41 | ### Throughput 42 | 43 | description 44 | 45 | * Download - Steady state throughput, ignoring any startup transients, 46 | during the Download portion of the test 47 | * Upload - Steady state throughput, ignoring any startup transients, 48 | during the Upload portion of the test 49 | * Bidirectional - Sum of the Download and Upload throughputs 50 | during the Bidirectional portion of the test. 51 | Also displays the individual Download and Upload throughputs. 52 | * Streams - number of TCP connections used in each direction 53 | * Stream Stagger - The delay between the start of each stream 54 | * Throughput sample interval - Interval between throughput measurements 55 | 56 | ### Latency 57 | 58 | description 59 | 60 | Crusader smooths all the latency samples over a 400 ms window. 61 | The values shown in the window display the maximum of those smoothed values. 62 | This emphasizes sustained peaks of latency. 63 | 64 | * Download - Summarizes the round-trip latency during the 65 | Download portion of the test. 66 | Also displays the measured one-way delay for Download (from server to client) 67 | and Upload (client to server) 68 | * Upload - Summarizes the latency for the Upload portion of the test 69 | * Bidirectional - Summarizes the latency for the Bidirectional portion of the test 70 | * Idle latency - Measured latency when no traffic is present. 71 | * Latency sample interval - Interval between latency measurements 72 | 73 | ### Packet loss 74 | 75 | description 76 | 77 | When it measures packet loss, Crusader is using the UDP packets 78 | that it also uses for latency measurements. 79 | 80 | * Download - Summarizes packet loss during the Download portion of the test 81 | * Upload - Summarizes packet loss during the Upload portion of the test 82 | * Bidirectional - Summarizes packet loss during the Bidirectional 83 | portion of the test 84 | -------------------------------------------------------------------------------- /docs/TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | * Crusader requires that TCP and UDP ports 35481 are open for its tests. 4 | Crusader also uses ports 35482 for the remote webserver 5 | and port 35483 for discovering other Crusader servers. 6 | Check that your firewall is letting those ports through. 7 | 8 | * On macOS, the first time you double-click 9 | the pre-built `crusader` or `crusader-gui` icon, 10 | the OS refuses to let it run. 11 | You must use **System Preferences -> Privacy & Security** 12 | to approve Crusader to run. 13 | 14 | * The message 15 | `Warning: Load termination timed out. There may be residual untracked traffic in the background.` 16 | is not necessarily harmful. 17 | It may happen due to the TCP termination being lost 18 | or TCP incompatibilities between OSes. 19 | It's likely benign if you see throughput and latency drop 20 | to idle values after the tests in the graph. 21 | 22 | * The up and down latency measurements rely on symmetric stable latency 23 | measurements to the server. 24 | These values may be wrong if those assumption don't hold on test startup. -------------------------------------------------------------------------------- /media/Crusader Screen Shots.md: -------------------------------------------------------------------------------- 1 | # Verify Crusader Screen Shots 2 | 3 | This page is useful for checking the appearance of screen shots. 4 | Capture the screen shots (Cmd-Shift-5 on macOS) and save with filenames 5 | like "Client.png", "Server.png", etc., one for each of the tabs. 6 | 7 | Run the `batch_add_border.sh` script - it finds all these files, 8 | removes the drop shadow and adds "Crusader-" to each result file. 9 | 10 | Remove the original files before committing to git. 11 | 12 | ## Client 13 | 14 | ![Options](./Crusader-Client.png) 15 | 16 | ## Server 17 | 18 | ![Server](./Crusader-Server.png) 19 | 20 | ## Remote 21 | 22 | ![Remote](./Crusader-Remote.png) 23 | 24 | ## Monitor 25 | 26 | ![Monitor](./Crusader-Monitor.png) 27 | 28 | ## Result 29 | 30 | ![Result](./Crusader-Result.png) 31 | 32 | ## Result with stats 33 | 34 | ![Options](./Crusader-Result-with-stats.png) 35 | 36 | ## Throughput popup 37 | 38 | ![Throughput](./Crusader-Throughput.png) 39 | description 40 | 41 | ## Latency popup 42 | 43 | ![Latency](./Crusader-Latency.png) 44 | description 45 | 46 | ## Packet loss popup 47 | 48 | ![Packet Loss](./Crusader-Loss.png) 49 | description 50 | -------------------------------------------------------------------------------- /media/Crusader-Client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Client.png -------------------------------------------------------------------------------- /media/Crusader-Latency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Latency.png -------------------------------------------------------------------------------- /media/Crusader-Loss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Loss.png -------------------------------------------------------------------------------- /media/Crusader-Monitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Monitor.png -------------------------------------------------------------------------------- /media/Crusader-Remote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Remote.png -------------------------------------------------------------------------------- /media/Crusader-Result-with-stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Result-with-stats.png -------------------------------------------------------------------------------- /media/Crusader-Result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Result.png -------------------------------------------------------------------------------- /media/Crusader-Server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Server.png -------------------------------------------------------------------------------- /media/Crusader-Throughput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/media/Crusader-Throughput.png -------------------------------------------------------------------------------- /media/batch_add_border.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # add_border.sh - This script uses ImageMagick to process 4 | # macOS screen shots for publication 5 | 6 | # Usage: 7 | # 1. Take screen shots (Cmd-Shift-5) on macOS 8 | # 2. Save the file with a name that would be a good suffix (e.g. Remote.png) 9 | # 3. Run this script. The script outputs modified files prefixed with "Crusader-" 10 | # 4. Discard the original files 11 | 12 | # The script does the following - for the .png files in the directory: 13 | # - find all .png files that don't begin with "Crusader" 14 | # - remove the transparent area/drop shadow from a macOS screen shot 15 | # - shrink the image to the size of the image 16 | # - draw a grey border 1 px wide around the window. 17 | # - save the resulting file as "Crusader-....png" 18 | # Thanks, ChatGPT 19 | 20 | # Get the directory where the script is located 21 | script_dir="$(dirname "$0")" 22 | 23 | # Define the border size (1 pixel) and colors 24 | border_size=1 25 | border_color="gray" 26 | background_color="white" 27 | 28 | # Loop through all .png files 29 | for input_file in "$script_dir"/[a-zA-Z]*.png; do 30 | # Check if the file actually exists (in case no files match) 31 | if [ ! -f "$input_file" ]; then 32 | continue 33 | fi 34 | 35 | # Ignore files that already start with "Crusader" 36 | if [[ $(basename "$input_file") == Crusader* ]]; then 37 | echo "Skipping: $input_file" 38 | continue 39 | fi 40 | 41 | # Output file name (prepend "Crusader-" to the original file name) 42 | output_file="$script_dir/Crusader-$(basename "$input_file")" 43 | 44 | # Process the image: 45 | # 1. Remove the alpha channel (transparency) by filling with white 46 | # 2. Trim the image to its non-transparent content 47 | # 3. Add a 1-pixel grey border 48 | magick "$input_file" \ 49 | -alpha off \ 50 | -trim \ 51 | -bordercolor $border_color \ 52 | -border ${border_size}x${border_size} \ 53 | "$output_file" 54 | 55 | echo "Processed: $input_file -> $output_file" 56 | done 57 | 58 | echo "All matching .png files processed." 59 | -------------------------------------------------------------------------------- /src/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crusader", "crusader-lib", "crusader-gui-lib", "crusader-gui"] 3 | resolver = "1" 4 | 5 | [profile.dev] 6 | panic = "abort" 7 | 8 | [profile.release] 9 | panic = "abort" 10 | strip = "symbols" 11 | 12 | [profile.speed] 13 | opt-level = 3 14 | codegen-units = 1 15 | inherits = "release" 16 | lto = "fat" 17 | 18 | [profile.size] 19 | inherits = "speed" 20 | opt-level = "z" 21 | 22 | [patch.crates-io] 23 | egui_plot = { git = "https://github.com/Zoxc/egui_plot", branch = "crusader" } 24 | -------------------------------------------------------------------------------- /src/crusader-gui-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crusader-gui-lib" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | toml = "0.5.9" 10 | serde = { version = "1.0.137", features = ["derive"] } 11 | crusader-lib = { path = "../crusader-lib", features = ["server", "client"] } 12 | tokio = { version = "1.18.2", features = ["full"] } 13 | eframe = "0.28.1" 14 | egui_plot = "0.28.1" 15 | egui_extras = { version = "0.28.1", default-features = false } 16 | open = "5.3.0" 17 | 18 | [target.'cfg(not(target_os = "android"))'.dependencies] 19 | rfd = { version = "0.10.0", default-features = false, features = [ 20 | "xdg-portal", 21 | ] } 22 | -------------------------------------------------------------------------------- /src/crusader-gui-lib/src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::{Tab, Tester}; 2 | use crusader_lib::{ 3 | file_format::RawResult, 4 | protocol, 5 | test::{self}, 6 | with_time, Config, 7 | }; 8 | use eframe::{ 9 | egui::{self, vec2, Grid, ScrollArea, TextEdit, Ui}, 10 | emath::Align, 11 | }; 12 | use serde::{Deserialize, Serialize}; 13 | use std::{mem, sync::Arc, time::Duration}; 14 | use tokio::sync::{ 15 | mpsc::{self}, 16 | oneshot, 17 | }; 18 | 19 | #[derive(Serialize, Deserialize, Clone, PartialEq)] 20 | #[serde(default)] 21 | pub struct ClientSettings { 22 | pub server: String, 23 | pub download: bool, 24 | pub upload: bool, 25 | pub bidirectional: bool, 26 | pub streams: u64, 27 | pub load_duration: f64, 28 | pub grace_duration: f64, 29 | pub stream_stagger: f64, 30 | pub latency_sample_interval: u64, 31 | pub throughput_sample_interval: u64, 32 | pub latency_peer: bool, 33 | pub latency_peer_server: String, 34 | pub advanced: bool, 35 | pub idle_test: bool, 36 | pub idle_duration: f64, 37 | } 38 | 39 | impl ClientSettings { 40 | fn config(&self) -> Config { 41 | Config { 42 | port: protocol::PORT, 43 | streams: self.streams, 44 | grace_duration: Duration::from_secs_f64(self.grace_duration), 45 | load_duration: Duration::from_secs_f64(self.load_duration), 46 | stream_stagger: Duration::from_secs_f64(self.stream_stagger), 47 | download: self.download, 48 | upload: self.upload, 49 | bidirectional: self.bidirectional, 50 | ping_interval: Duration::from_millis(self.latency_sample_interval), 51 | throughput_interval: Duration::from_millis(self.throughput_sample_interval), 52 | } 53 | } 54 | } 55 | 56 | impl Default for ClientSettings { 57 | fn default() -> Self { 58 | Self { 59 | server: String::new(), 60 | download: true, 61 | upload: true, 62 | bidirectional: true, 63 | streams: 8, 64 | load_duration: 10.0, 65 | grace_duration: 2.0, 66 | stream_stagger: 0.0, 67 | latency_sample_interval: 5, 68 | throughput_sample_interval: 60, 69 | latency_peer: false, 70 | latency_peer_server: String::new(), 71 | advanced: false, 72 | idle_test: false, 73 | idle_duration: 10.0, 74 | } 75 | } 76 | } 77 | 78 | pub struct Client { 79 | rx: mpsc::UnboundedReceiver, 80 | pub done: Option>>>, 81 | pub abort: Option>, 82 | } 83 | 84 | #[derive(PartialEq, Eq)] 85 | pub enum ClientState { 86 | Stopped, 87 | Stopping, 88 | Running, 89 | } 90 | 91 | impl Tester { 92 | fn start_client(&mut self, ctx: &egui::Context) { 93 | self.save_settings(); 94 | self.msgs.clear(); 95 | self.msg_scrolled = 0; 96 | 97 | let (signal_done, done) = oneshot::channel(); 98 | let (tx, rx) = mpsc::unbounded_channel(); 99 | 100 | let ctx = ctx.clone(); 101 | let ctx_ = ctx.clone(); 102 | 103 | let config = if self.settings.client.idle_test { 104 | let mut config = ClientSettings::default().config(); 105 | config.grace_duration = Duration::from_secs_f64(self.settings.client.idle_duration); 106 | config.ping_interval = 107 | Duration::from_millis(self.settings.client.latency_sample_interval); 108 | config.bidirectional = false; 109 | config.download = false; 110 | config.upload = false; 111 | config 112 | } else { 113 | self.settings.client.config() 114 | }; 115 | 116 | let abort = test::test_callback( 117 | config, 118 | (!self.settings.client.server.trim().is_empty()) 119 | .then_some(&self.settings.client.server), 120 | self.settings.client.latency_peer.then_some( 121 | (!self.settings.client.latency_peer_server.trim().is_empty()) 122 | .then_some(&self.settings.client.latency_peer_server), 123 | ), 124 | Arc::new(move |msg| { 125 | tx.send(with_time(msg)).unwrap(); 126 | ctx.request_repaint(); 127 | }), 128 | Box::new(move |result| { 129 | signal_done.send(result).map_err(|_| ()).unwrap(); 130 | ctx_.request_repaint(); 131 | }), 132 | ); 133 | 134 | self.client = Some(Client { 135 | done: Some(done), 136 | rx, 137 | abort: Some(abort), 138 | }); 139 | self.client_state = ClientState::Running; 140 | } 141 | 142 | fn idle_settings(&mut self, ui: &mut Ui) { 143 | Grid::new("idle-settings").show(ui, |ui| { 144 | ui.label("Duration: "); 145 | ui.add( 146 | egui::DragValue::new(&mut self.settings.client.idle_duration) 147 | .range(0..=1000) 148 | .speed(0.05), 149 | ); 150 | ui.label("seconds"); 151 | ui.end_row(); 152 | if self.settings.client.advanced { 153 | ui.label("Latency sample interval:"); 154 | ui.add( 155 | egui::DragValue::new(&mut self.settings.client.latency_sample_interval) 156 | .range(1..=1000) 157 | .speed(0.05), 158 | ); 159 | ui.label("milliseconds"); 160 | ui.end_row(); 161 | } 162 | }); 163 | 164 | if self.settings.client.advanced { 165 | ui.separator(); 166 | 167 | ui.horizontal_wrapped(|ui| { 168 | ui.checkbox(&mut self.settings.client.latency_peer, "Latency peer:").on_hover_text("Specifies another server (peer) which will also measure the latency to the server independently of the client"); 169 | ui.add_enabled_ui(self.settings.client.latency_peer, |ui| { 170 | ui.add( 171 | TextEdit::singleline(&mut self.settings.client.latency_peer_server) 172 | .hint_text("(Locate local peer)"), 173 | ); 174 | }); 175 | }); 176 | } 177 | 178 | ui.separator(); 179 | 180 | if !self.settings.client.advanced { 181 | let mut any = false; 182 | let config = self.settings.client.clone(); 183 | let default = ClientSettings::default(); 184 | 185 | if config.latency_sample_interval != default.latency_sample_interval { 186 | any = true; 187 | ui.label(format!( 188 | "Latency sample interval: {:.2} milliseconds", 189 | config.latency_sample_interval 190 | )); 191 | } 192 | 193 | if config.latency_peer != default.latency_peer { 194 | any = true; 195 | let server = (!config.latency_peer_server.trim().is_empty()) 196 | .then_some(&*config.latency_peer_server); 197 | ui.label(format!("Latency peer: {}", server.unwrap_or(""))); 198 | } 199 | 200 | if any { 201 | ui.separator(); 202 | } 203 | } 204 | } 205 | 206 | fn latency_under_load_settings(&mut self, ui: &mut Ui, compact: bool) { 207 | if !self.settings.client.advanced || compact { 208 | ui.horizontal_wrapped(|ui| { 209 | ui.checkbox(&mut self.settings.client.download, "Download") 210 | .on_hover_text("Run a download test"); 211 | ui.add_space(10.0); 212 | ui.checkbox(&mut self.settings.client.upload, "Upload") 213 | .on_hover_text("Run an upload test"); 214 | ui.add_space(10.0); 215 | ui.checkbox(&mut self.settings.client.bidirectional, "Bidirectional") 216 | .on_hover_text("Run a test doing both download and upload"); 217 | }); 218 | Grid::new("settings-compact").show(ui, |ui| { 219 | ui.label("Streams: ").on_hover_text( 220 | "The number of TCP connections used to generate traffic in a single direction", 221 | ); 222 | ui.add( 223 | egui::DragValue::new(&mut self.settings.client.streams) 224 | .range(1..=1000) 225 | .speed(0.05), 226 | ); 227 | ui.end_row(); 228 | ui.label("Load duration: ") 229 | .on_hover_text("The duration in which traffic is generated"); 230 | ui.add( 231 | egui::DragValue::new(&mut self.settings.client.load_duration) 232 | .range(0..=1000) 233 | .speed(0.05), 234 | ); 235 | ui.label("seconds"); 236 | ui.end_row(); 237 | if self.settings.client.advanced { 238 | ui.label("Grace duration: ") 239 | .on_hover_text("The idle time between each test"); 240 | ui.add( 241 | egui::DragValue::new(&mut self.settings.client.grace_duration) 242 | .range(0..=1000) 243 | .speed(0.05), 244 | ); 245 | ui.label("seconds"); 246 | ui.end_row(); 247 | ui.label("Stream stagger: ") 248 | .on_hover_text("The delay between the start of each stream"); 249 | ui.add( 250 | egui::DragValue::new(&mut self.settings.client.stream_stagger) 251 | .range(0..=1000) 252 | .speed(0.05), 253 | ); 254 | ui.label("seconds"); 255 | ui.end_row(); 256 | ui.label("Latency sample interval:"); 257 | ui.add( 258 | egui::DragValue::new(&mut self.settings.client.latency_sample_interval) 259 | .range(1..=1000) 260 | .speed(0.05), 261 | ); 262 | ui.label("milliseconds"); 263 | ui.end_row(); 264 | ui.label("Throughput sample interval:"); 265 | ui.add( 266 | egui::DragValue::new(&mut self.settings.client.throughput_sample_interval) 267 | .range(1..=1000) 268 | .speed(0.05), 269 | ); 270 | ui.label("milliseconds"); 271 | ui.end_row(); 272 | } 273 | }); 274 | } else { 275 | Grid::new("settings").show(ui, |ui| { 276 | ui.checkbox(&mut self.settings.client.download, "Download") 277 | .on_hover_text("Run a download test"); 278 | ui.allocate_space(vec2(1.0, 1.0)); 279 | ui.label("Streams: ").on_hover_text( 280 | "The number of TCP connections used to generate traffic in a single direction", 281 | ); 282 | ui.add( 283 | egui::DragValue::new(&mut self.settings.client.streams) 284 | .range(1..=1000) 285 | .speed(0.05), 286 | ); 287 | ui.label(""); 288 | ui.allocate_space(vec2(1.0, 1.0)); 289 | 290 | ui.label("Stream stagger: ") 291 | .on_hover_text("The delay between the start of each stream"); 292 | ui.add( 293 | egui::DragValue::new(&mut self.settings.client.stream_stagger) 294 | .range(0..=1000) 295 | .speed(0.05), 296 | ); 297 | ui.label("seconds"); 298 | ui.end_row(); 299 | 300 | ui.checkbox(&mut self.settings.client.upload, "Upload") 301 | .on_hover_text("Run an upload test"); 302 | ui.label(""); 303 | ui.label("Load duration: ") 304 | .on_hover_text("The duration in which traffic is generated"); 305 | ui.add( 306 | egui::DragValue::new(&mut self.settings.client.load_duration) 307 | .range(0..=1000) 308 | .speed(0.05), 309 | ); 310 | ui.label("seconds"); 311 | ui.label(""); 312 | 313 | ui.label("Latency sample interval: "); 314 | ui.add( 315 | egui::DragValue::new(&mut self.settings.client.latency_sample_interval) 316 | .range(1..=1000) 317 | .speed(0.05), 318 | ); 319 | ui.label("milliseconds"); 320 | ui.end_row(); 321 | 322 | ui.checkbox(&mut self.settings.client.bidirectional, "Bidirectional") 323 | .on_hover_text("Run a test doing both download and upload"); 324 | ui.label(""); 325 | ui.label("Grace duration: ") 326 | .on_hover_text("The idle time between each test"); 327 | ui.add( 328 | egui::DragValue::new(&mut self.settings.client.grace_duration) 329 | .range(0..=1000) 330 | .speed(0.05), 331 | ); 332 | ui.label("seconds"); 333 | ui.label(""); 334 | ui.label("Throughput sample interval: "); 335 | ui.add( 336 | egui::DragValue::new(&mut self.settings.client.throughput_sample_interval) 337 | .range(1..=1000) 338 | .speed(0.05), 339 | ); 340 | ui.label("milliseconds"); 341 | ui.end_row(); 342 | }); 343 | } 344 | 345 | if self.settings.client.advanced { 346 | ui.separator(); 347 | 348 | ui.horizontal_wrapped(|ui| { 349 | ui.checkbox(&mut self.settings.client.latency_peer, "Latency peer:").on_hover_text("Specifies another server (peer) which will also measure the latency to the server independently of the client"); 350 | ui.add_enabled_ui(self.settings.client.latency_peer, |ui| { 351 | ui.add( 352 | TextEdit::singleline(&mut self.settings.client.latency_peer_server) 353 | .hint_text("(Locate local peer)"), 354 | ); 355 | }); 356 | }); 357 | } 358 | 359 | ui.separator(); 360 | 361 | if !self.settings.client.advanced { 362 | let mut any = false; 363 | let config = self.settings.client.clone(); 364 | let default = ClientSettings::default(); 365 | 366 | if config.grace_duration != default.grace_duration { 367 | any = true; 368 | ui.label(format!( 369 | "Grace duration: {:.2} seconds", 370 | config.grace_duration 371 | )); 372 | } 373 | 374 | if config.stream_stagger != default.stream_stagger { 375 | any = true; 376 | ui.label(format!( 377 | "Stream stagger: {:.2} seconds", 378 | config.stream_stagger 379 | )); 380 | } 381 | 382 | if config.latency_sample_interval != default.latency_sample_interval { 383 | any = true; 384 | ui.label(format!( 385 | "Latency sample interval: {:.2} milliseconds", 386 | config.latency_sample_interval 387 | )); 388 | } 389 | 390 | if config.throughput_sample_interval != default.throughput_sample_interval { 391 | any = true; 392 | ui.label(format!( 393 | "Throughput sample interval: {:.2} milliseconds", 394 | config.throughput_sample_interval 395 | )); 396 | } 397 | 398 | if config.latency_peer != default.latency_peer { 399 | any = true; 400 | let server = (!config.latency_peer_server.trim().is_empty()) 401 | .then_some(&*config.latency_peer_server); 402 | ui.label(format!("Latency peer: {}", server.unwrap_or(""))); 403 | } 404 | 405 | if any { 406 | ui.separator(); 407 | } 408 | } 409 | } 410 | 411 | pub fn client(&mut self, ctx: &egui::Context, ui: &mut Ui, compact: bool) { 412 | let active = self.client_state == ClientState::Stopped; 413 | 414 | ui.horizontal_wrapped(|ui| { 415 | ui.add_enabled_ui(active, |ui| { 416 | ui.label("Server address:"); 417 | let response = ui.add( 418 | TextEdit::singleline(&mut self.settings.client.server) 419 | .hint_text("(Locate local server)"), 420 | ); 421 | if self.client_state == ClientState::Stopped 422 | && response.lost_focus() 423 | && ui.input(|i| i.key_pressed(egui::Key::Enter)) 424 | { 425 | self.start_client(ctx) 426 | } 427 | }); 428 | 429 | match self.client_state { 430 | ClientState::Running => { 431 | if ui.button("Stop test").clicked() { 432 | let client = self.client.as_mut().unwrap(); 433 | mem::take(&mut client.abort).unwrap().send(()).unwrap(); 434 | self.client_state = ClientState::Stopping; 435 | } 436 | } 437 | ClientState::Stopping => { 438 | ui.add_enabled_ui(false, |ui| { 439 | let _ = ui.button("Stopping test.."); 440 | }); 441 | } 442 | ClientState::Stopped => { 443 | if ui.button("Start test").clicked() { 444 | self.start_client(ctx) 445 | } 446 | } 447 | } 448 | }); 449 | 450 | ui.separator(); 451 | 452 | ScrollArea::vertical() 453 | .auto_shrink([false; 2]) 454 | .show(ui, |ui| { 455 | ui.add_enabled_ui(active, |ui| { 456 | ui.horizontal(|ui| { 457 | ui.label("Measure:"); 458 | ui.selectable_value( 459 | &mut self.settings.client.idle_test, 460 | false, 461 | "Latency under load", 462 | ); 463 | ui.selectable_value( 464 | &mut self.settings.client.idle_test, 465 | true, 466 | "Latency only", 467 | ); 468 | }); 469 | 470 | ui.separator(); 471 | 472 | if self.settings.client.idle_test { 473 | self.idle_settings(ui); 474 | } else { 475 | self.latency_under_load_settings(ui, compact); 476 | } 477 | 478 | ui.horizontal(|ui| { 479 | let mut default = ClientSettings::default(); 480 | default.idle_test = self.settings.client.idle_test; 481 | default.advanced = self.settings.client.advanced; 482 | default.server = self.settings.client.server.clone(); 483 | default.latency_peer_server = 484 | self.settings.client.latency_peer_server.clone(); 485 | 486 | let parameters_changed = self.settings.client != default; 487 | 488 | ui.add_enabled_ui(parameters_changed, |ui| { 489 | if ui.button("Reset settings").clicked() { 490 | self.settings.client = default; 491 | } 492 | }); 493 | 494 | ui.toggle_value(&mut self.settings.client.advanced, "Advanced mode"); 495 | }) 496 | }); 497 | 498 | if self.client_state == ClientState::Running 499 | || self.client_state == ClientState::Stopping 500 | { 501 | let client = self.client.as_mut().unwrap(); 502 | 503 | while let Ok(msg) = client.rx.try_recv() { 504 | println!("[Client] {msg}"); 505 | self.msgs.push(msg); 506 | } 507 | 508 | if let Ok(result) = client.done.as_mut().unwrap().try_recv() { 509 | match result { 510 | Some(Ok(result)) => { 511 | self.msgs.push(with_time("Test complete")); 512 | let result = result.to_test_result(); 513 | self.set_result(result); 514 | if self.tab == Tab::Client { 515 | self.tab = Tab::Result; 516 | } 517 | } 518 | Some(Err(error)) => { 519 | self.msgs.push(with_time(&format!("Error: {error}"))); 520 | } 521 | None => { 522 | self.msgs.push(with_time("Aborted...")); 523 | } 524 | } 525 | self.client = None; 526 | self.client_state = ClientState::Stopped; 527 | } 528 | } 529 | 530 | if !self.msgs.is_empty() { 531 | ui.separator(); 532 | } 533 | 534 | for (i, msg) in self.msgs.iter().enumerate() { 535 | let response = ui.label(msg); 536 | if self.msg_scrolled <= i { 537 | self.msg_scrolled = i + 1; 538 | response.scroll_to_me(Some(Align::Max)); 539 | } 540 | } 541 | }); 542 | } 543 | } 544 | -------------------------------------------------------------------------------- /src/crusader-gui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crusader-gui" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | crusader-lib = { path = "../crusader-lib" } 10 | crusader-gui-lib = { path = "../crusader-gui-lib" } 11 | env_logger = "0.10.0" 12 | eframe = "0.28.1" 13 | font-kit = "0.14.2" 14 | -------------------------------------------------------------------------------- /src/crusader-gui/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow( 2 | clippy::field_reassign_with_default, 3 | clippy::option_map_unit_fn, 4 | clippy::type_complexity 5 | )] 6 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 7 | 8 | use std::{error::Error, process, sync::Arc}; 9 | 10 | use crusader_gui_lib::Tester; 11 | use crusader_lib::version; 12 | use eframe::{ 13 | egui::{self, Context, FontData, FontDefinitions, FontFamily}, 14 | emath::vec2, 15 | Theme, 16 | }; 17 | #[allow(unused_imports)] 18 | use font_kit::family_name::FamilyName; 19 | use font_kit::{handle::Handle, properties::Properties, source::SystemSource}; 20 | 21 | fn main() { 22 | env_logger::init(); 23 | 24 | let mut options = eframe::NativeOptions::default(); 25 | options.follow_system_theme = false; 26 | options.default_theme = Theme::Light; 27 | 28 | crusader_lib::plot::register_fonts(); 29 | 30 | let settings = std::env::current_exe() 31 | .ok() 32 | .map(|exe| exe.with_extension("toml")); 33 | 34 | let result = eframe::run_native( 35 | &format!("Crusader Network Tester {}", version()), 36 | options, 37 | Box::new(move |cc| { 38 | let ctx = &cc.egui_ctx; 39 | let mut style = ctx.style(); 40 | let style_ = Arc::make_mut(&mut style); 41 | 42 | style_.spacing.button_padding = vec2(6.0, 0.0); 43 | style_.spacing.interact_size.y = 30.0; 44 | style_.spacing.item_spacing = vec2(5.0, 5.0); 45 | 46 | let font_size = if cfg!(target_os = "macos") { 47 | 13.5 48 | } else { 49 | 12.5 50 | }; 51 | 52 | style_.text_styles.get_mut(&egui::TextStyle::Body).map(|v| { 53 | v.size = font_size; 54 | }); 55 | style_ 56 | .text_styles 57 | .get_mut(&egui::TextStyle::Button) 58 | .map(|v| { 59 | v.size = font_size; 60 | }); 61 | style_ 62 | .text_styles 63 | .get_mut(&egui::TextStyle::Small) 64 | .map(|v| { 65 | v.size = font_size; 66 | }); 67 | style_ 68 | .text_styles 69 | .get_mut(&egui::TextStyle::Monospace) 70 | .map(|v| { 71 | v.size = font_size; 72 | }); 73 | ctx.set_style(style); 74 | 75 | load_system_font(ctx).ok(); 76 | 77 | Ok(Box::new(App { 78 | tester: Tester::new(settings), 79 | })) 80 | }), 81 | ); 82 | if let Err(e) = result { 83 | eprintln!("Failed to run GUI: {}", e); 84 | process::exit(1); 85 | } 86 | } 87 | 88 | fn load_system_font(ctx: &Context) -> Result<(), Box> { 89 | let mut fonts = FontDefinitions::default(); 90 | 91 | let handle = SystemSource::new().select_best_match( 92 | &[ 93 | #[cfg(target_os = "macos")] 94 | FamilyName::SansSerif, 95 | #[cfg(windows)] 96 | FamilyName::Title("Segoe UI".to_string()), 97 | ], 98 | &Properties::new(), 99 | )?; 100 | 101 | let buf: Vec = match handle { 102 | Handle::Memory { bytes, .. } => bytes.to_vec(), 103 | Handle::Path { path, .. } => std::fs::read(path)?, 104 | }; 105 | 106 | const UI_FONT: &str = "System Sans Serif"; 107 | 108 | fonts 109 | .font_data 110 | .insert(UI_FONT.to_owned(), FontData::from_owned(buf)); 111 | 112 | if let Some(vec) = fonts.families.get_mut(&FontFamily::Proportional) { 113 | vec.insert(0, UI_FONT.to_owned()); 114 | } 115 | 116 | if let Some(vec) = fonts.families.get_mut(&FontFamily::Monospace) { 117 | vec.insert(0, UI_FONT.to_owned()); 118 | } 119 | 120 | ctx.set_fonts(fonts); 121 | 122 | Ok(()) 123 | } 124 | 125 | struct App { 126 | tester: Tester, 127 | } 128 | 129 | impl eframe::App for App { 130 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 131 | egui::CentralPanel::default().show(ctx, |ui| { 132 | self.tester.show(ctx, ui); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/crusader-lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crusader-lib" 3 | version = "0.1.0" 4 | edition = "2021" 5 | build = "build.rs" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [features] 10 | server = [] 11 | client = ["dep:plotters", "dep:axum", "dep:image", "dep:snap", "dep:serde_json"] 12 | 13 | [dependencies] 14 | plotters = { version = "0.3.6", default-features = false, optional = true, features = [ 15 | "ab_glyph", 16 | "bitmap_backend", 17 | "line_series", 18 | "bitmap_encoder", 19 | ] } 20 | chrono = "0.4.19" 21 | bincode = "1.3.3" 22 | serde = { version = "1.0.137", features = ["derive"] } 23 | serde_json = { version = "1.0.122", optional = true } 24 | rand = "0.8.5" 25 | parking_lot = "0.12.0" 26 | hostname = "0.4.0" 27 | tokio = { version = "1.18.2", features = ["full"] } 28 | tokio-util = { version = "0.7.2", features = ["codec"] } 29 | futures = "0.3.21" 30 | bytes = "1.1.0" 31 | snap = { version = "1.0.5", optional = true } 32 | rmp-serde = "1.1.0" 33 | socket2 = "0.4.6" 34 | nix = { version = "0.29.0", features = ["net"] } 35 | libc = "0.2" 36 | anyhow = "1.0.86" 37 | axum = { version = "0.7.5", features = [ 38 | "ws", 39 | "tokio", 40 | "http1", 41 | ], default-features = false, optional = true } 42 | image = { version = "0.24.9", optional = true } 43 | 44 | [target."cfg(target_os = \"windows\")".dependencies] 45 | ipconfig = { version = "=0.3.2", default-features = false } 46 | widestring = "=1.1.0" 47 | -------------------------------------------------------------------------------- /src/crusader-lib/UFL.txt: -------------------------------------------------------------------------------- 1 | ------------------------------- 2 | UBUNTU FONT LICENCE Version 1.0 3 | ------------------------------- 4 | 5 | PREAMBLE 6 | This licence allows the licensed fonts to be used, studied, modified and 7 | redistributed freely. The fonts, including any derivative works, can be 8 | bundled, embedded, and redistributed provided the terms of this licence 9 | are met. The fonts and derivatives, however, cannot be released under 10 | any other licence. The requirement for fonts to remain under this 11 | licence does not require any document created using the fonts or their 12 | derivatives to be published under this licence, as long as the primary 13 | purpose of the document is not to be a vehicle for the distribution of 14 | the fonts. 15 | 16 | DEFINITIONS 17 | "Font Software" refers to the set of files released by the Copyright 18 | Holder(s) under this licence and clearly marked as such. This may 19 | include source files, build scripts and documentation. 20 | 21 | "Original Version" refers to the collection of Font Software components 22 | as received under this licence. 23 | 24 | "Modified Version" refers to any derivative made by adding to, deleting, 25 | or substituting -- in part or in whole -- any of the components of the 26 | Original Version, by changing formats or by porting the Font Software to 27 | a new environment. 28 | 29 | "Copyright Holder(s)" refers to all individuals and companies who have a 30 | copyright ownership of the Font Software. 31 | 32 | "Substantially Changed" refers to Modified Versions which can be easily 33 | identified as dissimilar to the Font Software by users of the Font 34 | Software comparing the Original Version with the Modified Version. 35 | 36 | To "Propagate" a work means to do anything with it that, without 37 | permission, would make you directly or secondarily liable for 38 | infringement under applicable copyright law, except executing it on a 39 | computer or modifying a private copy. Propagation includes copying, 40 | distribution (with or without modification and with or without charging 41 | a redistribution fee), making available to the public, and in some 42 | countries other activities as well. 43 | 44 | PERMISSION & CONDITIONS 45 | This licence does not grant any rights under trademark law and all such 46 | rights are reserved. 47 | 48 | Permission is hereby granted, free of charge, to any person obtaining a 49 | copy of the Font Software, to propagate the Font Software, subject to 50 | the below conditions: 51 | 52 | 1) Each copy of the Font Software must contain the above copyright 53 | notice and this licence. These can be included either as stand-alone 54 | text files, human-readable headers or in the appropriate machine- 55 | readable metadata fields within text or binary files as long as those 56 | fields can be easily viewed by the user. 57 | 58 | 2) The font name complies with the following: 59 | (a) The Original Version must retain its name, unmodified. 60 | (b) Modified Versions which are Substantially Changed must be renamed to 61 | avoid use of the name of the Original Version or similar names entirely. 62 | (c) Modified Versions which are not Substantially Changed must be 63 | renamed to both (i) retain the name of the Original Version and (ii) add 64 | additional naming elements to distinguish the Modified Version from the 65 | Original Version. The name of such Modified Versions must be the name of 66 | the Original Version, with "derivative X" where X represents the name of 67 | the new work, appended to that name. 68 | 69 | 3) The name(s) of the Copyright Holder(s) and any contributor to the 70 | Font Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except (i) as required by this licence, (ii) to 72 | acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with 73 | their explicit written permission. 74 | 75 | 4) The Font Software, modified or unmodified, in part or in whole, must 76 | be distributed entirely under this licence, and must not be distributed 77 | under any other licence. The requirement for fonts to remain under this 78 | licence does not affect any document created using the Font Software, 79 | except any version of the Font Software extracted from a document 80 | created using the Font Software may only be distributed under this 81 | licence. 82 | 83 | TERMINATION 84 | This licence becomes null and void if any of the above conditions are 85 | not met. 86 | 87 | DISCLAIMER 88 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 89 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 90 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF 91 | COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 92 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 93 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 94 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 95 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER 96 | DEALINGS IN THE FONT SOFTWARE. 97 | -------------------------------------------------------------------------------- /src/crusader-lib/Ubuntu-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zoxc/crusader/5d1e6e76fb9b2e6e618c72d2fe9f99dffac5abab/src/crusader-lib/Ubuntu-Light.ttf -------------------------------------------------------------------------------- /src/crusader-lib/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | if let Some(commit) = Command::new("git") 5 | .args(["rev-parse", "--short", "HEAD"]) 6 | .output() 7 | .ok() 8 | .and_then(|output| String::from_utf8(output.stdout).ok()) 9 | { 10 | println!("cargo:rustc-env=GIT_COMMIT={}", commit.trim()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/crusader-lib/src/common.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | protocol::{receive, send, ClientMessage, Hello, Ping, ServerMessage}, 3 | serve::OnDrop, 4 | }; 5 | use anyhow::{anyhow, bail, Context}; 6 | use bytes::{Bytes, BytesMut}; 7 | use futures::{pin_mut, select, FutureExt, Sink, Stream}; 8 | use rand::Rng; 9 | use rand::{rngs::StdRng, SeedableRng}; 10 | use std::{ 11 | error::Error, 12 | io::Cursor, 13 | net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, 14 | sync::{ 15 | atomic::{AtomicBool, AtomicU64, Ordering}, 16 | Arc, 17 | }, 18 | time::Duration, 19 | }; 20 | use tokio::{ 21 | join, 22 | net::{ 23 | self, 24 | tcp::{OwnedReadHalf, OwnedWriteHalf}, 25 | TcpStream, ToSocketAddrs, UdpSocket, 26 | }, 27 | sync::{ 28 | oneshot, 29 | watch::{self, error::RecvError}, 30 | }, 31 | task::yield_now, 32 | time::{self, timeout, Instant}, 33 | }; 34 | use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; 35 | 36 | #[cfg(feature = "client")] 37 | pub(crate) type Msg = Arc; 38 | 39 | #[allow(unused)] 40 | #[derive(PartialEq, Eq, Debug, Clone, Copy, PartialOrd, Ord)] 41 | pub(crate) enum TestState { 42 | Setup, 43 | Grace1, 44 | LoadFromClient, 45 | Grace2, 46 | LoadFromServer, 47 | Grace3, 48 | LoadFromBoth, 49 | Grace4, 50 | End, 51 | EndPingRecv, 52 | } 53 | 54 | #[cfg(feature = "client")] 55 | #[derive(Copy, Clone, PartialEq)] 56 | pub struct Config { 57 | pub download: bool, 58 | pub upload: bool, 59 | pub bidirectional: bool, 60 | pub port: u16, 61 | pub load_duration: Duration, 62 | pub grace_duration: Duration, 63 | pub streams: u64, 64 | pub stream_stagger: Duration, 65 | pub ping_interval: Duration, 66 | pub throughput_interval: Duration, 67 | } 68 | 69 | pub async fn connect(addr: A, name: &str) -> Result { 70 | match timeout(Duration::from_secs(8), net::TcpStream::connect(addr)).await { 71 | Ok(v) => v.with_context(|| format!("Failed to connect to {name}")), 72 | Err(_) => bail!("Timed out trying to connect to {name}. Is the {name} running?"), 73 | } 74 | } 75 | 76 | pub fn interface_ips() -> Vec<(String, IpAddr)> { 77 | let mut _result = Vec::new(); 78 | 79 | #[cfg(target_family = "unix")] 80 | { 81 | use nix::net::if_::InterfaceFlags; 82 | 83 | if let Ok(interfaces) = nix::ifaddrs::getifaddrs() { 84 | for interface in interfaces { 85 | if interface.flags.contains(InterfaceFlags::IFF_LOOPBACK) { 86 | continue; 87 | } 88 | if !interface.flags.contains(InterfaceFlags::IFF_RUNNING) { 89 | continue; 90 | } 91 | if let Some(addr) = interface.address.as_ref().and_then(|i| i.as_sockaddr_in()) { 92 | _result.push((interface.interface_name.clone(), IpAddr::V4(addr.ip()))); 93 | } 94 | if let Some(addr) = interface.address.as_ref().and_then(|i| i.as_sockaddr_in6()) { 95 | if is_unicast_link_local(addr.ip()) { 96 | continue; 97 | } 98 | _result.push((interface.interface_name.clone(), IpAddr::V6(addr.ip()))); 99 | } 100 | } 101 | } 102 | } 103 | 104 | #[cfg(target_family = "windows")] 105 | { 106 | if let Ok(adapters) = ipconfig::get_adapters() { 107 | for adapter in adapters { 108 | if adapter.oper_status() != ipconfig::OperStatus::IfOperStatusUp { 109 | continue; 110 | } 111 | for &addr in adapter.ip_addresses() { 112 | if let IpAddr::V6(ip) = addr { 113 | if is_unicast_link_local(ip) { 114 | continue; 115 | } 116 | } 117 | if addr.is_loopback() { 118 | continue; 119 | } 120 | _result.push((adapter.friendly_name().to_owned(), addr)); 121 | } 122 | } 123 | } 124 | } 125 | 126 | _result 127 | } 128 | 129 | pub fn is_unicast_link_local(ip: Ipv6Addr) -> bool { 130 | (ip.segments()[0] & 0xffc0) == 0xfe80 131 | } 132 | 133 | pub fn fresh_socket_addr(socket: SocketAddr, port: u16) -> SocketAddr { 134 | match socket { 135 | SocketAddr::V4(socket) => SocketAddr::V4(SocketAddrV4::new(*socket.ip(), port)), 136 | SocketAddr::V6(socket) => { 137 | if let Some(ip) = socket.ip().to_ipv4_mapped() { 138 | return SocketAddr::V4(SocketAddrV4::new(ip, port)); 139 | } 140 | SocketAddr::V6(SocketAddrV6::new(*socket.ip(), port, 0, socket.scope_id())) 141 | } 142 | } 143 | } 144 | 145 | pub fn inherit_local(socket: SocketAddr, ip: IpAddr, port: u16) -> SocketAddr { 146 | if let SocketAddr::V6(socket) = socket { 147 | if let IpAddr::V6(ip) = ip { 148 | if is_unicast_link_local(ip) { 149 | return SocketAddr::V6(SocketAddrV6::new(ip, port, 0, socket.scope_id())); 150 | } 151 | } 152 | } 153 | 154 | SocketAddr::new(ip, port) 155 | } 156 | 157 | pub(crate) fn data() -> Vec { 158 | let mut vec = Vec::with_capacity(128 * 1024); 159 | let mut rng = StdRng::from_seed([ 160 | 18, 141, 186, 158, 195, 76, 244, 56, 219, 131, 65, 128, 250, 63, 228, 44, 233, 34, 9, 51, 161 | 13, 72, 230, 131, 223, 240, 124, 77, 103, 238, 103, 186, 162 | ]); 163 | for _ in 0..vec.capacity() { 164 | vec.push(rng.gen()) 165 | } 166 | vec 167 | } 168 | 169 | pub(crate) async fn read_data( 170 | stream: TcpStream, 171 | buffer: &mut [u8], 172 | bytes: Arc, 173 | until: Instant, 174 | writer_done: oneshot::Receiver<()>, 175 | ) -> Result { 176 | stream.set_linger(Some(Duration::from_secs(0))).ok(); 177 | 178 | let reading_done = Arc::new(AtomicBool::new(false)); 179 | 180 | // Set `reading_done` to true 2 minutes after the load should terminate. 181 | let reading_done_ = reading_done.clone(); 182 | tokio::spawn(async move { 183 | time::sleep_until(until + Duration::from_secs(120)).await; 184 | reading_done_.store(true, Ordering::Release); 185 | }); 186 | 187 | // Set `reading_done` to true after 5 seconds of not receiving data. 188 | let reading_done_ = reading_done.clone(); 189 | let bytes_ = bytes.clone(); 190 | tokio::spawn(async move { 191 | writer_done.await.ok(); 192 | 193 | let mut current = bytes_.load(Ordering::Acquire); 194 | let mut i = 0; 195 | loop { 196 | time::sleep(Duration::from_millis(100)).await; 197 | 198 | if reading_done_.load(Ordering::Acquire) { 199 | break; 200 | } 201 | 202 | let now = bytes_.load(Ordering::Acquire); 203 | 204 | if now != current { 205 | i = 0; 206 | current = now; 207 | } else { 208 | i += 1; 209 | 210 | if i > 50 { 211 | reading_done_.store(true, Ordering::Release); 212 | break; 213 | } 214 | } 215 | } 216 | }); 217 | 218 | // Set `reading_done` to true on exit to terminate the spawned task. 219 | let reading_done_ = reading_done.clone(); 220 | let _on_drop = OnDrop(|| { 221 | reading_done_.store(true, Ordering::Release); 222 | }); 223 | 224 | loop { 225 | if let Ok(Err(err)) = time::timeout(Duration::from_millis(50), stream.readable()).await { 226 | if err.kind() == std::io::ErrorKind::ConnectionReset 227 | || err.kind() == std::io::ErrorKind::ConnectionAborted 228 | { 229 | return Ok(false); 230 | } else { 231 | return Err(err.into()); 232 | } 233 | } 234 | 235 | loop { 236 | if reading_done.load(Ordering::Acquire) { 237 | return Ok(true); 238 | } 239 | 240 | match stream.try_read(buffer) { 241 | Ok(0) => return Ok(false), 242 | Ok(n) => { 243 | bytes.fetch_add(n as u64, Ordering::Release); 244 | yield_now().await; 245 | } 246 | Err(err) => { 247 | if err.kind() == std::io::ErrorKind::WouldBlock { 248 | break; 249 | } else if err.kind() == std::io::ErrorKind::ConnectionReset 250 | || err.kind() == std::io::ErrorKind::ConnectionAborted 251 | { 252 | return Ok(false); 253 | } else { 254 | return Err(err.into()); 255 | } 256 | } 257 | } 258 | } 259 | } 260 | } 261 | 262 | pub(crate) async fn write_data( 263 | stream: TcpStream, 264 | data: &[u8], 265 | until: Instant, 266 | ) -> Result<(), anyhow::Error> { 267 | stream.set_nodelay(false).ok(); 268 | stream.set_linger(Some(Duration::from_secs(0))).ok(); 269 | 270 | let done = Arc::new(AtomicBool::new(false)); 271 | let done_ = done.clone(); 272 | 273 | tokio::spawn(async move { 274 | time::sleep_until(until).await; 275 | done.store(true, Ordering::Release); 276 | }); 277 | 278 | loop { 279 | if let Ok(Err(err)) = time::timeout(Duration::from_millis(50), stream.writable()).await { 280 | if err.kind() == std::io::ErrorKind::ConnectionReset 281 | || err.kind() == std::io::ErrorKind::ConnectionAborted 282 | { 283 | break; 284 | } else { 285 | return Err(err.into()); 286 | } 287 | } 288 | 289 | if done_.load(Ordering::Acquire) { 290 | break; 291 | } 292 | match stream.try_write(data) { 293 | Ok(_) => (), 294 | Err(err) => { 295 | if err.kind() == std::io::ErrorKind::WouldBlock { 296 | } else if err.kind() == std::io::ErrorKind::ConnectionReset 297 | || err.kind() == std::io::ErrorKind::ConnectionAborted 298 | { 299 | break; 300 | } else { 301 | return Err(err.into()); 302 | } 303 | } 304 | } 305 | 306 | yield_now().await; 307 | } 308 | 309 | std::mem::drop(stream); 310 | 311 | Ok(()) 312 | } 313 | 314 | pub(crate) async fn hello< 315 | T: Sink + Unpin, 316 | R: Stream> + Unpin, 317 | RE, 318 | >( 319 | tx: &mut T, 320 | rx: &mut R, 321 | ) -> Result<(), anyhow::Error> 322 | where 323 | T::Error: Error + Send + Sync + 'static, 324 | RE: Error + Send + Sync + 'static, 325 | { 326 | let hello = Hello::new(); 327 | 328 | send(tx, &hello).await.context("Sending hello")?; 329 | let server_hello: Hello = receive(rx).await.context("Receiving hello")?; 330 | 331 | if hello != server_hello { 332 | bail!( 333 | "Mismatched server hello, got {:?}, expected {:?}", 334 | server_hello, 335 | hello 336 | ); 337 | } 338 | 339 | Ok(()) 340 | } 341 | 342 | pub(crate) fn udp_handle(result: std::io::Result<()>) -> std::io::Result<()> { 343 | match result { 344 | Ok(v) => Ok(v), 345 | Err(e) => { 346 | if e.raw_os_error() == Some(libc::ENOBUFS) { 347 | Ok(()) 348 | } else { 349 | Err(e) 350 | } 351 | } 352 | } 353 | } 354 | 355 | async fn ping_measure_send( 356 | mut index: u64, 357 | id: u64, 358 | setup_start: Instant, 359 | socket: Arc, 360 | samples: u32, 361 | ) -> Result<(Vec, u64), anyhow::Error> { 362 | let mut storage = Vec::with_capacity(samples as usize); 363 | let mut buf = [0; 64]; 364 | 365 | let mut interval = time::interval(Duration::from_millis(5)); 366 | 367 | for _ in 0..samples { 368 | interval.tick().await; 369 | 370 | let current = setup_start.elapsed(); 371 | 372 | let ping = Ping { id, index }; 373 | 374 | index += 1; 375 | 376 | let mut cursor = Cursor::new(&mut buf[..]); 377 | bincode::serialize_into(&mut cursor, &ping)?; 378 | let buf = &cursor.get_ref()[0..(cursor.position() as usize)]; 379 | 380 | socket.send(buf).await?; 381 | 382 | storage.push(current); 383 | } 384 | 385 | Ok((storage, index)) 386 | } 387 | 388 | async fn ping_measure_recv( 389 | setup_start: Instant, 390 | socket: Arc, 391 | samples: u32, 392 | ) -> Result, anyhow::Error> { 393 | let mut storage = Vec::with_capacity(samples as usize); 394 | let mut buf = [0; 64]; 395 | 396 | let end = time::sleep(Duration::from_millis(5) * samples + Duration::from_millis(1000)).fuse(); 397 | pin_mut!(end); 398 | 399 | loop { 400 | let result = { 401 | let packet = socket.recv(&mut buf).fuse(); 402 | pin_mut!(packet); 403 | 404 | select! { 405 | result = packet => result, 406 | _ = end => break, 407 | } 408 | }; 409 | 410 | let current = setup_start.elapsed(); 411 | let len = result?; 412 | let buf = buf 413 | .get_mut(..len) 414 | .ok_or_else(|| anyhow!("Pong too large"))?; 415 | let ping: Ping = bincode::deserialize(buf)?; 416 | 417 | storage.push((ping, current)); 418 | } 419 | 420 | Ok(storage) 421 | } 422 | 423 | pub struct LatencyResult { 424 | pub latency: Duration, 425 | pub threshold: Duration, 426 | pub server_pong: Duration, 427 | pub server_offset: u64, 428 | pub server_time: u64, 429 | pub control_rx: FramedRead, 430 | } 431 | 432 | pub(crate) async fn measure_latency( 433 | id: u64, 434 | ping_index: &mut u64, 435 | mut control_tx: &mut FramedWrite, 436 | mut control_rx: FramedRead, 437 | server: SocketAddr, 438 | local_udp: SocketAddr, 439 | setup_start: Instant, 440 | ) -> Result { 441 | send(&mut control_tx, &ClientMessage::GetMeasurements).await?; 442 | 443 | let latencies = tokio::spawn(async move { 444 | let mut latencies = Vec::new(); 445 | 446 | loop { 447 | let reply: ServerMessage = receive(&mut control_rx).await?; 448 | match reply { 449 | ServerMessage::LatencyMeasures(measures) => { 450 | latencies.extend(measures.into_iter()); 451 | } 452 | ServerMessage::MeasurementsDone { .. } => break, 453 | _ => bail!("Unexpected message {:?}", reply), 454 | }; 455 | } 456 | 457 | Ok((latencies, control_rx)) 458 | }); 459 | 460 | let udp_socket = Arc::new(net::UdpSocket::bind(local_udp).await?); 461 | udp_socket.connect(server).await?; 462 | let udp_socket2 = udp_socket.clone(); 463 | 464 | let samples = 100; 465 | 466 | let ping_start_index = *ping_index; 467 | let ping_send = tokio::spawn(ping_measure_send( 468 | ping_start_index, 469 | id, 470 | setup_start, 471 | udp_socket, 472 | samples, 473 | )); 474 | 475 | let ping_recv = tokio::spawn(ping_measure_recv(setup_start, udp_socket2, samples)); 476 | 477 | let (sent, recv) = join!(ping_send, ping_recv); 478 | 479 | send(&mut control_tx, &ClientMessage::StopMeasurements).await?; 480 | 481 | let (mut latencies, control_rx) = latencies.await??; 482 | 483 | let (sent, new_ping_index) = sent??; 484 | *ping_index = new_ping_index; 485 | let mut recv = recv??; 486 | 487 | latencies.sort_by_key(|d| d.index); 488 | recv.sort_by_key(|d| d.0.index); 489 | let mut pings: Vec<(Duration, Duration, u64)> = sent 490 | .into_iter() 491 | .enumerate() 492 | .filter_map(|(index, sent)| { 493 | let index = index as u64 + ping_start_index; 494 | let latency = latencies 495 | .binary_search_by_key(&index, |e| e.index) 496 | .ok() 497 | .map(|ping| latencies[ping].time); 498 | 499 | latency.and_then(|time| { 500 | recv.binary_search_by_key(&index, |e| e.0.index) 501 | .ok() 502 | .map(|ping| (sent, recv[ping].1 - sent, time)) 503 | }) 504 | }) 505 | .collect(); 506 | if pings.is_empty() { 507 | bail!("Unable to measure latency to server"); 508 | } 509 | if pings.len() < (samples / 2) as usize { 510 | bail!("Unable get enough latency samples from server"); 511 | } 512 | 513 | pings.sort_by_key(|d| d.1); 514 | 515 | let latency = pings.get(pings.len() / 2).unwrap().1; 516 | 517 | let threshold = pings.get(pings.len() / 3).unwrap().1; 518 | 519 | let pings: Vec<_> = pings 520 | .get(0..=(pings.len() / 3)) 521 | .unwrap() 522 | .iter() 523 | .map(|&(sent, latency, server_time)| { 524 | let server_pong = sent + latency / 2; 525 | 526 | let server_offset = (server_pong.as_micros() as u64).wrapping_sub(server_time); 527 | 528 | (server_pong, latency, server_offset, server_time) 529 | }) 530 | .collect(); 531 | 532 | let server_pong = pings 533 | .iter() 534 | .map(|&(server_pong, _, _, _)| server_pong) 535 | .sum::() 536 | / (pings.len() as u32); 537 | 538 | let server_offset = pings 539 | .iter() 540 | .map(|&(_, _, offset, _)| offset as u128) 541 | .sum::() 542 | / (pings.len() as u128); 543 | 544 | let server_time = pings 545 | .iter() 546 | .map(|&(_, _, _, time)| time as u128) 547 | .sum::() 548 | / (pings.len() as u128); 549 | 550 | Ok(LatencyResult { 551 | latency, 552 | threshold, 553 | server_pong, 554 | server_offset: server_offset as u64, 555 | server_time: server_time as u64, 556 | control_rx, 557 | }) 558 | } 559 | 560 | pub(crate) async fn ping_send( 561 | mut ping_index: u64, 562 | id: u64, 563 | state_rx: watch::Receiver<(TestState, Instant)>, 564 | setup_start: Instant, 565 | socket: Arc, 566 | interval: Duration, 567 | estimated_duration: Duration, 568 | ) -> Result<(Vec, u64), anyhow::Error> { 569 | let mut storage = Vec::with_capacity( 570 | ((estimated_duration.as_secs_f64() + 2.0) * (1000.0 / interval.as_millis() as f64) * 1.5) 571 | as usize, 572 | ); 573 | let mut buf = [0; 64]; 574 | 575 | let mut interval = time::interval(interval); 576 | 577 | loop { 578 | interval.tick().await; 579 | 580 | if state_rx.borrow().0 >= TestState::End { 581 | break; 582 | } 583 | 584 | let current = setup_start.elapsed(); 585 | 586 | let ping = Ping { 587 | id, 588 | index: ping_index, 589 | }; 590 | 591 | ping_index += 1; 592 | 593 | let mut cursor = Cursor::new(&mut buf[..]); 594 | bincode::serialize_into(&mut cursor, &ping).unwrap(); 595 | let buf = &cursor.get_ref()[0..(cursor.position() as usize)]; 596 | 597 | udp_handle(socket.send(buf).await.map(|_| ())).context("Unable to send UDP ping packet")?; 598 | 599 | storage.push(current); 600 | } 601 | 602 | Ok((storage, ping_index)) 603 | } 604 | 605 | pub(crate) async fn ping_recv( 606 | mut state_rx: watch::Receiver<(TestState, Instant)>, 607 | setup_start: Instant, 608 | socket: Arc, 609 | interval: Duration, 610 | estimated_duration: Duration, 611 | ) -> Result, anyhow::Error> { 612 | let mut storage = Vec::with_capacity( 613 | ((estimated_duration.as_secs_f64() + 2.0) * (1000.0 / interval.as_millis() as f64) * 1.5) 614 | as usize, 615 | ); 616 | let mut buf = [0; 64]; 617 | 618 | let end = wait_for_state(&mut state_rx, TestState::EndPingRecv).fuse(); 619 | pin_mut!(end); 620 | 621 | loop { 622 | let result = { 623 | let packet = socket.recv(&mut buf).fuse(); 624 | pin_mut!(packet); 625 | 626 | select! { 627 | result = packet => result, 628 | _ = end => break, 629 | } 630 | }; 631 | 632 | let current = setup_start.elapsed(); 633 | let len = result?; 634 | let buf = buf 635 | .get_mut(..len) 636 | .ok_or_else(|| anyhow!("Pong too large"))?; 637 | let ping: Ping = bincode::deserialize(buf)?; 638 | 639 | storage.push((ping, current)); 640 | } 641 | 642 | Ok(storage) 643 | } 644 | 645 | pub(crate) async fn wait_for_state( 646 | state_rx: &mut watch::Receiver<(TestState, Instant)>, 647 | state: TestState, 648 | ) -> Result { 649 | loop { 650 | { 651 | let current = state_rx.borrow_and_update(); 652 | if current.0 == state { 653 | return Ok(current.1); 654 | } 655 | } 656 | state_rx.changed().await?; 657 | } 658 | } 659 | -------------------------------------------------------------------------------- /src/crusader-lib/src/discovery.rs: -------------------------------------------------------------------------------- 1 | use crate::{common::is_unicast_link_local, protocol, serve::State, version}; 2 | #[cfg(feature = "client")] 3 | use anyhow::anyhow; 4 | use anyhow::bail; 5 | #[cfg(target_family = "unix")] 6 | use nix::net::if_::InterfaceFlags; 7 | use serde::{Deserialize, Serialize}; 8 | use socket2::{Domain, Protocol, Socket}; 9 | use std::{ 10 | net::{IpAddr, Ipv6Addr, SocketAddr}, 11 | str::FromStr, 12 | sync::Arc, 13 | }; 14 | use tokio::net::UdpSocket; 15 | 16 | pub const DISCOVER_PORT: u16 = protocol::PORT + 2; 17 | pub const DISCOVER_VERSION: u64 = 0; 18 | 19 | #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] 20 | struct Hello { 21 | magic: u64, 22 | pub version: u64, 23 | } 24 | 25 | impl Hello { 26 | pub fn new() -> Self { 27 | Hello { 28 | magic: protocol::MAGIC, 29 | version: DISCOVER_VERSION, 30 | } 31 | } 32 | } 33 | 34 | #[derive(Serialize, Deserialize, Debug)] 35 | struct Data { 36 | hello: Hello, 37 | message: Message, 38 | } 39 | 40 | #[derive(Serialize, Deserialize, Debug)] 41 | enum Message { 42 | Discover { 43 | peer: bool, 44 | }, 45 | Server { 46 | peer: bool, 47 | port: u16, 48 | protocol_version: u64, 49 | software_version: String, 50 | hostname: Option, 51 | label: Option, 52 | ips: Vec<[u8; 16]>, 53 | }, 54 | } 55 | 56 | #[cfg(feature = "client")] 57 | pub struct Server { 58 | pub at: String, 59 | pub socket: SocketAddr, 60 | pub software_version: String, 61 | } 62 | 63 | fn interfaces() -> Vec { 64 | let mut _result = vec![0]; 65 | 66 | #[cfg(target_family = "unix")] 67 | { 68 | if let Ok(interfaces) = nix::ifaddrs::getifaddrs() { 69 | for interface in interfaces { 70 | if interface.flags.contains(InterfaceFlags::IFF_LOOPBACK) { 71 | continue; 72 | } 73 | if !interface.flags.contains(InterfaceFlags::IFF_MULTICAST) { 74 | continue; 75 | } 76 | if let Some(addr) = interface.address.as_ref().and_then(|i| i.as_sockaddr_in6()) { 77 | if !is_unicast_link_local(addr.ip()) { 78 | continue; 79 | } 80 | _result.push(addr.scope_id()); 81 | } 82 | } 83 | } 84 | } 85 | 86 | _result 87 | } 88 | 89 | #[cfg(feature = "client")] 90 | pub async fn locate(peer_server: bool) -> Result { 91 | use crate::{common::fresh_socket_addr, serve::OnDrop}; 92 | use std::{ 93 | net::SocketAddrV6, 94 | sync::atomic::{AtomicBool, Ordering}, 95 | time::Duration, 96 | }; 97 | use tokio::time::{self, timeout}; 98 | 99 | fn handle_packet( 100 | peer_server: bool, 101 | packet: &[u8], 102 | src: SocketAddr, 103 | ) -> Result { 104 | let data: Data = bincode::deserialize(packet)?; 105 | if data.hello != Hello::new() { 106 | bail!("Wrong hello"); 107 | } 108 | if let Message::Server { 109 | peer, 110 | port, 111 | protocol_version, 112 | software_version, 113 | hostname, 114 | ips: _, 115 | label: _, 116 | } = data.message 117 | { 118 | if peer != peer_server { 119 | bail!("Wrong server kind"); 120 | } 121 | if protocol_version != protocol::VERSION { 122 | bail!("Wrong protocol"); 123 | } 124 | let socket = fresh_socket_addr(src, port); 125 | 126 | let at = hostname 127 | .map(|hostname| format!("`{hostname}` {socket}")) 128 | .unwrap_or(socket.to_string()); 129 | 130 | Ok(Server { 131 | at, 132 | socket, 133 | software_version, 134 | }) 135 | } else { 136 | bail!("Wrong message") 137 | } 138 | } 139 | 140 | let socket = Socket::new(Domain::IPV6, socket2::Type::DGRAM, Some(Protocol::UDP))?; 141 | socket.set_only_v6(true)?; 142 | socket.bind(&SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).into())?; 143 | let socket: std::net::UdpSocket = socket.into(); 144 | socket.set_nonblocking(true)?; 145 | let socket = UdpSocket::from_std(socket)?; 146 | 147 | socket.set_broadcast(true)?; 148 | 149 | let socket = Arc::new(socket); 150 | 151 | let data = Data { 152 | hello: Hello::new(), 153 | message: Message::Discover { peer: peer_server }, 154 | }; 155 | 156 | let buf = bincode::serialize(&data)?; 157 | 158 | let ip = Ipv6Addr::from_str("ff02::1").unwrap(); 159 | 160 | let socket_ = socket.clone(); 161 | let send_packet = move || { 162 | let socket = socket_.clone(); 163 | let buf = buf.clone(); 164 | async move { 165 | let mut any = false; 166 | for interface in interfaces() { 167 | if socket 168 | .send_to(&buf, SocketAddrV6::new(ip, DISCOVER_PORT, 0, interface)) 169 | .await 170 | .is_ok() 171 | { 172 | any = true; 173 | } 174 | } 175 | any 176 | } 177 | }; 178 | 179 | if !send_packet().await { 180 | bail!("Failed to send any discovery multicast packets"); 181 | } 182 | 183 | let done = Arc::new(AtomicBool::new(false)); 184 | let done_ = done.clone(); 185 | let _on_drop = OnDrop(|| { 186 | done_.store(true, Ordering::Release); 187 | }); 188 | 189 | tokio::spawn(async move { 190 | loop { 191 | time::sleep(Duration::from_millis(100)).await; 192 | 193 | if done.load(Ordering::Acquire) { 194 | break; 195 | } 196 | 197 | send_packet().await; 198 | } 199 | }); 200 | 201 | let find = async { 202 | let mut buf = [0; 1500]; 203 | loop { 204 | if let Ok((len, src)) = socket.recv_from(&mut buf).await { 205 | if let Ok(server) = handle_packet(peer_server, &buf[..len], src) { 206 | return server; 207 | } 208 | } 209 | } 210 | }; 211 | 212 | timeout(Duration::from_secs(1), find).await.map_err(|_| { 213 | if peer_server { 214 | anyhow!("Failed to locate local latency peer") 215 | } else { 216 | anyhow!("Failed to locate local server") 217 | } 218 | }) 219 | } 220 | 221 | pub fn serve(state: Arc, port: u16) -> Result<(), anyhow::Error> { 222 | async fn handle_packet( 223 | port: u16, 224 | peer_server: bool, 225 | hostname: &Option, 226 | packet: &[u8], 227 | socket: &UdpSocket, 228 | src: SocketAddr, 229 | ) -> Result<(), anyhow::Error> { 230 | match src { 231 | SocketAddr::V6(src) if is_unicast_link_local(*src.ip()) => (), 232 | _ => bail!("Unexpected source"), 233 | } 234 | let data: Data = bincode::deserialize(packet)?; 235 | if data.hello != Hello::new() { 236 | bail!("Wrong hello"); 237 | } 238 | if let Message::Discover { peer } = data.message { 239 | if peer != peer_server { 240 | return Ok(()); 241 | } 242 | let data = Data { 243 | hello: Hello::new(), 244 | message: Message::Server { 245 | peer, 246 | port, 247 | protocol_version: protocol::VERSION, 248 | software_version: version(), 249 | hostname: hostname.clone(), 250 | label: None, 251 | ips: Vec::new(), 252 | }, 253 | }; 254 | let buf = bincode::serialize(&data)?; 255 | socket.send_to(&buf, src).await?; 256 | } 257 | Ok(()) 258 | } 259 | 260 | let hostname = hostname::get() 261 | .ok() 262 | .and_then(|n| n.into_string().ok()) 263 | .filter(|n| n != "localhost"); 264 | 265 | let socket = Socket::new(Domain::IPV6, socket2::Type::DGRAM, Some(Protocol::UDP))?; 266 | socket.set_only_v6(true)?; 267 | socket.set_reuse_address(true)?; 268 | socket.bind(&SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), DISCOVER_PORT).into())?; 269 | let socket: std::net::UdpSocket = socket.into(); 270 | socket.set_nonblocking(true)?; 271 | let socket = UdpSocket::from_std(socket)?; 272 | 273 | let ip = Ipv6Addr::from_str("ff02::1").unwrap(); 274 | 275 | let mut any = false; 276 | for interface in interfaces() { 277 | if socket.join_multicast_v6(&ip, interface).is_ok() { 278 | any = true; 279 | } 280 | } 281 | if !any { 282 | bail!("Failed to join any multicast groups"); 283 | } 284 | 285 | tokio::spawn(async move { 286 | let mut buf = [0; 1500]; 287 | loop { 288 | if let Ok((len, src)) = socket.recv_from(&mut buf).await { 289 | handle_packet( 290 | port, 291 | state.peer_server, 292 | &hostname, 293 | &buf[..len], 294 | &socket, 295 | src, 296 | ) 297 | .await 298 | .map_err(|error| { 299 | (state.msg)(&format!("Unable to handle discovery packet: {:?}", error)); 300 | }) 301 | .ok(); 302 | } 303 | } 304 | }); 305 | 306 | Ok(()) 307 | } 308 | -------------------------------------------------------------------------------- /src/crusader-lib/src/file_format.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fs::File; 3 | use std::io::BufReader; 4 | use std::io::BufWriter; 5 | use std::io::Read; 6 | use std::io::Write; 7 | use std::path::Path; 8 | use std::time::Duration; 9 | 10 | use crate::protocol; 11 | use crate::protocol::RawLatency; 12 | 13 | // Note that rmp_serde doesn't not use an enumerator when serializing Option. 14 | // Be careful about which types are inside Option. 15 | 16 | #[derive(Serialize, Deserialize)] 17 | #[serde(transparent)] 18 | pub struct Elasped { 19 | pub microseconds: u64, 20 | } 21 | 22 | // V0 specific 23 | 24 | #[derive(Serialize, Deserialize)] 25 | pub struct RawPingV0 { 26 | pub index: u64, 27 | pub sent: Duration, 28 | pub latency: Option, 29 | } 30 | 31 | impl RawPingV0 { 32 | pub fn to_v1(&self) -> RawPing { 33 | RawPing { 34 | index: self.index, 35 | sent: self.sent, 36 | latency: self.latency.map(|total| RawLatency { 37 | total: Some(total), 38 | up: Duration::from_secs(0), 39 | }), 40 | } 41 | } 42 | } 43 | 44 | #[derive(Serialize, Deserialize, Clone)] 45 | pub struct RawConfigV0 { 46 | // Seconds 47 | pub load_duration: u64, 48 | pub grace_duration: u64, 49 | 50 | // Milliseconds 51 | pub ping_interval: u64, 52 | pub bandwidth_interval: u64, 53 | } 54 | 55 | impl RawConfigV0 { 56 | pub fn to_v1(&self) -> RawConfig { 57 | RawConfig { 58 | stagger: Duration::from_secs(0), 59 | load_duration: Duration::from_secs(self.load_duration), 60 | grace_duration: Duration::from_secs(self.grace_duration), 61 | ping_interval: Duration::from_millis(self.ping_interval), 62 | bandwidth_interval: Duration::from_millis(self.bandwidth_interval), 63 | } 64 | } 65 | } 66 | 67 | #[derive(Serialize, Deserialize)] 68 | pub struct RawResultV0 { 69 | pub config: RawConfigV0, 70 | pub start: Duration, 71 | pub duration: Duration, 72 | pub stream_groups: Vec, 73 | pub pings: Vec, 74 | } 75 | 76 | impl RawResultV0 { 77 | pub fn to_v1(&self) -> RawResult { 78 | RawResult { 79 | version: 0, 80 | generated_by: String::new(), 81 | config: self.config.to_v1(), 82 | start: self.start, 83 | server_latency: Duration::from_secs(0), 84 | ipv6: false, 85 | duration: self.duration, 86 | stream_groups: self.stream_groups.clone(), 87 | pings: self.pings.iter().map(|ping| ping.to_v1()).collect(), 88 | server_overload: false, 89 | load_termination_timeout: false, 90 | peer_pings: None, 91 | test_data: Vec::new(), 92 | } 93 | } 94 | } 95 | 96 | #[derive(Serialize, Deserialize, Copy, Clone, PartialEq, Eq, Hash, Debug)] 97 | pub enum TestKind { 98 | Download, 99 | Upload, 100 | Bidirectional, 101 | } 102 | 103 | impl TestKind { 104 | pub fn name(&self) -> &'static str { 105 | match *self { 106 | Self::Download => "Download", 107 | Self::Upload => "Upload", 108 | Self::Bidirectional => "Bidirectional", 109 | } 110 | } 111 | } 112 | 113 | #[derive(Serialize, Deserialize, Clone)] 114 | pub struct TestData { 115 | pub start: Duration, 116 | pub end: Duration, 117 | pub kind: TestKind, 118 | } 119 | 120 | #[derive(Serialize, Deserialize, Clone)] 121 | pub struct RawPoint { 122 | pub time: Duration, 123 | pub bytes: u64, 124 | } 125 | 126 | #[derive(Serialize, Deserialize, Clone)] 127 | pub struct RawStream { 128 | pub data: Vec, 129 | } 130 | 131 | impl RawStream { 132 | pub(crate) fn to_vec(&self) -> Vec<(u64, u64)> { 133 | self.data 134 | .iter() 135 | .map(|point| (point.time.as_micros() as u64, point.bytes)) 136 | .collect() 137 | } 138 | } 139 | 140 | #[derive(Serialize, Deserialize, Clone)] 141 | pub struct RawStreamGroup { 142 | pub download: bool, 143 | pub both: bool, 144 | pub streams: Vec, 145 | } 146 | 147 | #[derive(Serialize, Deserialize, Clone, Debug)] 148 | pub struct RawPing { 149 | pub index: u64, 150 | pub sent: Duration, 151 | pub latency: Option, 152 | } 153 | 154 | #[derive(Serialize, Deserialize, Clone)] 155 | pub struct RawConfig { 156 | // Microseconds 157 | pub stagger: Duration, 158 | pub load_duration: Duration, 159 | pub grace_duration: Duration, 160 | pub ping_interval: Duration, 161 | pub bandwidth_interval: Duration, 162 | } 163 | 164 | #[derive(Serialize, Deserialize, Eq, PartialEq)] 165 | pub struct RawHeader { 166 | pub magic: u64, 167 | pub version: u64, 168 | } 169 | 170 | impl Default for RawHeader { 171 | fn default() -> Self { 172 | Self { 173 | magic: protocol::MAGIC, 174 | version: 2, 175 | } 176 | } 177 | } 178 | 179 | #[derive(Serialize, Deserialize, Clone)] 180 | pub struct RawResult { 181 | pub version: u64, 182 | pub generated_by: String, 183 | pub config: RawConfig, 184 | pub ipv6: bool, 185 | #[serde(default)] 186 | pub load_termination_timeout: bool, // Added in V2 187 | #[serde(default)] 188 | pub server_overload: bool, // Added in V2 189 | pub server_latency: Duration, 190 | pub start: Duration, 191 | pub duration: Duration, 192 | pub stream_groups: Vec, 193 | pub pings: Vec, 194 | #[serde(default)] 195 | pub peer_pings: Option>, // Added in V2 196 | #[serde(default)] // Added in V2 197 | pub test_data: Vec, 198 | } 199 | 200 | impl RawResult { 201 | pub fn streams(&self) -> u64 { 202 | self.stream_groups 203 | .first() 204 | .map(|group| group.streams.len()) 205 | .unwrap_or_default() 206 | .try_into() 207 | .unwrap() 208 | } 209 | 210 | pub fn download(&self) -> bool { 211 | self.stream_groups 212 | .iter() 213 | .any(|group| group.download && !group.both) 214 | } 215 | 216 | pub fn upload(&self) -> bool { 217 | self.stream_groups 218 | .iter() 219 | .any(|group| !group.download && !group.both) 220 | } 221 | 222 | pub fn idle(&self) -> bool { 223 | self.stream_groups.is_empty() 224 | } 225 | 226 | pub fn both(&self) -> bool { 227 | self.stream_groups.iter().any(|group| group.both) 228 | } 229 | 230 | pub fn load_from_reader(reader: impl Read) -> Option { 231 | let mut file = BufReader::new(reader); 232 | let header: RawHeader = bincode::deserialize_from(&mut file).ok()?; 233 | if header.magic != RawHeader::default().magic { 234 | return None; 235 | } 236 | match header.version { 237 | 0 => { 238 | let result: RawResultV0 = bincode::deserialize_from(file).ok()?; 239 | Some(result.to_v1()) 240 | } 241 | 1 | 2 => { 242 | let data = snap::read::FrameDecoder::new(file); 243 | Some(rmp_serde::decode::from_read(data).ok()?) 244 | } 245 | _ => None, 246 | } 247 | } 248 | 249 | pub fn load(path: &Path) -> Option { 250 | Self::load_from_reader(File::open(path).ok()?) 251 | } 252 | 253 | pub fn save_to_writer(&self, writer: impl Write) -> Result<(), anyhow::Error> { 254 | let mut file = BufWriter::new(writer); 255 | 256 | bincode::serialize_into(&mut file, &RawHeader::default())?; 257 | 258 | let mut compressor = snap::write::FrameEncoder::new(file); 259 | 260 | self.serialize(&mut rmp_serde::Serializer::new(&mut compressor).with_struct_map())?; 261 | 262 | compressor.flush()?; 263 | Ok(()) 264 | } 265 | 266 | pub fn save(&self, name: &Path) -> Result<(), anyhow::Error> { 267 | self.save_to_writer(File::create(name)?) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/crusader-lib/src/latency.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, bail, Context}; 2 | use futures::future::FutureExt; 3 | use futures::select; 4 | use parking_lot::Mutex; 5 | use std::collections::VecDeque; 6 | use std::{ 7 | io::Cursor, 8 | net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, 9 | sync::Arc, 10 | time::Duration, 11 | }; 12 | use std::{iter, thread}; 13 | use tokio::net::UdpSocket; 14 | use tokio::sync::mpsc::{channel, Sender}; 15 | use tokio::sync::oneshot; 16 | use tokio::task; 17 | use tokio::time::Instant; 18 | use tokio::{ 19 | net::{self}, 20 | time, 21 | }; 22 | use tokio_util::codec::{FramedRead, FramedWrite}; 23 | 24 | use crate::common::{connect, hello, measure_latency, udp_handle, LatencyResult}; 25 | use crate::discovery; 26 | use crate::protocol::{codec, receive, send, ClientMessage, Ping, ServerMessage}; 27 | 28 | type UpdateFn = Arc; 29 | 30 | #[derive(Copy, Clone)] 31 | pub struct Config { 32 | pub port: u16, 33 | pub ping_interval: Duration, 34 | } 35 | 36 | #[derive(Debug, Copy, Clone)] 37 | pub enum EventKind { 38 | Sent { sent: Duration }, 39 | Timeout, 40 | AtServer { server_time: u64 }, 41 | Pong { recv: Duration }, 42 | } 43 | 44 | #[derive(Debug, Copy, Clone)] 45 | pub struct Event { 46 | pub ping_index: u64, 47 | pub kind: EventKind, 48 | } 49 | 50 | #[derive(Clone)] 51 | pub struct Point { 52 | pub pending: bool, 53 | pub index: u64, 54 | pub sent: Duration, 55 | pub total: Option, 56 | pub up: Option, 57 | at_server: Option, // In server time 58 | recv: Option, 59 | } 60 | 61 | #[derive(Debug, Clone)] 62 | pub enum State { 63 | Connecting, 64 | Syncing, 65 | Monitoring { at: String }, 66 | } 67 | 68 | pub struct Data { 69 | pub state: Mutex, 70 | pub start: Instant, 71 | pub limit: usize, 72 | pub points: tokio::sync::Mutex>, 73 | update_fn: UpdateFn, 74 | } 75 | 76 | impl Data { 77 | pub fn new(limit: usize, update_fn: UpdateFn) -> Self { 78 | Self { 79 | state: Mutex::new(State::Connecting), 80 | start: Instant::now(), 81 | limit, 82 | points: tokio::sync::Mutex::new(VecDeque::new()), 83 | update_fn, 84 | } 85 | } 86 | } 87 | 88 | async fn test_async( 89 | config: Config, 90 | server: Option<&str>, 91 | data: Arc, 92 | stop: oneshot::Receiver<()>, 93 | ) -> Result<(), anyhow::Error> { 94 | let (control, at) = if let Some(server) = server { 95 | ( 96 | connect((server, config.port), "server").await?, 97 | server.to_owned(), 98 | ) 99 | } else { 100 | let server = discovery::locate(false).await?; 101 | (connect(server.socket, "server").await?, server.at) 102 | }; 103 | 104 | control.set_nodelay(true)?; 105 | 106 | let server = control.peer_addr()?; 107 | 108 | *data.state.lock() = State::Syncing; 109 | (data.update_fn)(); 110 | 111 | let (rx, tx) = control.into_split(); 112 | let mut control_rx = FramedRead::new(rx, codec()); 113 | let mut control_tx = FramedWrite::new(tx, codec()); 114 | 115 | hello(&mut control_tx, &mut control_rx).await?; 116 | 117 | send(&mut control_tx, &ClientMessage::NewClient).await?; 118 | 119 | let setup_start = data.start; 120 | 121 | let reply: ServerMessage = receive(&mut control_rx).await?; 122 | let id = match reply { 123 | ServerMessage::NewClient(Some(id)) => id, 124 | ServerMessage::NewClient(None) => bail!("Server was unable to create client"), 125 | _ => bail!("Unexpected message {:?}", reply), 126 | }; 127 | 128 | let local_udp = if server.is_ipv6() { 129 | SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) 130 | } else { 131 | SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) 132 | }; 133 | 134 | let mut ping_index = 0; 135 | 136 | let LatencyResult { 137 | threshold: latency, 138 | server_offset: mut server_time_offset, 139 | mut control_rx, 140 | .. 141 | } = measure_latency( 142 | id, 143 | &mut ping_index, 144 | &mut control_tx, 145 | control_rx, 146 | server, 147 | local_udp, 148 | setup_start, 149 | ) 150 | .await?; 151 | 152 | let sample_interval = Duration::from_secs(2); 153 | let sample_count = 154 | (((sample_interval.as_secs_f64() * 0.6) / config.ping_interval.as_secs_f64()).round() 155 | as usize) 156 | .clamp(10, 1000); 157 | 158 | let latency_filter = 159 | Duration::from_secs_f64(latency.as_secs_f64() * 1.01) + Duration::from_micros(500); 160 | 161 | let mut samples: VecDeque = iter::repeat(server_time_offset) 162 | .take(sample_count) 163 | .collect(); 164 | 165 | let udp_socket = Arc::new(net::UdpSocket::bind(local_udp).await?); 166 | udp_socket.connect(server).await?; 167 | let udp_socket2 = udp_socket.clone(); 168 | 169 | let ping_interval = config.ping_interval; 170 | 171 | let (event_tx, mut event_rx) = channel(1000); 172 | 173 | send(&mut control_tx, &ClientMessage::GetMeasurements).await?; 174 | 175 | let event_tx_ = event_tx.clone(); 176 | let measures = tokio::spawn(async move { 177 | let overload_; 178 | 179 | loop { 180 | let reply: ServerMessage = receive(&mut control_rx).await?; 181 | match reply { 182 | ServerMessage::LatencyMeasures(measures) => { 183 | for measure in measures { 184 | event_tx_ 185 | .send(Event { 186 | ping_index: measure.index, 187 | kind: EventKind::AtServer { 188 | server_time: measure.time, 189 | }, 190 | }) 191 | .await?; 192 | } 193 | } 194 | ServerMessage::MeasurementsDone { overload } => { 195 | overload_ = overload; 196 | break; 197 | } 198 | _ => bail!("Unexpected message {:?}", reply), 199 | }; 200 | } 201 | 202 | Ok(overload_) 203 | }); 204 | 205 | let ping_recv = tokio::spawn(ping_recv( 206 | event_tx.clone(), 207 | setup_start, 208 | udp_socket2.clone(), 209 | )); 210 | 211 | time::sleep(Duration::from_millis(50)).await; 212 | 213 | *data.state.lock() = State::Monitoring { at }; 214 | (data.update_fn)(); 215 | 216 | let ping_send = tokio::spawn(ping_send( 217 | event_tx.clone(), 218 | ping_index, 219 | id, 220 | setup_start, 221 | udp_socket2.clone(), 222 | ping_interval, 223 | )); 224 | 225 | tokio::spawn(async move { 226 | let mut sync_time = |server_time_offset: &mut u64, point: &Point| { 227 | if let Some(at_server) = point.at_server { 228 | if let Some(recv) = point.recv { 229 | let sent = point.sent; 230 | let latency = recv.saturating_sub(sent); 231 | 232 | if latency > latency_filter { 233 | return; 234 | } 235 | 236 | let server_time = at_server; 237 | let server_pong = sent + latency / 2; 238 | 239 | let server_offset = (server_pong.as_micros() as u64).wrapping_sub(server_time); 240 | 241 | samples.push_front(server_offset); 242 | samples.pop_back(); 243 | 244 | let current = *server_time_offset; 245 | 246 | let sum: i64 = samples 247 | .iter() 248 | .map(|server_offset| server_offset.wrapping_sub(current) as i64) 249 | .sum(); 250 | 251 | let offset = sum / (samples.len() as i64); 252 | 253 | *server_time_offset = current.wrapping_add(offset as u64); 254 | } 255 | } 256 | }; 257 | 258 | while let Some(event) = event_rx.recv().await { 259 | { 260 | let mut points = data.points.lock().await; 261 | let i = points 262 | .iter() 263 | .enumerate() 264 | .find(|r| r.1.index == event.ping_index) 265 | .map(|r| r.0); 266 | match event.kind { 267 | EventKind::Sent { sent } => { 268 | while points.len() > data.limit { 269 | points.pop_back(); 270 | } 271 | points.push_front(Point { 272 | pending: true, 273 | index: event.ping_index, 274 | sent, 275 | up: None, 276 | total: None, 277 | at_server: None, 278 | recv: None, 279 | }); 280 | } 281 | EventKind::AtServer { server_time } => { 282 | i.map(|i| { 283 | let time = 284 | Duration::from_micros(server_time.wrapping_add(server_time_offset)); 285 | points[i].up = Some(time.saturating_sub(points[i].sent)); 286 | points[i].at_server = Some(server_time); 287 | sync_time(&mut server_time_offset, &points[i]); 288 | }); 289 | } 290 | EventKind::Pong { recv } => { 291 | i.map(|i| { 292 | points[i].pending = false; 293 | points[i].recv = Some(recv); 294 | points[i].total = Some(recv.saturating_sub(points[i].sent)); 295 | sync_time(&mut server_time_offset, &points[i]); 296 | }); 297 | } 298 | EventKind::Timeout => { 299 | i.map(|i| { 300 | points[i].pending = false; 301 | }); 302 | } 303 | } 304 | } 305 | (data.update_fn)(); 306 | } 307 | }); 308 | 309 | select! { 310 | result = ping_recv.fuse() => { 311 | result??; 312 | }, 313 | result = ping_send.fuse() => { 314 | result??; 315 | }, 316 | result = stop.fuse() => { 317 | result?; 318 | }, 319 | } 320 | 321 | send(&mut control_tx, &ClientMessage::StopMeasurements).await?; 322 | send(&mut control_tx, &ClientMessage::Done).await?; 323 | 324 | let _server_overload = measures.await??; 325 | 326 | Ok(()) 327 | } 328 | 329 | async fn ping_send( 330 | event_tx: Sender, 331 | mut ping_index: u64, 332 | id: u64, 333 | setup_start: Instant, 334 | socket: Arc, 335 | interval: Duration, 336 | ) -> Result<(), anyhow::Error> { 337 | let mut buf = [0; 64]; 338 | 339 | let mut interval = time::interval(interval); 340 | 341 | loop { 342 | interval.tick().await; 343 | 344 | let current = setup_start.elapsed(); 345 | 346 | let ping = Ping { 347 | id, 348 | index: ping_index, 349 | }; 350 | 351 | let mut cursor = Cursor::new(&mut buf[..]); 352 | bincode::serialize_into(&mut cursor, &ping).unwrap(); 353 | let buf = &cursor.get_ref()[0..(cursor.position() as usize)]; 354 | 355 | udp_handle(socket.send(buf).await.map(|_| ())).context("Unable to send UDP ping packet")?; 356 | 357 | event_tx 358 | .send(Event { 359 | ping_index, 360 | kind: EventKind::Sent { sent: current }, 361 | }) 362 | .await?; 363 | 364 | let event_tx = event_tx.clone(); 365 | tokio::spawn(async move { 366 | time::sleep(Duration::from_secs(1)).await; 367 | event_tx 368 | .send(Event { 369 | ping_index, 370 | kind: EventKind::Timeout, 371 | }) 372 | .await 373 | .ok(); 374 | }); 375 | 376 | ping_index += 1; 377 | } 378 | } 379 | 380 | async fn ping_recv( 381 | event_tx: Sender, 382 | setup_start: Instant, 383 | socket: Arc, 384 | ) -> Result, anyhow::Error> { 385 | let mut buf = [0; 64]; 386 | 387 | loop { 388 | let result = socket.recv(&mut buf).await; 389 | 390 | let current = setup_start.elapsed(); 391 | let len = result?; 392 | let buf = buf 393 | .get_mut(..len) 394 | .ok_or_else(|| anyhow!("Pong too large"))?; 395 | let ping: Ping = bincode::deserialize(buf)?; 396 | 397 | event_tx 398 | .send(Event { 399 | ping_index: ping.index, 400 | kind: EventKind::Pong { recv: current }, 401 | }) 402 | .await?; 403 | } 404 | } 405 | 406 | pub fn test_callback( 407 | config: Config, 408 | host: Option<&str>, 409 | data: Arc, 410 | done: Box>) + Send>, 411 | ) -> oneshot::Sender<()> { 412 | let (stop_tx, stop_rx) = oneshot::channel(); 413 | let (force_stop_tx, force_stop_rx) = oneshot::channel(); 414 | let host = host.map(|host| host.to_string()); 415 | thread::spawn(move || { 416 | let rt = tokio::runtime::Runtime::new().unwrap(); 417 | 418 | done(rt.block_on(async move { 419 | let (tx, rx) = oneshot::channel(); 420 | task::spawn(async move { 421 | stop_rx.await.ok(); 422 | tx.send(()).ok(); 423 | time::sleep(Duration::from_secs(5)).await; 424 | force_stop_tx.send(()).ok(); 425 | }); 426 | 427 | let mut result = task::spawn(async move { 428 | test_async(config, host.as_deref(), data, rx) 429 | .await 430 | .map_err(|error| format!("{:?}", error)) 431 | }) 432 | .fuse(); 433 | 434 | select! { 435 | result = result => { 436 | Some(result.map_err(|error| error.to_string()).and_then(|result| result)) 437 | }, 438 | result = force_stop_rx.fuse() => { 439 | result.ok(); 440 | None 441 | }, 442 | } 443 | })); 444 | }); 445 | stop_tx 446 | } 447 | -------------------------------------------------------------------------------- /src/crusader-lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow( 2 | clippy::new_without_default, 3 | clippy::too_many_arguments, 4 | clippy::useless_format, 5 | clippy::type_complexity, 6 | clippy::collapsible_else_if, 7 | clippy::option_map_unit_fn 8 | )] 9 | 10 | const VERSION: &str = "0.3.3-dev"; 11 | 12 | pub fn version() -> String { 13 | if !VERSION.ends_with("-dev") { 14 | VERSION.to_owned() 15 | } else { 16 | let commit = option_env!("GIT_COMMIT") 17 | .map(|commit| format!("commit {}", commit)) 18 | .unwrap_or("unknown commit".to_owned()); 19 | format!("{} ({})", VERSION, commit) 20 | } 21 | } 22 | 23 | pub fn with_time(msg: &str) -> String { 24 | let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); 25 | format!("[{}] {}", time, msg) 26 | } 27 | 28 | mod common; 29 | mod discovery; 30 | #[cfg(feature = "client")] 31 | pub use common::Config; 32 | #[cfg(feature = "client")] 33 | pub mod file_format; 34 | #[cfg(feature = "client")] 35 | pub mod latency; 36 | mod peer; 37 | #[cfg(feature = "client")] 38 | pub mod plot; 39 | pub mod protocol; 40 | #[cfg(feature = "client")] 41 | pub mod remote; 42 | pub mod serve; 43 | #[cfg(feature = "client")] 44 | pub mod test; 45 | -------------------------------------------------------------------------------- /src/crusader-lib/src/peer.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{connect, LatencyResult}; 2 | #[cfg(feature = "client")] 3 | use crate::common::{Config, Msg}; 4 | #[cfg(feature = "client")] 5 | use crate::discovery; 6 | use crate::protocol::PeerLatency; 7 | use crate::serve::State; 8 | use crate::{ 9 | common::{hello, measure_latency, ping_recv, ping_send, TestState}, 10 | protocol::{codec, receive, send, ClientMessage, RawLatency, ServerMessage}, 11 | }; 12 | use anyhow::{bail, Context}; 13 | use std::{ 14 | net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, 15 | sync::Arc, 16 | time::Duration, 17 | }; 18 | use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; 19 | use tokio::sync::watch; 20 | use tokio::time::Instant; 21 | use tokio::{ 22 | net::{self}, 23 | time, 24 | }; 25 | use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; 26 | 27 | #[cfg(feature = "client")] 28 | pub struct Peer { 29 | msg: Msg, 30 | tx: FramedWrite, 31 | rx: FramedRead, 32 | } 33 | 34 | #[cfg(feature = "client")] 35 | impl Peer { 36 | pub async fn start(&mut self) -> Result<(), anyhow::Error> { 37 | let reply: ServerMessage = receive(&mut self.rx) 38 | .await 39 | .context("Peer failed to get ready")?; 40 | match reply { 41 | ServerMessage::PeerReady { server_latency } => { 42 | (self.msg)(&format!( 43 | "Peer idle latency to server {:.2} ms", 44 | Duration::from_nanos(server_latency).as_secs_f64() * 1000.0 45 | )); 46 | } 47 | _ => bail!("Unexpected message {:?}", reply), 48 | }; 49 | send(&mut self.tx, &ClientMessage::PeerStart).await?; 50 | let reply: ServerMessage = receive(&mut self.rx).await?; 51 | match reply { 52 | ServerMessage::PeerStarted => (), 53 | _ => bail!("Unexpected message {:?}", reply), 54 | }; 55 | Ok(()) 56 | } 57 | 58 | pub async fn stop(&mut self) -> Result<(), anyhow::Error> { 59 | send(&mut self.tx, &ClientMessage::PeerStop).await?; 60 | Ok(()) 61 | } 62 | 63 | pub async fn complete(mut self) -> Result<(bool, Vec), anyhow::Error> { 64 | let reply: ServerMessage = receive(&mut self.rx).await?; 65 | match reply { 66 | ServerMessage::PeerDone { 67 | overload, 68 | latencies, 69 | } => Ok((overload, latencies)), 70 | _ => bail!("Unexpected message {:?}", reply), 71 | } 72 | } 73 | } 74 | 75 | #[cfg(feature = "client")] 76 | pub async fn connect_to_peer( 77 | config: Config, 78 | server: SocketAddr, 79 | latency_peer_server: Option<&str>, 80 | estimated_duration: Duration, 81 | msg: Msg, 82 | ) -> Result { 83 | let control = if let Some(server) = latency_peer_server { 84 | connect((server, config.port), "latency peer").await? 85 | } else { 86 | let server = discovery::locate(true).await?; 87 | msg(&format!( 88 | "Found peer at {} running version {}", 89 | server.at, server.software_version 90 | )); 91 | connect(server.socket, "latency peer").await? 92 | }; 93 | control.set_nodelay(true)?; 94 | 95 | let peer_server = control.peer_addr()?; 96 | 97 | msg(&format!("Connected to peer {}", peer_server)); 98 | 99 | let (rx, tx) = control.into_split(); 100 | let mut control_rx = FramedRead::new(rx, codec()); 101 | let mut control_tx = FramedWrite::new(tx, codec()); 102 | 103 | hello(&mut control_tx, &mut control_rx).await?; 104 | 105 | send( 106 | &mut control_tx, 107 | &ClientMessage::NewPeer { 108 | server: match server.ip() { 109 | IpAddr::V4(ip) => ip.to_ipv6_mapped(), 110 | IpAddr::V6(ip) => ip, 111 | } 112 | .octets(), 113 | port: server.port(), 114 | ping_interval: config.ping_interval.as_millis() as u64, 115 | estimated_duration: estimated_duration.as_millis(), 116 | }, 117 | ) 118 | .await?; 119 | 120 | let reply: ServerMessage = receive(&mut control_rx) 121 | .await 122 | .context("Failed to create peer")?; 123 | match reply { 124 | ServerMessage::NewPeer => (), 125 | _ => bail!("Unexpected message {:?}", reply), 126 | }; 127 | 128 | Ok(Peer { 129 | msg, 130 | rx: control_rx, 131 | tx: control_tx, 132 | }) 133 | } 134 | 135 | pub async fn run_peer( 136 | state: Arc, 137 | server: SocketAddr, 138 | ping_interval: Duration, 139 | estimated_duration: Duration, 140 | stream_rx: &mut FramedRead, 141 | stream_tx: &mut FramedWrite, 142 | ) -> Result<(), anyhow::Error> { 143 | let control = connect(server, "server").await?; 144 | control.set_nodelay(true)?; 145 | 146 | let server = control.peer_addr()?; 147 | 148 | (state.msg)(&format!("Peer connected to server {}", server)); 149 | 150 | let (rx, tx) = control.into_split(); 151 | let mut control_rx = FramedRead::new(rx, codec()); 152 | let mut control_tx = FramedWrite::new(tx, codec()); 153 | 154 | hello(&mut control_tx, &mut control_rx).await?; 155 | 156 | send(&mut control_tx, &ClientMessage::NewClient).await?; 157 | 158 | let setup_start = Instant::now(); 159 | 160 | let reply: ServerMessage = receive(&mut control_rx).await?; 161 | let id = match reply { 162 | ServerMessage::NewClient(Some(id)) => id, 163 | ServerMessage::NewClient(None) => bail!("Server was unable to create client"), 164 | _ => bail!("Unexpected message {:?}", reply), 165 | }; 166 | 167 | send(stream_tx, &ServerMessage::NewPeer).await?; 168 | 169 | let local_udp = if server.is_ipv6() { 170 | SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) 171 | } else { 172 | SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) 173 | }; 174 | 175 | let mut ping_index = 0; 176 | 177 | let LatencyResult { 178 | latency, 179 | server_pong: pre_server_pong, 180 | server_time: pre_server_time, 181 | mut control_rx, 182 | .. 183 | } = measure_latency( 184 | id, 185 | &mut ping_index, 186 | &mut control_tx, 187 | control_rx, 188 | server, 189 | local_udp, 190 | setup_start, 191 | ) 192 | .await?; 193 | 194 | (state.msg)(&format!( 195 | "Peer idle latency to server {:.2} ms", 196 | latency.as_secs_f64() * 1000.0 197 | )); 198 | 199 | let udp_socket = Arc::new(net::UdpSocket::bind(local_udp).await?); 200 | udp_socket.connect(server).await?; 201 | let udp_socket2 = udp_socket.clone(); 202 | 203 | let (state_tx, state_rx) = watch::channel((TestState::Setup, setup_start)); 204 | 205 | send(&mut control_tx, &ClientMessage::GetMeasurements).await?; 206 | 207 | let measures = tokio::spawn(async move { 208 | let mut latencies = Vec::new(); 209 | let overload_; 210 | 211 | loop { 212 | let reply: ServerMessage = receive(&mut control_rx).await?; 213 | match reply { 214 | ServerMessage::LatencyMeasures(measures) => { 215 | latencies.extend(measures.into_iter()); 216 | } 217 | ServerMessage::MeasurementsDone { overload } => { 218 | overload_ = overload; 219 | break; 220 | } 221 | _ => bail!("Unexpected message {:?}", reply), 222 | }; 223 | } 224 | 225 | Ok((latencies, overload_, control_rx)) 226 | }); 227 | 228 | send( 229 | stream_tx, 230 | &ServerMessage::PeerReady { 231 | server_latency: latency.as_nanos() as u64, 232 | }, 233 | ) 234 | .await?; 235 | 236 | let reply: ClientMessage = receive(stream_rx).await?; 237 | match reply { 238 | ClientMessage::PeerStart => (), 239 | _ => bail!("Unexpected message {:?}", reply), 240 | }; 241 | 242 | let ping_start_index = ping_index; 243 | let ping_send = tokio::spawn(ping_send( 244 | ping_index, 245 | id, 246 | state_rx.clone(), 247 | setup_start, 248 | udp_socket2.clone(), 249 | ping_interval, 250 | estimated_duration, 251 | )); 252 | 253 | let ping_recv = tokio::spawn(ping_recv( 254 | state_rx.clone(), 255 | setup_start, 256 | udp_socket2.clone(), 257 | ping_interval, 258 | estimated_duration, 259 | )); 260 | 261 | send(stream_tx, &ServerMessage::PeerStarted).await?; 262 | 263 | // Wait for client to complete test 264 | let reply: ClientMessage = receive(stream_rx).await?; 265 | match reply { 266 | ClientMessage::PeerStop => (), 267 | _ => bail!("Unexpected message {:?}", reply), 268 | }; 269 | 270 | state_tx.send((TestState::End, Instant::now())).ok(); 271 | 272 | // Wait for pings to return 273 | time::sleep(Duration::from_millis(500)).await; 274 | 275 | state_tx.send((TestState::EndPingRecv, Instant::now())).ok(); 276 | 277 | let (pings_sent, mut ping_index) = ping_send.await??; 278 | let mut pongs = ping_recv.await??; 279 | 280 | send(&mut control_tx, &ClientMessage::StopMeasurements).await?; 281 | 282 | let (mut latencies, server_overload, control_rx) = measures.await??; 283 | 284 | let LatencyResult { 285 | server_pong: post_server_pong, 286 | server_time: post_server_time, 287 | .. 288 | } = measure_latency( 289 | id, 290 | &mut ping_index, 291 | &mut control_tx, 292 | control_rx, 293 | server, 294 | local_udp, 295 | setup_start, 296 | ) 297 | .await?; 298 | 299 | send(&mut control_tx, &ClientMessage::Done).await?; 300 | 301 | let server_time = post_server_time.wrapping_sub(pre_server_time); 302 | let peer_time = post_server_pong.saturating_sub(pre_server_pong); 303 | let peer_time_micros = peer_time.as_micros() as f64; 304 | let ratio = peer_time_micros / server_time as f64; 305 | let inv_ratio = server_time as f64 / peer_time_micros; 306 | 307 | let to_peer_time = |server_time: u64| -> u64 { 308 | let time = server_time.wrapping_sub(pre_server_time); 309 | let time = (time as f64 * ratio) as u64; 310 | (pre_server_pong.as_micros() as u64).saturating_add(time) 311 | }; 312 | 313 | let to_server_time = |peer_time: Duration| -> u64 { 314 | let time = peer_time.saturating_sub(pre_server_pong).as_micros() as u64; 315 | let time = (time as f64 * inv_ratio) as u64; 316 | pre_server_time.wrapping_add(time) 317 | }; 318 | 319 | latencies.sort_by_key(|d| d.index); 320 | pongs.sort_by_key(|d| d.0.index); 321 | let pings: Vec<_> = pings_sent 322 | .into_iter() 323 | .enumerate() 324 | .map(|(index, sent)| { 325 | let index = index as u64 + ping_start_index; 326 | let mut latency = latencies 327 | .binary_search_by_key(&index, |e| e.index) 328 | .ok() 329 | .map(|ping| RawLatency { 330 | total: None, 331 | up: Duration::from_micros(to_peer_time(latencies[ping].time)) 332 | .saturating_sub(sent), 333 | }); 334 | 335 | latency.as_mut().map(|latency| { 336 | pongs 337 | .binary_search_by_key(&index, |e| e.0.index) 338 | .ok() 339 | .map(|ping| { 340 | latency.total = Some(pongs[ping].1.saturating_sub(sent)); 341 | }); 342 | }); 343 | 344 | PeerLatency { 345 | sent: to_server_time(sent), 346 | latency, 347 | } 348 | }) 349 | .collect(); 350 | 351 | send( 352 | stream_tx, 353 | &ServerMessage::PeerDone { 354 | overload: server_overload, 355 | latencies: pings, 356 | }, 357 | ) 358 | .await?; 359 | 360 | Ok(()) 361 | } 362 | -------------------------------------------------------------------------------- /src/crusader-lib/src/protocol.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use bytes::{Bytes, BytesMut}; 3 | use futures::{Sink, SinkExt, Stream, StreamExt}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{error::Error, time::Duration}; 6 | use tokio_util::codec::{length_delimited, LengthDelimitedCodec}; 7 | 8 | #[derive(Serialize, Deserialize, Copy, Clone, Debug)] 9 | pub struct RawLatency { 10 | // File format: Changed from Duration to Option in v2. 11 | pub total: Option, 12 | pub up: Duration, 13 | } 14 | 15 | impl RawLatency { 16 | pub fn down(&self) -> Option { 17 | self.total.map(|total| total.saturating_sub(self.up)) 18 | } 19 | } 20 | 21 | pub const PORT: u16 = 35481; 22 | 23 | pub const MAGIC: u64 = 0x5372ab82ae7c59cb; 24 | pub const VERSION: u64 = 3; 25 | 26 | #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] 27 | pub struct Hello { 28 | magic: u64, 29 | pub version: u64, 30 | } 31 | 32 | impl Hello { 33 | pub fn new() -> Self { 34 | Hello { 35 | magic: MAGIC, 36 | version: VERSION, 37 | } 38 | } 39 | } 40 | 41 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] 42 | pub struct TestStream { 43 | pub group: u32, 44 | pub id: u32, 45 | } 46 | 47 | #[derive(Serialize, Deserialize, Debug)] 48 | pub struct LatencyMeasure { 49 | pub time: u64, // In microseconds and in server time 50 | pub index: u64, 51 | } 52 | 53 | #[derive(Serialize, Deserialize, Debug)] 54 | pub struct PeerLatency { 55 | pub sent: u64, // In microseconds and in server time 56 | pub latency: Option, 57 | } 58 | 59 | #[derive(Serialize, Deserialize, Debug)] 60 | pub enum ServerMessage { 61 | NewClient(Option), 62 | LatencyMeasures(Vec), 63 | Measure { 64 | stream: TestStream, 65 | time: u64, 66 | bytes: u64, 67 | }, 68 | MeasureStreamDone { 69 | stream: TestStream, 70 | timeout: bool, 71 | }, 72 | MeasurementsDone { 73 | overload: bool, 74 | }, 75 | LoadComplete { 76 | stream: TestStream, 77 | }, 78 | ScheduledLoads { 79 | groups: Vec, 80 | time: u64, 81 | }, 82 | WaitingForLoad, 83 | WaitingForByte, 84 | NewPeer, 85 | PeerReady { 86 | server_latency: u64, 87 | }, 88 | PeerStarted, 89 | PeerDone { 90 | overload: bool, 91 | latencies: Vec, 92 | }, 93 | } 94 | 95 | #[derive(Serialize, Deserialize, Debug)] 96 | pub enum ClientMessage { 97 | NewClient, 98 | Associate(u64), 99 | Done, 100 | ScheduleLoads { 101 | groups: Vec, 102 | delay: u64, 103 | }, 104 | LoadFromClient { 105 | stream: TestStream, 106 | duration: u64, 107 | delay: u64, 108 | throughput_interval: u64, 109 | }, 110 | LoadFromServer { 111 | stream: TestStream, 112 | duration: u64, 113 | delay: u64, 114 | }, 115 | LoadComplete { 116 | stream: TestStream, 117 | }, 118 | SendByte, 119 | GetMeasurements, 120 | StopMeasurements, 121 | NewPeer { 122 | server: [u8; 16], 123 | port: u16, 124 | ping_interval: u64, 125 | estimated_duration: u128, 126 | }, 127 | PeerStart, 128 | PeerStop, 129 | } 130 | 131 | #[derive(Serialize, Deserialize, Debug)] 132 | pub struct Ping { 133 | pub id: u64, 134 | pub index: u64, 135 | } 136 | 137 | pub fn codec() -> LengthDelimitedCodec { 138 | length_delimited::Builder::new() 139 | .little_endian() 140 | .length_field_type::() 141 | .new_codec() 142 | } 143 | 144 | pub async fn send + Unpin>( 145 | sink: &mut S, 146 | value: &impl Serialize, 147 | ) -> Result<(), anyhow::Error> 148 | where 149 | S::Error: Error + Send + Sync + 'static, 150 | { 151 | Ok(sink.send(bincode::serialize(value)?.into()).await?) 152 | } 153 | 154 | pub async fn receive> + Unpin, T: for<'a> Deserialize<'a>, E>( 155 | stream: &mut S, 156 | ) -> Result 157 | where 158 | E: Error + Send + Sync + 'static, 159 | { 160 | let bytes = stream 161 | .next() 162 | .await 163 | .context("Expected protocol message, but stream closed")? 164 | .context("Failed to receive protocol message")?; 165 | Ok(bincode::deserialize(&bytes)?) 166 | } 167 | -------------------------------------------------------------------------------- /src/crusader-lib/src/remote.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Crusader Remote Client 7 | 8 | 80 | 82 | 83 | 84 | 142 | 143 | 239 | 240 | -------------------------------------------------------------------------------- /src/crusader-lib/src/remote.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{interface_ips, Config}; 2 | use crate::plot::save_graph_to_mem; 3 | use crate::test::{test_async, timed, PlotConfig}; 4 | use crate::{version, with_time}; 5 | use anyhow::anyhow; 6 | use anyhow::bail; 7 | use anyhow::Error; 8 | use axum::body::Body; 9 | use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; 10 | use axum::http::{header, HeaderValue, Response}; 11 | use axum::{ 12 | extract::{ConnectInfo, State}, 13 | response::{Html, IntoResponse}, 14 | routing::get, 15 | Router, 16 | }; 17 | use image::ImageFormat; 18 | use serde::Deserialize; 19 | use serde_json::json; 20 | use socket2::{Domain, Protocol, Socket}; 21 | use std::io::{Cursor, ErrorKind}; 22 | use std::net::{IpAddr, Ipv6Addr}; 23 | use std::thread; 24 | use std::time::Duration; 25 | use std::{ 26 | net::{Ipv4Addr, SocketAddr}, 27 | sync::Arc, 28 | }; 29 | use tokio::net::TcpSocket; 30 | use tokio::sync::mpsc::unbounded_channel; 31 | use tokio::sync::oneshot; 32 | use tokio::{net::TcpListener, signal, task}; 33 | 34 | struct Env { 35 | live_reload: bool, 36 | msg: Box, 37 | } 38 | 39 | async fn ws_client( 40 | State(state): State>, 41 | ws: WebSocketUpgrade, 42 | ConnectInfo(addr): ConnectInfo, 43 | ) -> impl IntoResponse { 44 | ws.on_upgrade(move |socket| async move { 45 | handle_client(state, socket, addr).await.ok(); 46 | }) 47 | } 48 | 49 | #[derive(Deserialize, Debug)] 50 | struct TestArgs { 51 | server: Option, 52 | download: bool, 53 | upload: bool, 54 | bidirectional: bool, 55 | port: u16, 56 | 57 | streams: u64, 58 | 59 | stream_stagger: f64, 60 | 61 | load_duration: f64, 62 | 63 | grace_duration: f64, 64 | latency_sample_interval: u64, 65 | throughput_sample_interval: u64, 66 | latency_peer: bool, 67 | latency_peer_server: Option, 68 | } 69 | 70 | async fn handle_client( 71 | state: Arc, 72 | mut socket: WebSocket, 73 | who: SocketAddr, 74 | ) -> Result<(), Error> { 75 | let args: TestArgs = match socket.recv().await.ok_or(anyhow!("No request"))?? { 76 | Message::Text(request) => serde_json::from_str(&request)?, 77 | _ => bail!("unexpected message"), 78 | }; 79 | let config = Config { 80 | port: args.port, 81 | streams: args.streams, 82 | stream_stagger: Duration::from_secs_f64(args.stream_stagger), 83 | grace_duration: Duration::from_secs_f64(args.grace_duration), 84 | load_duration: Duration::from_secs_f64(args.load_duration), 85 | download: args.download, 86 | upload: args.upload, 87 | bidirectional: args.bidirectional, 88 | ping_interval: Duration::from_millis(args.latency_sample_interval), 89 | throughput_interval: Duration::from_millis(args.throughput_sample_interval), 90 | }; 91 | 92 | (state.msg)(&format!("Remote client ({}) test started", who.ip())); 93 | 94 | let (msg_tx, mut msg_rx) = unbounded_channel(); 95 | 96 | let tester = tokio::spawn(async move { 97 | let msg = Arc::new(move |msg: &str| { 98 | let msg = with_time(msg); 99 | msg_tx.send(msg.clone()).ok(); 100 | task::spawn_blocking(move || println!("{}", msg)); 101 | }); 102 | let result = test_async( 103 | config, 104 | args.server.as_deref(), 105 | args.latency_peer 106 | .then_some(args.latency_peer_server.as_deref()), 107 | msg.clone(), 108 | ) 109 | .await 110 | .map_err(|err| { 111 | msg(&format!("Client failed: {}", err)); 112 | anyhow!("Client failed") 113 | }); 114 | (result, timed("")) 115 | }); 116 | 117 | while let Some(msg) = msg_rx.recv().await { 118 | socket 119 | .send(Message::Text( 120 | json!({ 121 | "type": "log", 122 | "message": msg, 123 | }) 124 | .to_string(), 125 | )) 126 | .await?; 127 | } 128 | 129 | let (result, time) = tester.await?; 130 | let result = result?; 131 | 132 | socket 133 | .send(Message::Text( 134 | json!({ 135 | "type": "result", 136 | "time": time, 137 | }) 138 | .to_string(), 139 | )) 140 | .await?; 141 | 142 | let (result, plot) = task::spawn_blocking(move || -> Result<_, anyhow::Error> { 143 | let mut data = Cursor::new(Vec::new()); 144 | 145 | let plot = save_graph_to_mem(&PlotConfig::default(), &result.to_test_result())?; 146 | plot.write_to(&mut data, ImageFormat::Png)?; 147 | Ok((result, data.into_inner())) 148 | }) 149 | .await??; 150 | 151 | socket.send(Message::Binary(plot)).await?; 152 | 153 | let data = task::spawn_blocking(move || { 154 | let mut data = Vec::new(); 155 | 156 | result.save_to_writer(&mut data)?; 157 | Ok::<_, anyhow::Error>(data) 158 | }) 159 | .await??; 160 | socket.send(Message::Binary(data)).await?; 161 | 162 | (state.msg)(&format!("Remote client ({}) test complete", who.ip())); 163 | Ok(()) 164 | } 165 | 166 | async fn listen(state: Arc, listener: TcpListener) { 167 | async fn root(State(state): State>) -> Html { 168 | if state.live_reload { 169 | if let Ok(data) = std::fs::read_to_string("crusader-lib/src/remote.html") { 170 | return Html(data); 171 | } 172 | } 173 | 174 | Html(include_str!("remote.html").to_string()) 175 | } 176 | 177 | async fn vue() -> Response { 178 | #[cfg(debug_assertions)] 179 | let body: Body = include_str!("../assets/vue.js").into(); 180 | #[cfg(not(debug_assertions))] 181 | let body: Body = include_str!("../assets/vue.prod.js").into(); 182 | ( 183 | [( 184 | header::CONTENT_TYPE, 185 | HeaderValue::from_static("text/javascript"), 186 | )], 187 | body, 188 | ) 189 | .into_response() 190 | } 191 | 192 | let app = Router::new() 193 | .route("/", get(root)) 194 | .route("/assets/vue.js", get(vue)) 195 | .route("/api/client", get(ws_client)) 196 | .with_state(state); 197 | 198 | axum::serve( 199 | listener, 200 | app.into_make_service_with_connect_info::(), 201 | ) 202 | .await 203 | .unwrap(); 204 | } 205 | 206 | async fn serve_async(port: u16, msg: Box) -> Result<(), Error> { 207 | let live_reload = cfg!(debug_assertions) 208 | && std::fs::read_to_string("crusader-lib/src/remote.html") 209 | .map(|file| *file == *include_str!("remote.html")) 210 | .unwrap_or_default(); 211 | 212 | if live_reload { 213 | (msg)(&format!( 214 | "Live reload of crusader-lib/src/remote.html enabled", 215 | )); 216 | } 217 | 218 | let state = Arc::new(Env { live_reload, msg }); 219 | 220 | let v6 = Socket::new(Domain::IPV6, socket2::Type::STREAM, Some(Protocol::TCP))?; 221 | v6.set_only_v6(true)?; 222 | let v6: std::net::TcpStream = v6.into(); 223 | v6.set_nonblocking(true)?; 224 | let v6 = TcpSocket::from_std_stream(v6); 225 | v6.bind(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port)) 226 | .map_err(|error| { 227 | if let ErrorKind::AddrInUse = error.kind() { 228 | anyhow!( 229 | "Failed to bind TCP port, maybe another Crusader instance is already running" 230 | ) 231 | } else { 232 | error.into() 233 | } 234 | })?; 235 | let v6 = v6.listen(1024)?; 236 | 237 | let v4 = TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).await?; 238 | 239 | task::spawn(listen(state.clone(), v6)); 240 | task::spawn(listen(state.clone(), v4)); 241 | 242 | (state.msg)(&format!( 243 | "Remote{} version {} running...", 244 | if cfg!(debug_assertions) { 245 | " (debugging enabled)" 246 | } else { 247 | "" 248 | }, 249 | version() 250 | )); 251 | 252 | for (name, ip) in interface_ips() { 253 | let addr = match ip { 254 | IpAddr::V6(ip) => format!("[{ip}]"), 255 | IpAddr::V4(ip) => ip.to_string(), 256 | }; 257 | (state.msg)(&format!("Address on `{name}`: http://{addr}:{port}")); 258 | } 259 | 260 | Ok(()) 261 | } 262 | 263 | pub fn serve_until( 264 | port: u16, 265 | msg: Box, 266 | started: Box) + Send>, 267 | done: Box, 268 | ) -> Result, anyhow::Error> { 269 | let (tx, rx) = oneshot::channel(); 270 | 271 | let rt = tokio::runtime::Runtime::new()?; 272 | 273 | thread::spawn(move || { 274 | rt.block_on(async move { 275 | match serve_async(port, msg).await { 276 | Ok(()) => { 277 | started(Ok(())); 278 | rx.await.ok(); 279 | } 280 | Err(error) => started(Err(error.to_string())), 281 | } 282 | }); 283 | 284 | done(); 285 | }); 286 | 287 | Ok(tx) 288 | } 289 | 290 | pub fn run(port: u16) -> Result<(), anyhow::Error> { 291 | let rt = tokio::runtime::Runtime::new()?; 292 | rt.block_on(async move { 293 | serve_async( 294 | port, 295 | Box::new(|msg: &str| { 296 | let msg = msg.to_owned(); 297 | task::spawn_blocking(move || println!("{}", with_time(&msg))); 298 | }), 299 | ) 300 | .await?; 301 | signal::ctrl_c().await?; 302 | println!("{}", with_time("Remote server aborting...")); 303 | Ok(()) 304 | }) 305 | } 306 | -------------------------------------------------------------------------------- /src/crusader/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crusader" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | crusader-lib = { path = "../crusader-lib" } 8 | clap = { version = "4.5.13", features = ["derive", "string"] } 9 | clap-num = "1.1.1" 10 | env_logger = "0.10.0" 11 | anyhow = "1.0.86" 12 | serde_json = { version = "1.0.122", optional = true } 13 | 14 | [features] 15 | default = ["client"] 16 | client = ["crusader-lib/client", "dep:serde_json"] 17 | -------------------------------------------------------------------------------- /src/crusader/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use clap::{Parser, Subcommand}; 3 | use clap_num::si_number; 4 | #[cfg(feature = "client")] 5 | use crusader_lib::file_format::RawResult; 6 | #[cfg(feature = "client")] 7 | use crusader_lib::test::PlotConfig; 8 | use crusader_lib::{protocol, version}; 9 | #[cfg(feature = "client")] 10 | use crusader_lib::{with_time, Config}; 11 | #[cfg(feature = "client")] 12 | use std::path::PathBuf; 13 | use std::process; 14 | #[cfg(feature = "client")] 15 | use { 16 | anyhow::anyhow, 17 | std::fs::OpenOptions, 18 | std::io::{BufWriter, Write}, 19 | std::path::Path, 20 | std::time::Duration, 21 | }; 22 | 23 | #[derive(Parser)] 24 | #[command(version = version())] 25 | struct Cli { 26 | #[command(subcommand)] 27 | command: Commands, 28 | } 29 | 30 | #[derive(clap::Args)] 31 | struct PlotArgs { 32 | #[arg(long, help = "Plot transferred bytes")] 33 | plot_transferred: bool, 34 | #[arg(long, help = "Plot upload and download separately and plot streams")] 35 | plot_split_throughput: bool, 36 | #[arg(long, value_parser=si_number::, value_name = "BPS", 37 | long_help = "Sets the axis for throughput to at least this value. \ 38 | SI units are supported so `100M` would specify 100 Mbps")] 39 | plot_max_throughput: Option, 40 | #[arg( 41 | long, 42 | value_name = "MILLISECONDS", 43 | help = "Sets the axis for latency to at least this value" 44 | )] 45 | plot_max_latency: Option, 46 | #[arg(long, value_name = "PIXELS")] 47 | plot_width: Option, 48 | #[arg(long, value_name = "PIXELS")] 49 | plot_height: Option, 50 | #[arg(long)] 51 | plot_title: Option, 52 | } 53 | 54 | impl PlotArgs { 55 | #[cfg(feature = "client")] 56 | fn config(&self) -> PlotConfig { 57 | PlotConfig { 58 | transferred: self.plot_transferred, 59 | split_throughput: self.plot_split_throughput, 60 | max_throughput: self.plot_max_throughput, 61 | max_latency: self.plot_max_latency, 62 | width: self.plot_width, 63 | height: self.plot_height, 64 | title: self.plot_title.clone(), 65 | } 66 | } 67 | } 68 | 69 | #[derive(Subcommand)] 70 | enum Commands { 71 | #[command(about = "Runs the server")] 72 | Serve { 73 | #[arg(long, default_value_t = protocol::PORT, help = "Specifies the TCP and UDP port used by the server")] 74 | port: u16, 75 | #[arg(long, help = "Allow use and discovery as a peer")] 76 | peer: bool, 77 | }, 78 | #[command( 79 | long_about = "Runs a test client against a specified server and saves the result to the current directory. \ 80 | By default this does a download test, an upload test, and a test doing both download and upload while measuring the latency to the server" 81 | )] 82 | #[cfg(feature = "client")] 83 | Test { 84 | server: Option, 85 | #[arg(long, help = "Run a download test")] 86 | download: bool, 87 | #[arg(long, help = "Run an upload test")] 88 | upload: bool, 89 | #[arg(long, help = "Run a test doing both download and upload")] 90 | bidirectional: bool, 91 | #[arg( 92 | long, 93 | long_help = "Run a test only measuring latency. The duration is specified by `grace_duration`" 94 | )] 95 | idle: bool, 96 | #[arg(long, default_value_t = protocol::PORT, help = "Specifies the TCP and UDP port used by the server")] 97 | port: u16, 98 | #[arg( 99 | long, 100 | default_value_t = 8, 101 | help = "The number of TCP connections used to generate traffic in a single direction" 102 | )] 103 | streams: u64, 104 | #[arg( 105 | long, 106 | default_value_t = 0.0, 107 | value_name = "SECONDS", 108 | help = "The delay between the start of each stream" 109 | )] 110 | stream_stagger: f64, 111 | #[arg( 112 | long, 113 | default_value_t = 10.0, 114 | value_name = "SECONDS", 115 | help = "The duration in which traffic is generated" 116 | )] 117 | load_duration: f64, 118 | #[arg( 119 | long, 120 | default_value_t = 2.0, 121 | value_name = "SECONDS", 122 | help = "The idle time between each test" 123 | )] 124 | grace_duration: f64, 125 | #[arg(long, default_value_t = 5, value_name = "MILLISECONDS")] 126 | latency_sample_interval: u64, 127 | #[arg(long, default_value_t = 60, value_name = "MILLISECONDS")] 128 | throughput_sample_interval: u64, 129 | #[command(flatten)] 130 | plot: PlotArgs, 131 | #[arg( 132 | long, 133 | long_help = "Specifies another server (peer) which will also measure the latency to the server independently of the client" 134 | )] 135 | latency_peer_address: Option, 136 | #[arg( 137 | long, 138 | help = "Use another server (peer) which will also measure the latency to the server independently of the client" 139 | )] 140 | latency_peer: bool, 141 | #[arg( 142 | long, 143 | help = "The filename prefix used for the test result raw data and plot filenames" 144 | )] 145 | out_name: Option, 146 | }, 147 | #[cfg(feature = "client")] 148 | #[command(about = "Plots a previous result")] 149 | Plot { 150 | data: PathBuf, 151 | #[command(flatten)] 152 | plot: PlotArgs, 153 | }, 154 | #[cfg(feature = "client")] 155 | #[command(about = "Allows the client to be controlled over a web server")] 156 | Remote { 157 | #[arg( 158 | long, 159 | default_value_t = protocol::PORT + 1, 160 | help = "Specifies the HTTP port used by the server" 161 | )] 162 | port: u16, 163 | }, 164 | #[cfg(feature = "client")] 165 | #[command(about = "Converts a result file to JSON")] 166 | Export { 167 | data: PathBuf, 168 | #[arg( 169 | long, 170 | short('o'), 171 | help = "The path where the output JSON will be stored" 172 | )] 173 | output: Option, 174 | #[arg(long, short('f'), help = "Overwrite the file if it exists")] 175 | force: bool, 176 | }, 177 | } 178 | 179 | fn run() -> Result<(), anyhow::Error> { 180 | let cli = Cli::parse(); 181 | 182 | match &cli.command { 183 | #[cfg(feature = "client")] 184 | &Commands::Test { 185 | ref server, 186 | download, 187 | upload, 188 | bidirectional, 189 | idle, 190 | throughput_sample_interval, 191 | latency_sample_interval, 192 | ref plot, 193 | port, 194 | streams, 195 | stream_stagger, 196 | grace_duration, 197 | load_duration, 198 | ref latency_peer_address, 199 | latency_peer, 200 | ref out_name, 201 | } => { 202 | let mut config = Config { 203 | port, 204 | streams, 205 | stream_stagger: Duration::from_secs_f64(stream_stagger), 206 | grace_duration: Duration::from_secs_f64(grace_duration), 207 | load_duration: Duration::from_secs_f64(load_duration), 208 | download: !idle, 209 | upload: !idle, 210 | bidirectional: !idle, 211 | ping_interval: Duration::from_millis(latency_sample_interval), 212 | throughput_interval: Duration::from_millis(throughput_sample_interval), 213 | }; 214 | 215 | if download || upload || bidirectional { 216 | if idle { 217 | println!("Cannot run `idle` test with a load test"); 218 | process::exit(1); 219 | } 220 | config.download = download; 221 | config.upload = upload; 222 | config.bidirectional = bidirectional; 223 | } 224 | 225 | crusader_lib::test::test( 226 | config, 227 | plot.config(), 228 | server.as_deref(), 229 | (latency_peer || latency_peer_address.is_some()) 230 | .then_some(latency_peer_address.as_deref()), 231 | out_name.as_deref().unwrap_or("test"), 232 | ) 233 | } 234 | &Commands::Serve { port, peer } => crusader_lib::serve::serve(port, peer), 235 | 236 | #[cfg(feature = "client")] 237 | Commands::Remote { port } => crusader_lib::remote::run(*port), 238 | 239 | #[cfg(feature = "client")] 240 | Commands::Plot { data, plot } => { 241 | let result = RawResult::load(data).ok_or(anyhow!("Unable to load data"))?; 242 | let root = data.parent().unwrap_or(Path::new("")); 243 | let file = crusader_lib::plot::save_graph( 244 | &plot.config(), 245 | &result.to_test_result(), 246 | data.file_stem() 247 | .and_then(|name| name.to_str()) 248 | .unwrap_or("plot"), 249 | data.parent().unwrap_or(Path::new("")), 250 | )?; 251 | println!( 252 | "{}", 253 | with_time(&format!("Saved plot as {}", root.join(file).display())) 254 | ); 255 | Ok(()) 256 | } 257 | #[cfg(feature = "client")] 258 | Commands::Export { 259 | data, 260 | output, 261 | force, 262 | } => { 263 | let result = RawResult::load(data).ok_or(anyhow!("Unable to load data"))?; 264 | let output = output 265 | .clone() 266 | .unwrap_or_else(|| data.with_extension("json")); 267 | let file = OpenOptions::new() 268 | .create_new(!*force) 269 | .create(*force) 270 | .truncate(true) 271 | .write(true) 272 | .open(output) 273 | .context("Failed to create output file")?; 274 | let mut file = BufWriter::new(file); 275 | serde_json::to_writer_pretty(&mut file, &result).context("Failed to serialize data")?; 276 | file.flush().context("Failed to flush output")?; 277 | 278 | Ok(()) 279 | } 280 | } 281 | } 282 | 283 | fn main() { 284 | env_logger::init(); 285 | 286 | #[cfg(feature = "client")] 287 | crusader_lib::plot::register_fonts(); 288 | 289 | if let Err(error) = run() { 290 | println!("Error: {:?}", error); 291 | process::exit(1); 292 | } 293 | } 294 | --------------------------------------------------------------------------------