diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 631e240..1d5bf25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,12 @@ +# This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ +# # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: # # * checks for a Git Tag that looks like a release -# * builds artifacts with cargo-dist (archives, installers, hashes) +# * builds artifacts with dist (archives, installers, hashes) # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # @@ -12,9 +14,8 @@ # title/body based on your changelogs. name: Release - permissions: - contents: write + "contents": "write" # This task will run whenever you push a git tag that looks like a version # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. @@ -23,10 +24,10 @@ permissions: # must be a Cargo-style SemVer Version (must have at least major.minor.patch). # # If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't cargo-dist-able). +# package (erroring out if it doesn't have the given version or isn't dist-able). # # If PACKAGE_NAME isn't specified, then the announcement will be for all -# (cargo-dist-able) packages in the workspace with that version (this mode is +# (dist-able) packages in the workspace with that version (this mode is # intended for workspaces with only one dist-able package, or with all dist-able # packages versioned/released in lockstep). # @@ -44,7 +45,7 @@ on: - '**[0-9]+.[0-9]+.[0-9]+*' jobs: - # Run 'cargo dist plan' (or host) to determine what tasks we need to do + # Run 'dist plan' (or host) to determine what tasks we need to do plan: runs-on: "ubuntu-20.04" outputs: @@ -58,11 +59,16 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - name: Install cargo-dist + - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.15.1/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.25.1/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist # sure would be cool if github gave us proper conditionals... # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible # functionality based on whether this is a pull_request, and whether it's from a fork. @@ -70,8 +76,8 @@ jobs: # but also really annoying to build CI around when it needs secrets to work right.) - id: plan run: | - cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json - echo "cargo dist ran successfully" + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" @@ -89,12 +95,12 @@ jobs: if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} strategy: fail-fast: false - # Target platforms/runners are computed by cargo-dist in create-release. + # Target platforms/runners are computed by dist in create-release. # Each member of the matrix has the following arguments: # # - runner: the github runner - # - dist-args: cli flags to pass to cargo dist - # - install-dist: expression to run to install cargo-dist on the runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner # # Typically there will be: # - 1 "global" task that builds universal installers @@ -111,10 +117,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - uses: swatinem/rust-cache@v2 - with: - key: ${{ join(matrix.targets, '-') }} - - name: Install cargo-dist + - name: Install dist run: ${{ matrix.install_dist }} # Get the dist-manifest - name: Fetch local artifacts @@ -129,8 +132,8 @@ jobs: - name: Build artifacts run: | # Actually do builds and make zips and whatnot - cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json - echo "cargo dist ran successfully" + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up @@ -165,9 +168,12 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - name: Install cargo-dist - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.15.1/cargo-dist-installer.sh | sh" + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts uses: actions/download-artifact@v4 @@ -178,8 +184,8 @@ jobs: - id: cargo-dist shell: bash run: | - cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json - echo "cargo dist ran successfully" + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" # Parse out what we just built and upload it to scratch storage echo "paths<> "$GITHUB_OUTPUT" @@ -211,8 +217,12 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - name: Install cargo-dist - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.15.1/cargo-dist-installer.sh | sh" + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts uses: actions/download-artifact@v4 @@ -220,11 +230,10 @@ jobs: pattern: artifacts-* path: target/distrib/ merge-multiple: true - # This is a harmless no-op for GitHub Releases, hosting for that happens in "announce" - id: host shell: bash run: | - cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json echo "artifacts uploaded and released successfully" cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" @@ -234,6 +243,28 @@ jobs: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* publish-homebrew-formula: needs: @@ -270,12 +301,16 @@ jobs: name=$(echo "$filename" | sed "s/\.rb$//") version=$(echo "$release" | jq .app_version --raw-output) + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + git add "Formula/${filename}" git commit -m "${name} ${version}" done git push - # Create a GitHub Release while uploading all files to it announce: needs: - plan @@ -292,21 +327,3 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v4 - with: - pattern: artifacts-* - path: artifacts - merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json - - name: Create GitHub Release - uses: ncipollo/release-action@v1 - with: - tag: ${{ needs.plan.outputs.tag }} - name: ${{ fromJson(needs.host.outputs.val).announcement_title }} - body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} - prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} - artifacts: "artifacts/*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40994ef..9518f0a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,34 +1,30 @@ -# GitHub workflow for automatically -# testing code on push to main -# and pull requests. +# GitHub workflow which automatically +# tests code on pushes and pull requests. name: Cargo Build & Test on: push: - branches: - - main pull_request: - types: [synchronize] env: CARGO_TERM_COLOR: always jobs: build_and_test: - name: Rust project - latest + name: Rust Build & Test runs-on: ubuntu-latest strategy: matrix: toolchain: - # - stable - - beta - # - nightly + - stable + # - beta + - nightly steps: - uses: actions/checkout@v3 - name: Update rustup - run: rustup update stable && rustup default stable + run: rustup update - name: Build run: cargo build --verbose @@ -46,7 +42,7 @@ jobs: run: cargo fmt --check --verbose - name: Install cargo audit - run: cargo install cargo-audit + run: cargo install --locked cargo-audit - name: Audit dependencies run: cargo audit diff --git a/.gitignore b/.gitignore index ac797dd..1b72444 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /Cargo.lock /target -tmp \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 146645a..860ac7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,9 @@ # Contributing -I'm eager to hear all your feedback and suggestions! -Just open a [GitHub issue](https://github.com/manforowicz/gday/issues) -and include as many details as you can. -For example, try running with `--verbosity debug` or `--verbosity trace` -and paste the log into your issue. +Open a [GitHub issue](https://github.com/manforowicz/gday/issues) +to report issues and suggest features. +Try running with `--verbosity debug` or `--verbosity trace` +and pasting the log into your issue. ## Contributing code diff --git a/Cargo.toml b/Cargo.toml index b04c507..bad44e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "gday", - "gday_gui", "gday_server", "gday_hole_punch", "gday_encryption", @@ -19,26 +18,7 @@ license = "MIT" repository = "https://github.com/manforowicz/gday/" version = "0.2.1" -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.15.1" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = ["homebrew"] -# A GitHub repo to push Homebrew formulas to -tap = "manforowicz/homebrew-tap" -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] -# Publish jobs to run in CI -publish-jobs = ["homebrew"] -# Publish jobs to run in CI -pr-run-mode = "plan" -# The archive format to use for non-windows builds (defaults .tar.xz) -unix-archive = ".tar.gz" - -# The profile that 'cargo dist' will build with +# The profile that 'dist' will build with [profile.dist] inherits = "release" lto = "thin" diff --git a/README.md b/README.md index 32d7b10..882109c 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,65 @@ -Note: this project is still in early-development, so expect breaking changes. - # gday [![Crates.io Version](https://img.shields.io/crates/v/gday)](https://crates.io/crates/gday) Command line tool to securely send files (without a relay or port forwarding).
-peer_1: gday send image.jpg folder
-<Asks for confirmation>
-Tell your mate to run "gday get 1.1C30.C71E.A".
-Transfer complete.
+peer_1: gday send file.mp4 folder
+Tell your mate to run "gday get 1.n5xn8.wvqsf".
 
-peer_2: gday get 1.1C30.C71E.A
-<Asks for confirmation>
+peer_2: gday get 1.n5xn8.wvqsf
 Transfer complete.
 
-[![asciicast](https://asciinema.org/a/1jjPVyccHweqgwA5V3un4tCnU.svg)](https://asciinema.org/a/1jjPVyccHweqgwA5V3un4tCnU) +[![asciicast](https://asciinema.org/a/Z8OJJr8xHRAJh6fuqocNcm9Zu.svg)](https://asciinema.org/a/Z8OJJr8xHRAJh6fuqocNcm9Zu) ## Installation To run the executable directly: -1. Go to [releases](https://github.com/manforowicz/gday/releases) -and download the correct file for your platform. +1. Download an executable from [releases](https://github.com/manforowicz/gday/releases). 2. Extract it (on Linux: `tar xf `). 3. Run it: `./gday` To install with **cargo**: ``` -$ cargo install gday +cargo install gday ``` To install with **brew**: ``` -$ brew install manforowicz/tap/gday +brew install manforowicz/tap/gday ``` ## Features -- File transfer is always direct, without relay servers. -A server is only used to exchange socket addresses at the beginning. + - No limit on the size of files and folders sent. + +- Files are sent directly, without relay servers. +A server is only used to exchange socket addresses at the beginning. + +- Automatically resumes interrupted transfers. Just `gday send` the same files, and partial downloads will be detected and resumed. + - Doesn't require port forwarding. Instead, uses [TCP Hole Punching](https://bford.info/pub/net/p2pnat/) to traverse [NATs](https://en.wikipedia.org/wiki/Network_address_translation). -Note: this may not work on very restrictive NATs. -- Server connection encrypted with [TLS](https://docs.rs/rustls/) -and file transfer encrypted with [ChaCha20Poly1305](https://docs.rs/chacha20poly1305/). +This may not work on very restrictive NATs. If that happens, enable IPv6 or move to a different network. + +- If a contact exchange server is down, just uses a different one from the default list. Or specify your own with `--server`. + +- Server connection encrypted with +[TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) +and file transfer is over TCP that's end-to-end encrypted with +[ChaCha20Poly1305](https://en.wikipedia.org/wiki/ChaCha20-Poly1305). + - Automatically tries both IPv4 and IPv6. -- Immune to malicious servers impersonating your peer. -Uses [SPAKE2](https://docs.rs/spake2/) password authenticated key exchange -to derive an encryption key from a shared secret. + +- Resistant to malicious servers impersonating your peer. +Uses [SPAKE2](https://datatracker.ietf.org/doc/rfc9382/) to derive an +encryption key from a shared secret. + - No `unsafe` Rust in this repository. @@ -68,7 +75,7 @@ Commands: Options: -s, --server Use a custom gday server with this domain name -p, --port Connect to a custom server port - -u, --unencrypted Use raw TCP without TLS + -u, --unencrypted Connect to server with TCP instead of TLS -v, --verbosity Verbosity. (trace, debug, info, warn, error) [default: warn] -h, --help Print help -V, --version Print version diff --git a/TODO.md b/TODO.md deleted file mode 100644 index fa93e82..0000000 --- a/TODO.md +++ /dev/null @@ -1,40 +0,0 @@ -# To-Do's -Quick notes on items to consider implementing. -Not all of them are desirable or necessary. - -- Update documentation after large async refactoring. - -- Add a GUI. - -- Confirm gday server works properly on both ipv4 and ipv6. Maybe add a test. - -- Confirm that TLS close is now properly sent, and no errors are logged. - - -## Abandoned ideas - -- Maybe add some versioning to the protocols? - Not needed, since the protocols are so simple, and won't change. - -- Make peer authentication not "block" hole punching. - "Blocking" might be an issue when the peer is receiving other - incoming connections. But this probably won't happen unless - the peer's device is acting as some sort of server. - -- Allow sending a simple text string instead of only files. - Though, I don't think this is a common use case. - -- Let the client select a source port, to utilize port forwarding. - However, turns out port forwarding works for inbound connections, - and not outbound ones, so this wouldn't help. - -- Restructure the hole puncher to force keeping connection to server open - during hole-punching. That might please some NATs that lose state when TCP connection is closed. - This is not really necessary, since I can just add a comment - telling any library users to not drop ServerConnection when calling connect to peer. - -- Make file transfer response msg list file names, instead of going by index? - I don't really see the advantage to doing this. - -- Support a shared secret longer than u64 in peer code? But then again, - users can just send their own struct in this case. \ No newline at end of file diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..0bf1ec2 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,21 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.25.1" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["homebrew"] +# A GitHub repo to push Homebrew formulas to +tap = "manforowicz/homebrew-tap" +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Publish jobs to run in CI +publish-jobs = ["homebrew"] +# Which actions to run on pull requests +pr-run-mode = "plan" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" diff --git a/gday/Cargo.toml b/gday/Cargo.toml index 5942dac..5fb8b7e 100644 --- a/gday/Cargo.toml +++ b/gday/Cargo.toml @@ -14,13 +14,12 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = { version = "4.5.9", features = ["derive"] } -env_logger = "0.11.3" +clap = { version = "4.5.21", features = ["derive"] } +env_logger = "0.11.5" gday_encryption = { version = "^0.2.1", path = "../gday_encryption" } gday_file_transfer = { version = "^0.2.1", path = "../gday_file_transfer" } gday_hole_punch = { version = "^0.2.1", path = "../gday_hole_punch" } -indicatif = "0.17.8" +indicatif = "0.17.9" log = "0.4.22" -owo-colors = "4.0.0" -rand = "0.8.5" -tokio = { version = "1.39.2", features = ["io-std"] } +owo-colors = "4.1.0" +tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros"] } diff --git a/gday/README.md b/gday/README.md index 3c6ff76..50095a6 100644 --- a/gday/README.md +++ b/gday/README.md @@ -1,58 +1,65 @@ -Note: this project is still in early-development, so expect breaking changes. - # gday [![Crates.io Version](https://img.shields.io/crates/v/gday)](https://crates.io/crates/gday) Command line tool to securely send files (without a relay or port forwarding).
-peer_1: gday send image.jpg folder
-<Asks for confirmation>
-Tell your mate to run "gday get 1.1C30.C71E.A".
-Transfer complete.
+peer_1: gday send file.mp4 folder
+Tell your mate to run "gday get 1.n5xn8.wvqsf".
 
-peer_2: gday get 1.1C30.C71E.A
-<Asks for confirmation>
+peer_2: gday get 1.n5xn8.wvqsf
 Transfer complete.
 
-[![asciicast](https://asciinema.org/a/1jjPVyccHweqgwA5V3un4tCnU.svg)](https://asciinema.org/a/1jjPVyccHweqgwA5V3un4tCnU) +[![asciicast](https://asciinema.org/a/Z8OJJr8xHRAJh6fuqocNcm9Zu.svg)](https://asciinema.org/a/Z8OJJr8xHRAJh6fuqocNcm9Zu) ## Installation To run the executable directly: -1. Go to [releases](https://github.com/manforowicz/gday/releases) -and download the correct file for your platform. +1. Download an executable from [releases](https://github.com/manforowicz/gday/releases). 2. Extract it (on Linux: `tar xf `). 3. Run it: `./gday` To install with **cargo**: ``` -$ cargo install gday +cargo install gday ``` To install with **brew**: ``` -$ brew install manforowicz/tap/gday +brew install manforowicz/tap/gday ``` ## Features -- File transfer is always direct, without relay servers. -A server is only used to exchange socket addresses at the beginning. + - No limit on the size of files and folders sent. + +- Files are sent directly, without relay servers. +A server is only used to exchange socket addresses at the beginning. + +- Automatically resumes interrupted transfers. Just `gday send` the same files, and partial downloads will be detected and resumed. + - Doesn't require port forwarding. Instead, uses [TCP Hole Punching](https://bford.info/pub/net/p2pnat/) to traverse [NATs](https://en.wikipedia.org/wiki/Network_address_translation). -Note: this may not work on very restrictive NATs. -- Server connection encrypted with [TLS](https://docs.rs/rustls/) -and file transfer encrypted with [ChaCha20Poly1305](https://docs.rs/chacha20poly1305/). +This may not work on very restrictive NATs. If that happens, enable IPv6 or move to a different network. + +- If a contact exchange server is down, just uses a different one from the default list. Or specify your own with `--server`. + +- Server connection encrypted with +[TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) +and file transfer end-to-end encrypted with +[ChaCha20Poly1305](https://en.wikipedia.org/wiki/ChaCha20-Poly1305). + - Automatically tries both IPv4 and IPv6. -- Immune to malicious servers impersonating your peer. -Uses [SPAKE2](https://docs.rs/spake2/) password authenticated key exchange -to derive an encryption key from a shared secret. + +- Resistant to malicious servers impersonating your peer. +Uses [SPAKE2](https://datatracker.ietf.org/doc/rfc9382/) to derive an +encryption key from a shared secret. + - No `unsafe` Rust in this repository. @@ -68,7 +75,7 @@ Commands: Options: -s, --server Use a custom gday server with this domain name -p, --port Connect to a custom server port - -u, --unencrypted Use raw TCP without TLS + -u, --unencrypted Connect to server with TCP instead of TLS -v, --verbosity Verbosity. (trace, debug, info, warn, error) [default: warn] -h, --help Print help -V, --version Print version diff --git a/gday/src/dialog.rs b/gday/src/dialog.rs index 49b4f7c..1615f4a 100644 --- a/gday/src/dialog.rs +++ b/gday/src/dialog.rs @@ -3,13 +3,15 @@ use gday_file_transfer::{FileOfferMsg, FileResponseMsg}; use indicatif::HumanBytes; use owo_colors::OwoColorize; -use std::{io::Write, path::Path}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use std::{ + io::{BufRead, Write}, + path::Path, +}; /// Confirms that the user wants to send these `files``. /// /// If not, returns false. -pub async fn confirm_send(files: &FileOfferMsg) -> std::io::Result { +pub fn confirm_send(files: &FileOfferMsg) -> std::io::Result { // print all the file names and sizes println!("{}", "Files to send:".bold()); for file in &files.files { @@ -25,13 +27,12 @@ pub async fn confirm_send(files: &FileOfferMsg) -> std::io::Result { HumanBytes(total_size).bold() ); std::io::stdout().flush()?; - let input = get_lowercase_input().await?; + let input = get_lowercase_input()?; // act on user choice if "yes".starts_with(&input) { Ok(true) } else { - println!("Cancelled."); Ok(false) } } @@ -39,7 +40,7 @@ pub async fn confirm_send(files: &FileOfferMsg) -> std::io::Result { /// Asks the user which of the files in `offer` to accept. /// /// `save_dir` is the directory where the files will later be saved. -pub async fn ask_receive( +pub fn ask_receive( offer: &FileOfferMsg, save_dir: &Path, ) -> Result { @@ -51,7 +52,7 @@ pub async fn ask_receive( print!("{} ({})", file.short_path.display(), HumanBytes(file.len)); // an interrupted download exists - if let Some(local_len) = file.partial_download_exists(save_dir).await? { + if let Some(local_len) = file.partial_download_exists(save_dir)? { let remaining_len = file.len - local_len; print!( @@ -62,7 +63,7 @@ pub async fn ask_receive( ); // file was already downloaded - } else if file.already_exists(save_dir).await? { + } else if file.already_exists(save_dir)? { print!(" {}", "ALREADY EXISTS".green().bold()); } println!(); @@ -70,8 +71,9 @@ pub async fn ask_receive( println!(); - let new_files = FileResponseMsg::accept_only_new_and_interrupted(offer, save_dir).await?; + let new_files = FileResponseMsg::accept_only_new_and_interrupted(offer, save_dir)?; let all_files = FileResponseMsg::accept_all_files(offer); + let no_files = FileResponseMsg::reject_all_files(offer); // If there are no existing/interrupted files, // send or quit. @@ -79,16 +81,15 @@ pub async fn ask_receive( print!( "Download all {} files ({})? (y/n): ", all_files.get_num_fully_accepted(), - HumanBytes(offer.get_transfer_size(&new_files)?).bold() + HumanBytes(offer.get_transfer_size(&all_files)?).bold() ); std::io::stdout().flush()?; - let input = get_lowercase_input().await?; + let input = get_lowercase_input()?; if "yes".starts_with(&input) { return Ok(all_files); } else { - println!("Cancelled."); - std::process::exit(0); + return Ok(no_files); } } @@ -97,17 +98,33 @@ pub async fn ask_receive( all_files.response.len(), HumanBytes(offer.get_transfer_size(&all_files)?).bold() ); - println!( - "2. Download only the {} new files, and resume {} interrupted downloads ({}).", - new_files.get_num_fully_accepted(), - new_files.get_num_partially_accepted(), - HumanBytes(offer.get_transfer_size(&new_files)?).bold() - ); + + if new_files.get_num_partially_accepted() == 0 { + println!( + "2. Only download the {} new files ({}).", + new_files.get_num_fully_accepted(), + HumanBytes(offer.get_transfer_size(&new_files)?).bold() + ); + } else if new_files.get_num_fully_accepted() == 0 { + println!( + "2. Only resume the {} interrupted downloads ({}).", + new_files.get_num_partially_accepted(), + HumanBytes(offer.get_transfer_size(&new_files)?).bold() + ); + } else { + println!( + "2. Only download the {} new files, and resume {} interrupted downloads ({}).", + new_files.get_num_fully_accepted(), + new_files.get_num_partially_accepted(), + HumanBytes(offer.get_transfer_size(&new_files)?).bold() + ); + } + println!("3. Cancel."); print!("{} ", "Choose an option (1, 2, or 3):".bold()); std::io::stdout().flush()?; - match get_lowercase_input().await?.as_str() { + match get_lowercase_input()?.as_str() { // all files "1" => Ok(all_files), // new/interrupted files @@ -118,18 +135,14 @@ pub async fn ask_receive( } /// Reads a trimmed ascii-lowercase line of input from the user. -async fn get_lowercase_input() -> std::io::Result { - let Some(response) = BufReader::new(tokio::io::stdin()) - .lines() - .next_line() - .await? - else { +fn get_lowercase_input() -> std::io::Result { + let Some(response) = std::io::BufReader::new(std::io::stdin()).lines().next() else { return Err(std::io::Error::new( std::io::ErrorKind::UnexpectedEof, "Couldn't read user input.", )); }; - let response = response.trim().to_ascii_lowercase(); + let response = response?.trim().to_ascii_lowercase(); Ok(response) } diff --git a/gday/src/main.rs b/gday/src/main.rs index 8416112..8b8810a 100644 --- a/gday/src/main.rs +++ b/gday/src/main.rs @@ -9,20 +9,15 @@ use crate::dialog::ask_receive; use clap::{Parser, Subcommand}; use gday_encryption::EncryptedStream; use gday_file_transfer::{read_from_async, write_to_async, FileOfferMsg, FileResponseMsg}; -use gday_hole_punch::PeerCode; -use gday_hole_punch::{ - server_connector::{self, DEFAULT_SERVERS}, - ContactSharer, -}; +use gday_hole_punch::server_connector::{self, DEFAULT_SERVERS}; +use gday_hole_punch::{share_contacts, PeerCode}; use log::error; use log::info; use owo_colors::OwoColorize; -use rand::Rng; use std::path::PathBuf; -use std::str::FromStr; /// How long to try hole punching before giving up. -const HOLE_PUNCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const HOLE_PUNCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// How long to try connecting to a server before giving up. const SERVER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); @@ -31,7 +26,7 @@ const SERVER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); #[command(author, version, about)] struct Args { #[command(subcommand)] - operation: Command, + command: Command, /// Use a custom gday server with this domain name. #[arg(short, long)] @@ -41,7 +36,7 @@ struct Args { #[arg(short, long, requires("server"))] port: Option, - /// Use raw TCP without TLS. + /// Connect to server with TCP instead of TLS. #[arg(short, long, requires("server"))] unencrypted: bool, @@ -54,28 +49,30 @@ struct Args { enum Command { /// Send files and/or directories. Send { - /// Custom shared code of form "server_id.room_code.shared_secret" (base 16). - /// - /// server_id must be valid, or 0 when custom --server set. - /// - /// Doesn't require a checksum digit. - #[arg(short, long)] - code: Option, - /// Files and/or directories to send. #[arg(required = true, num_args = 1..)] paths: Vec, + + /// Custom shared code of form "server_id.room_code.shared_secret". + /// + /// A server_id of 0 causes a random server to be used. + /// server_id ignored when custom --server set. + #[arg(short, long, conflicts_with = "length")] + code: Option, + + /// Length of room_code and shared_secret to generate. + #[arg(short, long, default_value = "5", conflicts_with = "code")] + length: usize, }, /// Receive files. Get { + /// The code your peer gave you (of form "server_id.room_code.shared_secret") + code: PeerCode, + /// Directory where to save the files. - /// By default, saves them in the current directory. #[arg(short, long, default_value = ".")] path: PathBuf, - - /// The code that your peer gave you. - code: String, }, } @@ -99,69 +96,91 @@ async fn main() { } async fn run(args: crate::Args) -> Result<(), Box> { - // get the server port + // Get the server port let port = if let Some(port) = args.port { port } else { server_connector::DEFAULT_PORT }; - // use custom server if the user provided one, - // otherwise pick a random default server - let (mut server_connection, server_id) = if let Some(domain_name) = args.server { + // Connect to a custom server if the user chose one. + let custom_server = if let Some(domain_name) = args.server { if args.unencrypted { - ( + Some( server_connector::connect_tcp(format!("{domain_name}:{port}"), SERVER_TIMEOUT) .await?, - 0, ) } else { - ( - server_connector::connect_tls(domain_name, port, SERVER_TIMEOUT).await?, - 0, - ) + Some(server_connector::connect_tls(domain_name, port, SERVER_TIMEOUT).await?) } } else { - server_connector::connect_to_random_server(DEFAULT_SERVERS, SERVER_TIMEOUT).await? + None }; - match args.operation { - // sending files - crate::Command::Send { paths, code } => { + match args.command { + crate::Command::Send { + paths, + code, + length, + } => { + // If the user chose a custom server + let (mut server_connection, server_id) = if let Some(custom_server) = custom_server { + (custom_server, 0) + + // If the user chose a custom code + } else if let Some(code) = &code { + if code.server_id == 0 { + server_connector::connect_to_random_server(DEFAULT_SERVERS, SERVER_TIMEOUT) + .await? + } else { + ( + server_connector::connect_to_server_id( + DEFAULT_SERVERS, + code.server_id, + SERVER_TIMEOUT, + ) + .await?, + code.server_id, + ) + } + + // Otherwise, pick a random server + } else { + server_connector::connect_to_random_server(DEFAULT_SERVERS, SERVER_TIMEOUT).await? + }; + // generate random `room_code` and `shared_secret` // if the user didn't provide custom ones let peer_code = if let Some(code) = code { - PeerCode::from_str(&code)? + PeerCode { server_id, ..code } } else { - let mut rng = rand::thread_rng(); - PeerCode { - server_id, - room_code: rng.gen_range(0..u16::MAX as u64), - shared_secret: rng.gen_range(0..u16::MAX as u64), - } + PeerCode::random(server_id, length) }; // get metadata about the files to transfer - let local_files = gday_file_transfer::get_file_metas(&paths).await?; + let local_files = gday_file_transfer::get_file_metas(&paths)?; let offer_msg = FileOfferMsg::from(local_files.clone()); // confirm the user wants to send these files - if !dialog::confirm_send(&offer_msg).await? { - // Send aborted + if !dialog::confirm_send(&offer_msg)? { + println!("Cancelled."); return Ok(()); } // create a room in the server - let (contact_sharer, my_contact) = - ContactSharer::enter_room(&mut server_connection, peer_code.room_code, true) + let (my_contact, peer_contact_fut) = + share_contacts(&mut server_connection, peer_code.room_code.as_bytes(), true) .await?; info!("Your contact is:\n{my_contact}"); - println!("Tell your mate to run \"gday get {}\"", peer_code.bold()); + println!( + "Tell your mate to run \"gday get {}\"", + String::try_from(&peer_code)?.bold() + ); // get peer's contact - let peer_contact = contact_sharer.get_peer_contact().await?; + let peer_contact = peer_contact_fut.await?; info!("Your mate's contact is:\n{peer_contact}"); // connect to the peer @@ -170,12 +189,15 @@ async fn run(args: crate::Args) -> Result<(), Box> { gday_hole_punch::try_connect_to_peer( my_contact.local, peer_contact, - &peer_code.shared_secret.to_be_bytes(), + peer_code.shared_secret.as_bytes(), ), ) .await .map_err(|_| gday_hole_punch::Error::HolePunchTimeout)??; + // Gracefully close the server connection + server_connection.shutdown().await?; + let mut stream = EncryptedStream::encrypt_connection(stream, &shared_key).await?; info!("Established authenticated encrypted connection with peer."); @@ -185,7 +207,7 @@ async fn run(args: crate::Args) -> Result<(), Box> { println!("File offer sent to mate. Waiting on response."); - // receive file offer from peer + // receive response from peer let response: FileResponseMsg = read_from_async(&mut stream).await?; // Total number of files accepted @@ -211,13 +233,23 @@ async fn run(args: crate::Args) -> Result<(), Box> { // receiving files crate::Command::Get { path, code } => { - let code = PeerCode::from_str(&code)?; - let (contact_sharer, my_contact) = - ContactSharer::enter_room(&mut server_connection, code.room_code, false).await?; + let mut server_connection = if let Some(custom_server) = custom_server { + custom_server + } else { + server_connector::connect_to_server_id( + DEFAULT_SERVERS, + code.server_id, + SERVER_TIMEOUT, + ) + .await? + }; + + let (my_contact, peer_contact_fut) = + share_contacts(&mut server_connection, code.room_code.as_bytes(), false).await?; info!("Your contact is:\n{my_contact}"); - let peer_contact = contact_sharer.get_peer_contact().await?; + let peer_contact = peer_contact_fut.await?; info!("Your mate's contact is:\n{peer_contact}"); @@ -226,12 +258,15 @@ async fn run(args: crate::Args) -> Result<(), Box> { gday_hole_punch::try_connect_to_peer( my_contact.local, peer_contact, - &code.shared_secret.to_be_bytes(), + code.shared_secret.as_bytes(), ), ) .await .map_err(|_| gday_hole_punch::Error::HolePunchTimeout)??; + // Gracefully close the server connection + server_connection.shutdown().await?; + let mut stream = EncryptedStream::encrypt_connection(stream, &shared_key).await?; info!("Established authenticated encrypted connection with peer."); @@ -239,7 +274,7 @@ async fn run(args: crate::Args) -> Result<(), Box> { // receive file offer from peer let offer: FileOfferMsg = read_from_async(&mut stream).await?; - let response = ask_receive(&offer, &path).await?; + let response = ask_receive(&offer, &path)?; // respond to the file offer write_to_async(&response, &mut stream).await?; diff --git a/gday/src/transfer.rs b/gday/src/transfer.rs index b1030ff..fdbc558 100644 --- a/gday/src/transfer.rs +++ b/gday/src/transfer.rs @@ -8,15 +8,16 @@ pub async fn send_files( response: FileResponseMsg, writer: &mut EncryptedStream, ) -> Result<(), Box> { - let progress_bar = create_progress_bar(); + let len = FileOfferMsg::from(offer.clone()).get_transfer_size(&response)?; + let progress_bar = create_progress_bar(len); let mut current_file = String::from("Starting..."); let update_progress = |report: &TransferReport| { progress_bar.set_position(report.processed_bytes); - progress_bar.set_length(report.total_bytes); if current_file.as_str() != report.current_file.to_string_lossy() { - current_file = report.current_file.to_string_lossy().to_string(); - progress_bar.set_message(format!("Receiving {}", current_file)); + current_file.clear(); + current_file.push_str(&report.current_file.to_string_lossy()); + progress_bar.set_message(format!("Sending {}", current_file)); } }; @@ -26,7 +27,7 @@ pub async fn send_files( Ok(()) } Err(err) => { - progress_bar.abandon_with_message("Transfer failed."); + progress_bar.abandon_with_message("Send failed."); Err(err.into()) } } @@ -42,14 +43,15 @@ pub async fn receive_files( save_dir: &std::path::Path, reader: &mut EncryptedStream, ) -> Result<(), Box> { - let progress_bar = create_progress_bar(); - let mut current_file = String::from("Starting..."); + let len = offer.get_transfer_size(&response)?; + let progress_bar = create_progress_bar(len); + let mut current_file = String::new(); let update_progress = |report: &TransferReport| { progress_bar.set_position(report.processed_bytes); - progress_bar.set_length(report.total_bytes); if current_file.as_str() != report.current_file.to_string_lossy() { - current_file = report.current_file.to_string_lossy().to_string(); + current_file.clear(); + current_file.push_str(&report.current_file.to_string_lossy()); progress_bar.set_message(format!("Receiving {}", current_file)); } }; @@ -64,20 +66,20 @@ pub async fn receive_files( Ok(()) } Err(err) => { - progress_bar.abandon_with_message("Transfer failed."); + progress_bar.abandon_with_message("Receive failed."); Err(err.into()) } } } /// Create a stylded [`ProgressBar`]. -fn create_progress_bar() -> ProgressBar { +fn create_progress_bar(len: u64) -> ProgressBar { let style = ProgressStyle::with_template( "{msg} [{wide_bar}] {bytes}/{total_bytes} | {bytes_per_sec} | eta: {eta}", ) .expect("Progress bar style string was invalid."); let draw = ProgressDrawTarget::stderr_with_hz(2); - ProgressBar::with_draw_target(None, draw) + ProgressBar::with_draw_target(Some(len), draw) .with_style(style) .with_message("starting...") } diff --git a/gday_contact_exchange_protocol/Cargo.toml b/gday_contact_exchange_protocol/Cargo.toml index 42de444..994f695 100644 --- a/gday_contact_exchange_protocol/Cargo.toml +++ b/gday_contact_exchange_protocol/Cargo.toml @@ -14,10 +14,10 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -serde = { version = "1.0.204", features = ["derive"] } -serde_json = "1.0.120" -thiserror = "1.0.61" -tokio = { version = "1.38.0", features = ["io-util"] } +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +thiserror = "2.0.3" +tokio = { version = "1.41.1", features = ["io-util"] } [dev-dependencies] -tokio = { version = "1.38.0", features = ["test-util", "macros"] } +tokio = { version = "1.41.1", features = ["test-util", "macros"] } diff --git a/gday_contact_exchange_protocol/README.md b/gday_contact_exchange_protocol/README.md index 291b593..17ea72a 100644 --- a/gday_contact_exchange_protocol/README.md +++ b/gday_contact_exchange_protocol/README.md @@ -1,10 +1,8 @@ -Note: this crate is still in early-development, so expect breaking changes. - # gday_contact_exchange_protocol [![Crates.io Version](https://img.shields.io/crates/v/gday_contact_exchange_protocol)](https://crates.io/crates/gday_contact_exchange_protocol) [![docs.rs](https://img.shields.io/docsrs/gday_contact_exchange_protocol)](https://docs.rs/gday_contact_exchange_protocol/) -This protocol lets two users exchange their public and (optionally) private socket addresses via a server. +Protocol for peers to exchange their socket addresses via a server. See the [documentation](https://docs.rs/gday_contact_exchange_protocol/). diff --git a/gday_contact_exchange_protocol/src/lib.rs b/gday_contact_exchange_protocol/src/lib.rs index b2d6279..63cf66b 100644 --- a/gday_contact_exchange_protocol/src/lib.rs +++ b/gday_contact_exchange_protocol/src/lib.rs @@ -1,6 +1,4 @@ -//! Note: this crate is still in early-development, so expect breaking changes. -//! -//! This protocol lets two users exchange their public and (optionally) private socket addresses via a server. +//! Protocol for peers to exchange their socket addresses via a server. //! //! On it's own, this library doesn't do anything other than define a shared protocol. //! In most cases, you should use one of the following crates: @@ -14,7 +12,7 @@ //! //! # Example //! First, both peers connect with TLS on both IPv4 and IPv6 (if possible) -//! to a gday server with [`DEFAULT_PORT`]. +//! to a gday server on [`DEFAULT_PORT`]. //! Then they exchange contacts like so: //! ```no_run //! # use gday_contact_exchange_protocol::{ @@ -27,47 +25,47 @@ //! # let mut tls_ipv4 = std::collections::VecDeque::new(); //! # let mut tls_ipv6 = std::collections::VecDeque::new(); //! # -//! let room_code = 42; +//! let room_code = *b"32-bytes. May be a password hash"; //! -//! // A client tells the server to create a room. +//! // One client tells the server to create a room. //! // The server responds with ServerMsg::RoomCreated or -//! // ServerMsg::ErrorRoomTaken. +//! // an error message. //! let request = ClientMsg::CreateRoom { room_code }; //! write_to(request, &mut tls_ipv4)?; -//! let response: ServerMsg = read_from(&mut tls_ipv4)?; +//! let ServerMsg::RoomCreated = read_from(&mut tls_ipv4)? else { panic!() }; //! -//! // Each peer sends ClientMsg::RecordPublicAddr -//! // from all their endpoints. +//! // Both peers sends ClientMsg::RecordPublicAddr +//! // from their IPv4 and/or IPv6 endpoints. //! // The server records the client's public addresses from these connections. -//! // The server responds with ServerMsg::ReceivedAddr +//! // The server responds with ServerMsg::ReceivedAddr or an error message. //! let request = ClientMsg::RecordPublicAddr { room_code, is_creator: true }; //! write_to(request, &mut tls_ipv4)?; -//! let response: ServerMsg = read_from(&mut tls_ipv4)?; +//! let ServerMsg::ReceivedAddr = read_from(&mut tls_ipv4)? else { panic!() }; //! write_to(request, &mut tls_ipv6)?; -//! let response: ServerMsg = read_from(&mut tls_ipv6)?; +//! let ServerMsg::ReceivedAddr = read_from(&mut tls_ipv6)? else { panic!() }; //! //! // Both peers share their local address with the server. //! // The server immediately responds with ServerMsg::ClientContact, -//! // containing each client's FullContact. +//! // containing the client's FullContact. //! let local_contact = Contact { -//! v4: todo!("local v4 addr"), -//! v6: todo!("local v6 addr") +//! v4: Some("1.8.3.1:2304".parse()?), +//! v6: Some("[ab:41::b:43]:92".parse()?), //! }; //! let request = ClientMsg::ReadyToShare { local_contact, room_code, is_creator: true }; //! write_to(request, &mut tls_ipv4)?; -//! let response: ServerMsg = read_from(&mut tls_ipv4)?; +//! let ServerMsg::ClientContact(my_contact) = read_from(&mut tls_ipv4)? else { panic!() }; //! -//! // Once both clients have sent ClientMsg::ShareContact, +//! // Once both clients have sent ClientMsg::ReadyToShare, //! // the server sends both clients a ServerMsg::PeerContact //! // containing the FullContact of the peer. -//! let response: ServerMsg = read_from(&mut tls_ipv4)?; +//! let ServerMsg::PeerContact(peer_contact) = read_from(&mut tls_ipv4)? else { panic!() }; //! //! // The server then closes the room, and the peers disconnect. //! //! // The peers then connect directly to each other using a library //! // such as gday_hole_punch. //! # -//! # Ok::<(), gday_contact_exchange_protocol::Error>(()) +//! # Ok::<(), Box>(()) //! ``` //! #![forbid(unsafe_code)] @@ -85,23 +83,33 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; /// using encrypted TLS should listen on. pub const DEFAULT_PORT: u16 = 2311; +/// Version of the protocol. +/// Different numbers wound indicate +/// incompatible protocol breaking changes. +pub const PROTOCOL_VERSION: u8 = 1; + /// A message from client to server. #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Copy)] #[non_exhaustive] pub enum ClientMsg { /// Requests the server to create a new room. /// + /// The server should automatically delete new rooms after roughly 10 minutes. + /// + /// More than one room can be created per connection. + /// /// Server responds with [`ServerMsg::RoomCreated`] on success - /// and [`ServerMsg::ErrorRoomTaken`] if the room already exists. - CreateRoom { room_code: u64 }, + /// or [`ServerMsg::ErrorRoomTaken`] in the unlikely case that this room is taken. + CreateRoom { room_code: [u8; 32] }, - /// Tells the server to record the client's public socket address - /// of the connection on which this message was sent. + /// Tells the server to record this client's public socket address + /// from the connection on which this message was sent. /// - /// Server responds with [`ServerMsg::ReceivedAddr`] on success. + /// Server responds with [`ServerMsg::ReceivedAddr`] on success + /// or an error [`ServerMsg`] on failure. RecordPublicAddr { /// The room this client is in. - room_code: u64, + room_code: [u8; 32], /// Whether this is the client that created this room, /// or the other client. is_creator: bool, @@ -117,12 +125,16 @@ pub enum ClientMsg { /// connection. /// /// Once the other peer also sends [`ClientMsg::ReadyToShare`], - /// the server responds with [`ServerMsg::PeerContact`] - /// which contains the peer's contact info. - /// The room then closes. + /// the server sends both peers a [`ServerMsg::PeerContact`] + /// which contains the other peer's contact info. + /// The room then closes, but the server doesn't disconnect. ReadyToShare { + /// The local contact to share. local_contact: Contact, - room_code: u64, + /// The room this client is in. + room_code: [u8; 32], + /// Whether this is the client that created this room, + /// or the other client. is_creator: bool, }, } @@ -133,11 +145,11 @@ pub enum ClientMsg { pub enum ServerMsg { /// Immediately responds to a [`ClientMsg::CreateRoom`] request. /// Indicates that a room with the given ID has been successfully created. - /// The room will expire/close in a few minutes. + /// The room will automatically close in roughly 10 minutes. RoomCreated, /// Immediately responds to a [`ClientMsg::RecordPublicAddr`] - /// to indicate it was successfully recorded. + /// to indicate a client's public address was successfully recorded. ReceivedAddr, /// Immediately responds to a [`ClientMsg::ReadyToShare`]. @@ -145,7 +157,7 @@ pub enum ServerMsg { ClientContact(FullContact), /// After both clients in a room have sent [`ClientMsg::ReadyToShare`], - /// the server sends with this message. + /// the server sends this message. /// Contains the other peer's contact info. PeerContact(FullContact), @@ -155,15 +167,15 @@ pub enum ServerMsg { /// If only one client sends [`ClientMsg::ReadyToShare`] before the room /// times out, the server replies with this message instead of - /// [`ServerMsg::PeerContact`] + /// [`ServerMsg::PeerContact`]. ErrorPeerTimedOut, /// The server responds with this if the `room_code` of a [`ClientMsg`] /// doesn't exist, either because this room timed out, or never existed. ErrorNoSuchRoomCode, - /// The server responds with this if a client sends [`ClientMsg::RecordPublicAddr`] - /// after sending [`ClientMsg::ReadyToShare`] on a different connection. + /// The server may respond with this if a client sends [`ClientMsg::RecordPublicAddr`] + /// after already sending [`ClientMsg::ReadyToShare`]. ErrorUnexpectedMsg, /// Rejects a request if an IP address made too many requests. @@ -171,12 +183,9 @@ pub enum ServerMsg { ErrorTooManyRequests, /// The server responds with this if it receives a [`ClientMsg`] - /// it doesn't understand. The server then closes the connection. - ErrorSyntax, - - /// The server responds with this if it has any sort of connection error. + /// it doesn't understand. /// The server then closes the connection. - ErrorConnection, + ErrorSyntax, /// The server responds with this if it has an internal error. /// The server then closes the connection. @@ -194,13 +203,15 @@ impl Display for ServerMsg { Self::PeerContact(c) => write!(f, "The server says your peer's contact is {c}."), Self::ErrorRoomTaken => write!( f, - "Can't create room with this code, because it was already created." + "Can't create a room with this room code, because it's already taken." ), Self::ErrorPeerTimedOut => write!( f, - "Timed out while waiting for peer to finish sending their address." + "Timed out while waiting for peer to finish sending their addresses to the server." ), - Self::ErrorNoSuchRoomCode => write!(f, "No room with this room code has been created."), + Self::ErrorNoSuchRoomCode => { + write!(f, "No room with this code currently exists on the server.") + } Self::ErrorUnexpectedMsg => write!( f, "Server received RecordPublicAddr message after a ReadyToShare message. \ @@ -211,13 +222,12 @@ impl Display for ServerMsg { "Exceeded request limit from this IP address. Try again in a minute." ), Self::ErrorSyntax => write!(f, "Server couldn't parse message syntax from client."), - Self::ErrorConnection => write!(f, "Connection error to client."), - Self::ErrorInternal => write!(f, "Internal server error."), + Self::ErrorInternal => write!(f, "Server had an internal error."), } } } -/// The addresses of a single network endpoint. +/// The addresses of a single client. /// May have IPv6, IPv4, none, or both. #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Copy, Default)] pub struct Contact { @@ -247,9 +257,9 @@ impl std::fmt::Display for Contact { } } -/// The public and local endpoints of an client. +/// The local and public endpoints of an client. /// -/// [`FullContact::public`] is different from [`FullContact::local`] when the entity is behind +/// [`FullContact::local`] is only different from [`FullContact::public`] when the client is behind /// [NAT (network address translation)](https://en.wikipedia.org/wiki/Network_address_translation). #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Copy, Default)] pub struct FullContact { @@ -272,11 +282,17 @@ impl std::fmt::Display for FullContact { /// Writes `msg` to `writer` using [`serde_json`], and flushes. /// -/// Prefixes the message with 4 big-endian bytes that hold its length. +/// Prefixes the message with 2 bytes holding the [`PROTOCOL_VERSION`] +/// and 2 bytes holding the length of the following message (all in big-endian). pub fn write_to(msg: impl Serialize, writer: &mut impl Write) -> Result<(), Error> { let vec = serde_json::to_vec(&msg)?; - let len_byte = u32::try_from(vec.len())?; - writer.write_all(&len_byte.to_be_bytes())?; + let len = u16::try_from(vec.len())?; + + let mut header = [0; 3]; + header[0] = PROTOCOL_VERSION; + header[1..3].copy_from_slice(&len.to_be_bytes()); + + writer.write_all(&header)?; writer.write_all(&vec)?; writer.flush()?; Ok(()) @@ -284,14 +300,20 @@ pub fn write_to(msg: impl Serialize, writer: &mut impl Write) -> Result<(), Erro /// Asynchronously writes `msg` to `writer` using [`serde_json`], and flushes. /// -/// Prefixes the message with a 4 big-endian bytes that hold its length. +/// Prefixes the message with 2 bytes holding the [`PROTOCOL_VERSION`] +/// and 2 bytes holding the length of the following message (all in big-endian). pub async fn write_to_async( msg: impl Serialize, writer: &mut (impl AsyncWrite + Unpin), ) -> Result<(), Error> { let vec = serde_json::to_vec(&msg)?; - let len_byte = u32::try_from(vec.len())?; - writer.write_all(&len_byte.to_be_bytes()).await?; + let len = u16::try_from(vec.len())?; + + let mut header = [0; 3]; + header[0] = PROTOCOL_VERSION; + header[1..3].copy_from_slice(&len.to_be_bytes()); + + writer.write_all(&header).await?; writer.write_all(&vec).await?; writer.flush().await?; Ok(()) @@ -299,11 +321,15 @@ pub async fn write_to_async( /// Reads a message from `reader` using [`serde_json`]. /// -/// Assumes the message is prefixed with 4 big-endian bytes that holds its length. +/// Assumes the message is prefixed with 1 byte holding the [`PROTOCOL_VERSION`] +/// and 2 big-endian bytes holding the length of the following message. pub fn read_from(reader: &mut impl Read) -> Result { - let mut len = [0_u8; 4]; - reader.read_exact(&mut len)?; - let len = u32::from_be_bytes(len) as usize; + let mut header = [0_u8; 3]; + reader.read_exact(&mut header)?; + if header[0] != PROTOCOL_VERSION { + return Err(Error::IncompatibleProtocol); + } + let len = u16::from_be_bytes(header[1..3].try_into().unwrap()) as usize; let mut buf = vec![0; len]; reader.read_exact(&mut buf)?; @@ -312,13 +338,17 @@ pub fn read_from(reader: &mut impl Read) -> Result( reader: &mut (impl AsyncRead + Unpin), ) -> Result { - let mut len = [0_u8; 4]; - reader.read_exact(&mut len).await?; - let len = u32::from_be_bytes(len) as usize; + let mut header = [0_u8; 3]; + reader.read_exact(&mut header).await?; + if header[0] != PROTOCOL_VERSION { + return Err(Error::IncompatibleProtocol); + } + let len = u16::from_be_bytes(header[1..3].try_into().unwrap()) as usize; let mut buf = vec![0; len]; reader.read_exact(&mut buf).await?; @@ -337,7 +367,15 @@ pub enum Error { #[error("IO Error: {0}")] IO(#[from] std::io::Error), - /// Can't send message longer than 2^32 bytes. - #[error("Can't send message longer than 2^32 bytes: {0}")] + /// Can't send message longer than 2^16 bytes. + #[error("Can't send message longer than 2^16 bytes: {0}")] MsgTooLong(#[from] std::num::TryFromIntError), + + /// Received a message with an incompatible protocol version. + /// Check if this software is up-to-date. + #[error( + "Received a message with an incompatible protocol version. \ + Check if this software is up-to-date." + )] + IncompatibleProtocol, } diff --git a/gday_contact_exchange_protocol/tests/test_integration.rs b/gday_contact_exchange_protocol/tests/test_integration.rs index fe0d520..d18a3d6 100644 --- a/gday_contact_exchange_protocol/tests/test_integration.rs +++ b/gday_contact_exchange_protocol/tests/test_integration.rs @@ -1,13 +1,11 @@ #![forbid(unsafe_code)] #![warn(clippy::all)] -use std::io::Write; - -use tokio::io::AsyncWriteExt; - use gday_contact_exchange_protocol::{ read_from, read_from_async, write_to, write_to_async, ClientMsg, Contact, Error, FullContact, ServerMsg, }; +use std::io::Write; +use tokio::io::AsyncWriteExt; /// Test serializing and deserializing messages. #[test] @@ -38,11 +36,21 @@ fn error_on_invalid_json() { let mut pipe = std::collections::VecDeque::new(); // gibberish json - pipe.write_all(&[0, 0, 0, 5, 52, 45, 77, 123, 12]).unwrap(); + pipe.write_all(&[1, 0, 5, 52, 45, 77, 123, 12]).unwrap(); let result: Result = read_from(&mut pipe); assert!(matches!(result, Err(Error::JSON(_)))); } +#[test] +fn error_on_incompatible_version() { + let mut pipe = std::collections::VecDeque::new(); + + // invalid version + pipe.write_all(&[2, 0, 5, 52, 45, 77, 123, 12]).unwrap(); + let result: Result = read_from(&mut pipe); + assert!(matches!(result, Err(Error::IncompatibleProtocol))); +} + /// Test serializing and deserializing messages asynchronously. #[tokio::test] async fn sending_messages_async() { @@ -72,23 +80,37 @@ async fn error_on_invalid_json_async() { let (mut writer, mut reader) = tokio::io::duplex(1000); // gibberish json writer - .write_all(&[0, 0, 0, 5, 52, 45, 77, 123, 12]) + .write_all(&[1, 0, 5, 52, 45, 77, 123, 12]) .await .unwrap(); let result: Result = read_from_async(&mut reader).await; assert!(matches!(result, Err(Error::JSON(_)))); } +#[tokio::test] +async fn error_on_incompatible_version_async() { + let (mut writer, mut reader) = tokio::io::duplex(1000); + // gibberish json + writer + .write_all(&[2, 0, 5, 52, 45, 77, 123, 12]) + .await + .unwrap(); + let result: Result = read_from_async(&mut reader).await; + assert!(matches!(result, Err(Error::IncompatibleProtocol))); +} + /// Get a [`Vec`] of example [`ClientMsg`]s. fn get_client_msg_examples() -> Vec { vec![ - ClientMsg::CreateRoom { room_code: 452932 }, + ClientMsg::CreateRoom { + room_code: *b"fjdsafdssds89fph9ewafhusdp9afhas", + }, ClientMsg::RecordPublicAddr { - room_code: 2345, + room_code: *b"fdsjafp89rejfnsdi;ofnsdo;jfsadif", is_creator: true, }, ClientMsg::ReadyToShare { - room_code: 24325423, + room_code: *b"jfdsi9uapfj89erpajf98sdpfajisdaf", is_creator: false, local_contact: Contact { v4: Some("31.31.65.31:324".parse().unwrap()), @@ -128,6 +150,5 @@ fn get_server_msg_examples() -> Vec { ServerMsg::ErrorNoSuchRoomCode, ServerMsg::ErrorTooManyRequests, ServerMsg::ErrorSyntax, - ServerMsg::ErrorConnection, ] } diff --git a/gday_encryption/Cargo.toml b/gday_encryption/Cargo.toml index 584018e..d2601bc 100644 --- a/gday_encryption/Cargo.toml +++ b/gday_encryption/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gday_encryption" -description = "Simple encrypted ChaCha20Poly1305 wrapper around an IO stream." +description = "Simple encrypted ChaCha20Poly1305 wrapper around an async IO stream." homepage = "https://github.com/manforowicz/gday/tree/main/gday_encryption" categories = ["cryptography"] @@ -15,13 +15,13 @@ version.workspace = true [dependencies] chacha20poly1305 = { version = "0.10.1", features = ["stream"] } -pin-project = "1.1.5" +pin-project = "1.1.7" rand = "0.8.5" -tokio = { version = "1.39.2", features = ["io-util"] } +tokio = { version = "1.41.1", features = ["io-util"] } [dev-dependencies] -criterion = { version = "0.5.1", features = ["async_tokio", "tokio"] } -tokio = { version = "1.39.2", features = ["net", "rt", "macros"] } +criterion = { version = "0.5.1", features = ["async_tokio"] } +tokio = { version = "1.41.1", features = ["net", "rt", "macros"] } [[bench]] name = "benchmark" diff --git a/gday_encryption/README.md b/gday_encryption/README.md index 055c991..2261391 100644 --- a/gday_encryption/README.md +++ b/gday_encryption/README.md @@ -2,7 +2,7 @@ [![Crates.io Version](https://img.shields.io/crates/v/gday_encryption)](https://crates.io/crates/gday_encryption) [![docs.rs](https://img.shields.io/docsrs/gday_encryption)](https://docs.rs/gday_encryption/) -A simple encrypted wrapper around an IO stream. +Simple encrypted ChaCha20Poly1305 wrapper around an async IO stream. Uses a streaming [chacha20poly1305](https://docs.rs/chacha20poly1305/latest/chacha20poly1305/) cipher. See the [documentation](https://docs.rs/gday_encryption/). diff --git a/gday_encryption/src/helper_buf.rs b/gday_encryption/src/helper_buf.rs index f8c16c4..a304058 100644 --- a/gday_encryption/src/helper_buf.rs +++ b/gday_encryption/src/helper_buf.rs @@ -38,25 +38,26 @@ impl HelperBuf { } /// Returns the internal spare capacity after the right cursor. - /// - Put data to the spare capacity, then use [`Self::increase_len()`] + /// - Copy data to the spare capacity, then use [`Self::increase_len()`] pub fn spare_capacity(&mut self) -> &mut [u8] { &mut self.inner[self.r_cursor..] } /// Increment the right cursor by `num_bytes`. - /// - Do this after putting data to [`Self::spare_capacity()`]. - /// - Panics if this would put the right cursor beyond the capacity. + /// - Do this after copying data to [`Self::spare_capacity()`]. pub fn increase_len(&mut self, num_bytes: usize) { self.r_cursor += num_bytes; - assert!(self.r_cursor <= self.inner.len()); + debug_assert!(self.r_cursor <= self.inner.len()); } /// Shifts the stored data to the beginning of the internal buffer. /// Maximizes `spare_capacity_len()` without changing anything else. pub fn left_align(&mut self) { - self.inner.copy_within(self.l_cursor..self.r_cursor, 0); - self.r_cursor -= self.l_cursor; - self.l_cursor = 0; + if self.l_cursor != 0 { + self.inner.copy_within(self.l_cursor..self.r_cursor, 0); + self.r_cursor -= self.l_cursor; + self.l_cursor = 0; + } } /// Returns a mutable [`aead::Buffer`] view into the part of this @@ -85,10 +86,9 @@ impl aead::Buffer for HelperBuf { /// Shortens the length of [`HelperBuf`] to `len` /// by cutting off data at the end. - /// - Panics if `len > self.len()` fn truncate(&mut self, len: usize) { let new_r_cursor = self.l_cursor + len; - assert!(new_r_cursor <= self.r_cursor); + debug_assert!(new_r_cursor <= self.r_cursor); self.r_cursor = new_r_cursor; } } @@ -144,10 +144,9 @@ impl<'a> aead::Buffer for HelperBufPart<'a> { /// Shortens the length of this [`HelperBufPart`] to `len` /// by cutting off data at the end. - /// - Panics if `len > self.len()` fn truncate(&mut self, len: usize) { let new_r_cursor = self.start_i + len; - assert!(new_r_cursor <= self.parent.r_cursor); + debug_assert!(new_r_cursor <= self.parent.r_cursor); self.parent.r_cursor = new_r_cursor; } } diff --git a/gday_encryption/src/lib.rs b/gday_encryption/src/lib.rs index d2eddb4..d5c9c91 100644 --- a/gday_encryption/src/lib.rs +++ b/gday_encryption/src/lib.rs @@ -1,8 +1,4 @@ -//! A simple encrypted wrapper around an IO stream. -//! TODO: UPDATE FOR ASYNC -//! TODO: Optimization when inner is bufread -//! -//! Uses a streaming [chacha20poly1305](https://docs.rs/chacha20poly1305/latest/chacha20poly1305/) cipher. +//! Simple encrypted ChaCha20Poly1305 wrapper around an async IO stream. //! //! This library is used by [gday_file_transfer](https://crates.io/crates/gday_file_transfer), //! which is used by [gday](https://crates.io/crates/gday). @@ -89,19 +85,24 @@ pub struct EncryptedStream { /// Encrypted data received from the inner IO stream. /// - Invariant: Never stores a complete chunk(s). - /// As soon as full chunks are read, moves and decrypts them + /// + /// As soon as full chunk(s) are read, moves and decrypts them /// into `decrypted`. received: HelperBuf, /// Data that has been decrypted from `received`. /// - Invariant: This must be empty when calling - /// [`Self::inner_read()`] + /// [`Self::inner_read()`] decrypted: HelperBuf, - /// Data to be sent. Encrypted only when flushing. + /// Data to be sent. Encrypted only when [`Self::flushing`]. /// - Invariant: the first 2 bytes are always - /// reserved for the length header + /// reserved for the length + /// - Invariant: Data can only be appended when `flushing` is false. to_send: HelperBuf, + + /// Is the content of `to_send` encrypted and ready to write? + flushing: bool, } impl EncryptedStream { @@ -111,7 +112,7 @@ impl EncryptedStream { /// - The `key` must be a cryptographically random secret. /// - The `nonce` shouldn't be reused, but doesn't need to be secret. /// - /// - See [`Self::encrypt_connection()`] if you'd like an auto-generated nonce. + /// - See [`Self::encrypt_connection()`] if you'd like an auto-generatcan't createed nonce. pub fn new(io_stream: T, key: &[u8; 32], nonce: &[u8; 7]) -> Self { let mut to_send = HelperBuf::with_capacity(u16::MAX as usize + 2); // add 2 bytes for length header to uphold invariant @@ -124,6 +125,7 @@ impl EncryptedStream { received: HelperBuf::with_capacity(u16::MAX as usize + 2), decrypted: HelperBuf::with_capacity(u16::MAX as usize + 2), to_send, + flushing: false, } } } @@ -167,7 +169,7 @@ impl AsyncRead for EncryptedStream { buf: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { // if we're out of decrypted data, read more - if self.as_mut().decrypted.is_empty() { + if self.decrypted.is_empty() { ready!(self.as_mut().inner_read(cx))?; } @@ -180,7 +182,7 @@ impl AsyncRead for EncryptedStream { } } -impl AsyncBufRead for EncryptedStream { +impl AsyncBufRead for EncryptedStream { fn consume(self: std::pin::Pin<&mut EncryptedStream>, amt: usize) { self.project().decrypted.consume(amt); } @@ -204,15 +206,22 @@ impl AsyncWrite for EncryptedStream { cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { + // Finish up any flushes before proceeding. + if self.flushing { + ready!(self.as_mut().flush_write_buf(cx))?; + } + let me = self.as_mut().project(); + let bytes_taken = std::cmp::min(buf.len(), me.to_send.spare_capacity().len() - TAG_SIZE); me.to_send .extend_from_slice(&buf[0..bytes_taken]) .expect("unreachable"); - // if `to_send` is full, flush it + // if `to_send` is full, start the process + // of flushing it if me.to_send.spare_capacity().len() - TAG_SIZE == 0 { - ready!(self.flush_write_buf(cx))?; + let _ = self.flush_write_buf(cx)?; } Poll::Ready(Ok(bytes_taken)) } @@ -240,7 +249,7 @@ impl EncryptedStream { let mut me = self.project(); // ensure we have the full buffer to decrypt into - assert!(me.decrypted.is_empty()); + debug_assert!(me.decrypted.is_empty()); // maximize room to receive more data me.received.left_align(); @@ -297,18 +306,24 @@ impl EncryptedStream { /// Encrypts and fully flushes [`Self::to_send`]. fn flush_write_buf(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut me = self.project(); - // encrypt in place - let mut msg = me.to_send.split_off_aead_buf(2); - me.encryptor - .encrypt_next_in_place(&[], &mut msg) - .map_err(|_| std::io::Error::new(ErrorKind::InvalidData, "Encryption error"))?; - - let len = u16::try_from(msg.len()) - .expect("unreachable: Length of message buffer should always fit in u16") - .to_be_bytes(); - // write length to header - me.to_send[0..2].copy_from_slice(&len); + // If we're just starting a flush, + // encrypt the data. + if !*me.flushing { + *me.flushing = true; + // encrypt in place + let mut msg = me.to_send.split_off_aead_buf(2); + me.encryptor + .encrypt_next_in_place(&[], &mut msg) + .map_err(|_| std::io::Error::new(ErrorKind::InvalidData, "Encryption error"))?; + + let len = u16::try_from(msg.len()) + .expect("unreachable: Length of message buffer should always fit in u16") + .to_be_bytes(); + + // write length to header + me.to_send[0..2].copy_from_slice(&len); + } // write until empty while !me.to_send.is_empty() { @@ -316,6 +331,9 @@ impl EncryptedStream { me.to_send.consume(bytes_written); } + // if we've reached this point, flushing has finished + *me.flushing = false; + // make space for new header me.to_send .extend_from_slice(&[0, 0]) diff --git a/gday_encryption/tests/test_integration.rs b/gday_encryption/tests/test_integration.rs index 0590160..1719bdf 100644 --- a/gday_encryption/tests/test_integration.rs +++ b/gday_encryption/tests/test_integration.rs @@ -38,6 +38,9 @@ async fn test_transfers() { stream_a.write_all(chunk).await.unwrap(); stream_a.flush().await.unwrap(); } + // Ensure calling shutdown multiple times works + stream_a.shutdown().await.unwrap(); + stream_a.shutdown().await.unwrap(); }); // Stream that will receive the test data sent to the loopback address. @@ -52,6 +55,10 @@ async fn test_transfers() { stream_b.read_exact(&mut received).await.unwrap(); assert_eq!(*chunk, received); } + + // EOF should return 0 + assert_eq!(stream_b.read(&mut [0, 0, 0]).await.unwrap(), 0); + assert_eq!(stream_b.read(&mut [0, 0, 0]).await.unwrap(), 0); } /// Test bufread @@ -88,6 +95,9 @@ async fn test_bufread() { stream_a.write_all(chunk).await.unwrap(); stream_a.flush().await.unwrap(); } + + stream_a.shutdown().await.unwrap(); + stream_a.shutdown().await.unwrap(); }); // Stream that will receive the test data sent to the loopback address. @@ -105,6 +115,10 @@ async fn test_bufread() { assert_ne!(bytes_read, 0); } assert_eq!(received, bytes); + + // EOF should return 0 + assert_eq!(stream_b.read(&mut [0, 0, 0]).await.unwrap(), 0); + assert_eq!(stream_b.read(&mut [0, 0, 0]).await.unwrap(), 0); } /// Confirm there are no infinite loops on EOF diff --git a/gday_file_transfer/Cargo.toml b/gday_file_transfer/Cargo.toml index 9343275..f9152c8 100644 --- a/gday_file_transfer/Cargo.toml +++ b/gday_file_transfer/Cargo.toml @@ -14,14 +14,13 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -futures = "0.3.30" os_str_bytes = "7.0.0" -pin-project = "1.1.5" -rand = "0.8.5" -serde = { version = "1.0.204", features = ["derive"] } -serde_json = "1.0.120" -thiserror = "1.0.61" -tokio = { version = "1.39.2", features = ["fs", "net", "io-util", "rt", "macros"] } +pin-project = "1.1.7" +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +thiserror = "2.0.3" +tokio = { version = "1.41.1", features = ["io-util"] } [dev-dependencies] -tempfile = "3.10.1" +tempfile = "3.14.0" +tokio = { version = "1.41.1", features = ["macros"] } diff --git a/gday_file_transfer/README.md b/gday_file_transfer/README.md index f46090d..bee011a 100644 --- a/gday_file_transfer/README.md +++ b/gday_file_transfer/README.md @@ -1,5 +1,3 @@ -Note: this crate is still in early-development, so expect breaking changes. - # gday_file_transfer [![Crates.io Version](https://img.shields.io/crates/v/gday_file_transfer)](https://crates.io/crates/gday_file_transfer) [![docs.rs](https://img.shields.io/docsrs/gday_file_transfer)](https://docs.rs/gday_file_transfer/) diff --git a/gday_file_transfer/src/file_meta.rs b/gday_file_transfer/src/file_meta.rs index 921df07..b070f1f 100644 --- a/gday_file_transfer/src/file_meta.rs +++ b/gday_file_transfer/src/file_meta.rs @@ -1,5 +1,4 @@ use crate::Error; -use futures::{future::BoxFuture, FutureExt}; use os_str_bytes::OsStrBytesExt; use serde::{Deserialize, Serialize}; use std::{ @@ -16,7 +15,7 @@ pub struct FileMeta { pub len: u64, } -/// Information about a locally stored file +/// Information about a locally stored file. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)] pub struct FileMetaLocal { /// The shortened path that will be offered to the peer @@ -29,7 +28,7 @@ pub struct FileMetaLocal { impl FileMeta { /// Gets the base path where the file that this - /// [`FileMetaLocal`] represents should be saved. + /// [`FileMeta`] represents should be saved. /// /// Returns `save_dir` joined with [`Self::short_path`]. /// @@ -48,9 +47,9 @@ impl FileMeta { /// /// If all of these (up to `" (99)"`) are occupied, /// returns [`Error::FilenameOccupied`]. - pub async fn get_unoccupied_save_path(&self, save_dir: &Path) -> Result { + pub fn get_unoccupied_save_path(&self, save_dir: &Path) -> Result { let mut path = self.get_save_path(save_dir); - let number = get_first_unoccupied_number(&path).await?; + let number = get_first_unoccupied_number(&path)?; if number != 0 { suffix_with_number(&mut path, number); @@ -68,12 +67,9 @@ impl FileMeta { /// will be one less than that of /// [`Self::get_unoccupied_save_path()`] (or no suffix /// if [`Self::get_unoccupied_save_path()`] has suffix of 1). - pub async fn get_last_occupied_save_path( - &self, - save_dir: &Path, - ) -> Result, Error> { + pub fn get_last_occupied_save_path(&self, save_dir: &Path) -> Result, Error> { let mut path = self.get_save_path(save_dir); - let number = get_first_unoccupied_number(&path).await?; + let number = get_first_unoccupied_number(&path)?; if number == 0 { Ok(None) @@ -88,8 +84,8 @@ impl FileMeta { /// Returns `true` iff a file is already saved at /// [`Self::get_last_occupied_save_path()`] /// with the same length as [`Self::len`]. - pub async fn already_exists(&self, save_dir: &Path) -> Result { - if let Some(occupied) = self.get_last_occupied_save_path(save_dir).await? { + pub fn already_exists(&self, save_dir: &Path) -> Result { + if let Some(occupied) = self.get_last_occupied_save_path(save_dir)? { if let Ok(metadata) = occupied.metadata() { if metadata.is_file() && metadata.len() == self.len { return Ok(true); @@ -119,13 +115,13 @@ impl FileMeta { /// already exists and has a length smaller than [`Self::len`]. /// If so, returns the length of the partially downloaded file. /// If it doesn't exist, returns None. - pub async fn partial_download_exists(&self, save_dir: &Path) -> Result, Error> { + pub fn partial_download_exists(&self, save_dir: &Path) -> Result, Error> { let local_path = self.get_partial_download_path(save_dir)?; // check if the file can be opened - if let Ok(file) = tokio::fs::File::open(local_path).await { + if let Ok(file) = std::fs::File::open(local_path) { // check if its length is less than the meta length - if let Ok(local_meta) = file.metadata().await { + if let Ok(local_meta) = file.metadata() { let local_len = local_meta.len(); if local_len < self.len { return Ok(Some(local_len)); @@ -151,9 +147,9 @@ impl From for FileMeta { /// Otherwise, returns the smallest number, starting at 1, that /// when suffixed to `path` (using [`suffix_with_number()`]), /// gives an unoccupied path. -async fn get_first_unoccupied_number(path: &Path) -> Result { +fn get_first_unoccupied_number(path: &Path) -> Result { // if the file doesn't exist - if tokio::fs::metadata(path).await.is_err() { + if !path.exists() { return Ok(0); } @@ -198,11 +194,11 @@ fn suffix_with_number(path: &mut PathBuf, number: u32) { /// Returns the [`FileMetaLocal`] of each file, including those in nested directories. /// /// Returns an error if can't access a path, one path is the prefix -/// of another path, or two paths end in the same name. +/// of another path, or two of the given `paths` end in the same name. /// /// Each file's [`FileMeta::short_path`] will contain the path to the file, /// starting at the provided level, ignoring parent directories. -pub async fn get_file_metas(paths: &[PathBuf]) -> Result, Error> { +pub fn get_file_metas(paths: &[PathBuf]) -> Result, Error> { // canonicalize the paths to remove symlinks let paths = paths .iter() @@ -240,7 +236,7 @@ pub async fn get_file_metas(paths: &[PathBuf]) -> Result, Err let top_path = path.parent().unwrap_or(Path::new("")); // add all files in this path to the files set - get_file_metas_helper(top_path, &path, &mut files).await?; + get_file_metas_helper(top_path, &path, &mut files)?; } // build a vec from the set, and return @@ -248,44 +244,41 @@ pub async fn get_file_metas(paths: &[PathBuf]) -> Result, Err } /// - The [`FileMetaLocal::short_path`] will strip the prefix -/// `top_path` from all paths. `top_path` must be a prefix of `path`. +/// `top_path` from all paths. `top_path` must be a prefix of `path`. /// - `path` is the file or directory where recursive traversal begins. -/// - `files` is a [`HashSet`] to which found files will be inserted. -fn get_file_metas_helper<'a>( - top_path: &'a Path, - path: &'a Path, - files: &'a mut Vec, -) -> BoxFuture<'a, std::io::Result<()>> { - async move { - if path.is_dir() { - // recursively traverse subdirectories - let mut entries = tokio::fs::read_dir(path).await?; - while let Some(entry) = entries.next_entry().await? { - get_file_metas_helper(top_path, &entry.path(), files).await?; - } - } else if path.is_file() { - // return an error if a file couldn't be opened. - std::fs::File::open(path)?; - - // get the shortened path - let short_path = path - .strip_prefix(top_path) - .expect("`top_path` was not a prefix of `path`.") - .to_path_buf(); - - // get the file's size - let len = path.metadata()?.len(); - - // insert this file metadata into set - let meta = FileMetaLocal { - local_path: path.to_path_buf(), - short_path, - len, - }; - files.push(meta); +/// - `files` is a [`Vec`] to which found files will be inserted. +fn get_file_metas_helper( + top_path: &Path, + path: &Path, + files: &mut Vec, +) -> std::io::Result<()> { + if path.is_dir() { + // recursively traverse subdirectories + let entries = std::fs::read_dir(path)?; + for entry in entries { + get_file_metas_helper(top_path, &entry?.path(), files)?; } - - Ok(()) + } else if path.is_file() { + // return an error if a file couldn't be opened. + std::fs::File::open(path)?; + + // get the shortened path + let short_path = path + .strip_prefix(top_path) + .expect("`top_path` was not a prefix of `path`.") + .to_path_buf(); + + // get the file's size + let len = path.metadata()?.len(); + + // insert this file metadata into set + let meta = FileMetaLocal { + local_path: path.to_path_buf(), + short_path, + len, + }; + files.push(meta); } - .boxed() + + Ok(()) } diff --git a/gday_file_transfer/src/lib.rs b/gday_file_transfer/src/lib.rs index 01c7de1..4690190 100644 --- a/gday_file_transfer/src/lib.rs +++ b/gday_file_transfer/src/lib.rs @@ -1,5 +1,3 @@ -//! Note: this crate is still in early-development, so expect breaking changes. -//! //! This library lets you offer and transfer files to another peer, //! assuming you already have a reliable connection established. //! @@ -24,12 +22,12 @@ //! # //! # let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); //! # rt.block_on( async { -//! -//! # let (mut stream1, mut stream2) = tokio::io::duplex(64); -//! # +//! # let (stream1, stream2) = tokio::io::duplex(64); +//! # let mut stream1 = tokio::io::BufReader::new(stream1); +//! # let mut stream2 = tokio::io::BufReader::new(stream2); //! // Peer A offers files and folders they'd like to send //! let paths_to_send = ["folder/to/send/".into(), "a/file.txt".into()]; -//! let files_to_send = get_file_metas(&paths_to_send).await?; +//! let files_to_send = get_file_metas(&paths_to_send)?; //! let offer_msg = FileOfferMsg::from(files_to_send.clone()); //! write_to_async(offer_msg, &mut stream1).await?; //! @@ -38,7 +36,7 @@ //! let response_msg = FileResponseMsg::accept_only_new_and_interrupted( //! &offer_msg, //! Path::new("save/the/files/here/"), -//! ).await?; +//! )?; //! write_to_async(response_msg, &mut stream2).await?; //! //! // Peer A sends the accepted files @@ -67,6 +65,11 @@ pub use crate::offer::{ }; pub use crate::transfer::{receive_files, send_files, TransferReport}; +/// Version of the protocol. +/// Different numbers wound indicate +/// incompatible protocol breaking changes. +pub const PROTOCOL_VERSION: u8 = 1; + /// `gday_file_transfer` error. #[derive(Error, Debug)] #[non_exhaustive] @@ -80,7 +83,7 @@ pub enum Error { #[error("IO Error: {0}")] IO(#[from] std::io::Error), - /// All 100 suitable filenames for this [`FileMeta`] are occupied. + /// All 100 suitable locations to save [`FileMeta`] are occupied. /// /// Comes from [`FileMeta::get_unoccupied_save_path()`] /// or [`FileMeta::get_partial_download_path()`]. @@ -123,4 +126,12 @@ pub enum Error { This would make the offered metadata ambiguous." )] PathsHaveSameName(std::ffi::OsString), + + /// Received a message with an incompatible protocol version. + /// Check if this software is up-to-date. + #[error( + "Received a message with an incompatible protocol version. \ + Check if this software is up-to-date." + )] + IncompatibleProtocol, } diff --git a/gday_file_transfer/src/offer.rs b/gday_file_transfer/src/offer.rs index e00a3cc..841b291 100644 --- a/gday_file_transfer/src/offer.rs +++ b/gday_file_transfer/src/offer.rs @@ -1,4 +1,4 @@ -use crate::{Error, FileMeta, FileMetaLocal}; +use crate::{Error, FileMeta, FileMetaLocal, PROTOCOL_VERSION}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ io::{Read, Write}, @@ -60,7 +60,7 @@ impl From> for FileOfferMsg { /// - `None` indicates that the corresponding file is rejected. /// - `Some(0)` indicates that the corresponding file is fully accepted. /// - `Some(k)` indicates that the corresponding file is accepted, -/// except for the first `k` bytes. +/// except for the first `k` bytes. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct FileResponseMsg { /// The accepted files. `Some(start_byte)` element accepts the offered @@ -94,14 +94,14 @@ impl FileResponseMsg { /// by partially accepting files. /// /// Rejects all other files. - pub async fn accept_only_full_new_files( + pub fn accept_only_full_new_files( offer: &FileOfferMsg, save_dir: &Path, ) -> Result { let mut response = Vec::with_capacity(offer.files.len()); for file_meta in &offer.files { - if file_meta.already_exists(save_dir).await? { + if file_meta.already_exists(save_dir)? { // reject response.push(None); } else { @@ -112,44 +112,23 @@ impl FileResponseMsg { Ok(Self { response }) } - /// Returns a [`FileResponseMsg`] that would - /// accept only the remaining portions of files - /// whose downloads to `save_dir` have been previously interrupted. - /// - /// Rejects all other files. - pub async fn accept_only_remaining_portions( - offer: &FileOfferMsg, - save_dir: &Path, - ) -> Result { - let mut response = Vec::with_capacity(offer.files.len()); - - for offered in &offer.files { - if let Some(existing_size) = offered.partial_download_exists(save_dir).await? { - response.push(Some(existing_size)); - } else { - response.push(None); - } - } - Ok(Self { response }) - } - /// Get a [`FileResponseMsg`] that would: /// - Accept the remaining portions of files whose - /// downloads to `save_dir` have been previously interrupted, + /// downloads to `save_dir` have been previously interrupted, /// - AND files that are not yet in `save_dir`, - /// or have a different size. + /// or have a different size. /// /// Rejects all other files. - pub async fn accept_only_new_and_interrupted( + pub fn accept_only_new_and_interrupted( offer: &FileOfferMsg, save_dir: &Path, ) -> Result { let mut response = Vec::with_capacity(offer.files.len()); for offered in &offer.files { - if let Some(existing_size) = offered.partial_download_exists(save_dir).await? { + if let Some(existing_size) = offered.partial_download_exists(save_dir)? { response.push(Some(existing_size)); - } else if offered.already_exists(save_dir).await? { + } else if offered.already_exists(save_dir)? { response.push(None); } else { response.push(Some(0)); @@ -184,11 +163,17 @@ impl FileResponseMsg { /// Writes `msg` to `writer` using [`serde_json`], and flushes. /// -/// Prefixes the message with 4 big-endian bytes that hold its length. +/// Prefixes the message with 2 bytes holding the [`PROTOCOL_VERSION`] +/// and 4 bytes holding the length of the following message (all in big-endian). pub fn write_to(msg: impl Serialize, writer: &mut impl Write) -> Result<(), Error> { let vec = serde_json::to_vec(&msg)?; - let len_byte = u32::try_from(vec.len())?; - writer.write_all(&len_byte.to_be_bytes())?; + let len = u32::try_from(vec.len())?; + + let mut header = [0; 5]; + header[0] = PROTOCOL_VERSION; + header[1..5].copy_from_slice(&len.to_be_bytes()); + + writer.write_all(&header)?; writer.write_all(&vec)?; writer.flush()?; Ok(()) @@ -196,14 +181,20 @@ pub fn write_to(msg: impl Serialize, writer: &mut impl Write) -> Result<(), Erro /// Asynchronously writes `msg` to `writer` using [`serde_json`], and flushes. /// -/// Prefixes the message with a 4 big-endian bytes that hold its length. +/// Prefixes the message with 2 bytes holding the [`PROTOCOL_VERSION`] +/// and 4 bytes holding the length of the following message (all in big-endian). pub async fn write_to_async( msg: impl Serialize, writer: &mut (impl AsyncWrite + Unpin), ) -> Result<(), Error> { let vec = serde_json::to_vec(&msg)?; - let len_byte = u32::try_from(vec.len())?; - writer.write_all(&len_byte.to_be_bytes()).await?; + let len = u32::try_from(vec.len())?; + + let mut header = [0; 5]; + header[0] = PROTOCOL_VERSION; + header[1..5].copy_from_slice(&len.to_be_bytes()); + + writer.write_all(&header).await?; writer.write_all(&vec).await?; writer.flush().await?; Ok(()) @@ -211,11 +202,15 @@ pub async fn write_to_async( /// Reads a message from `reader` using [`serde_json`]. /// -/// Assumes the message is prefixed with 4 big-endian bytes that holds its length. +/// Assumes the message is prefixed with 1 byte holding the [`PROTOCOL_VERSION`] +/// and 4 big-endian bytes holding the length of the following message. pub fn read_from(reader: &mut impl Read) -> Result { - let mut len = [0_u8; 4]; - reader.read_exact(&mut len)?; - let len = u32::from_be_bytes(len) as usize; + let mut header = [0_u8; 5]; + reader.read_exact(&mut header)?; + if header[0] != PROTOCOL_VERSION { + return Err(Error::IncompatibleProtocol); + } + let len = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize; let mut buf = vec![0; len]; reader.read_exact(&mut buf)?; @@ -224,13 +219,17 @@ pub fn read_from(reader: &mut impl Read) -> Result( reader: &mut (impl AsyncRead + Unpin), ) -> Result { - let mut len = [0_u8; 4]; - reader.read_exact(&mut len).await?; - let len = u32::from_be_bytes(len) as usize; + let mut header = [0_u8; 5]; + reader.read_exact(&mut header).await?; + if header[0] != PROTOCOL_VERSION { + return Err(Error::IncompatibleProtocol); + } + let len = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize; let mut buf = vec![0; len]; reader.read_exact(&mut buf).await?; diff --git a/gday_file_transfer/src/transfer.rs b/gday_file_transfer/src/transfer.rs index 6611c79..3a581ae 100644 --- a/gday_file_transfer/src/transfer.rs +++ b/gday_file_transfer/src/transfer.rs @@ -1,15 +1,11 @@ -use tokio::io::{ - AsyncBufRead, AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt, BufWriter, -}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt}; use crate::{Error, FileMeta, FileMetaLocal, FileOfferMsg, FileResponseMsg}; -use std::io::SeekFrom; +use std::io::{ErrorKind, Seek, SeekFrom}; use std::path::Path; -use std::pin::Pin; +use std::pin::{pin, Pin}; use std::task::{ready, Context, Poll}; -const FILE_BUFFER_SIZE: usize = 1_000_000; - /// Holds the status of a file transfer #[derive(Debug, Clone)] pub struct TransferReport { @@ -22,18 +18,20 @@ pub struct TransferReport { /// Transfers the requested files to `writer`. /// -/// - `offer` is the `Vec` of [`FileMetaLocal`] offered to the peer. -/// - `response` is the peer's [`FileResponseMsg`]. +/// - `offer` is the `Vec` of [`FileMetaLocal`] you sent to your peer. +/// - `response` is the [`FileResponseMsg`] received from your peer. +/// - `writer` is the the IO stream on which the files will be sent. /// - `progress_callback` is a function that gets frequently -/// called with [`TransferReport`] to report progress. +/// called with [`TransferReport`] to report progress. /// /// Transfers the accepted files in order, sequentially, back-to-back. pub async fn send_files( offer: &[FileMetaLocal], response: &FileResponseMsg, - writer: impl AsyncWrite + Unpin, + writer: impl AsyncWrite, progress_callback: impl FnMut(&TransferReport), ) -> Result<(), Error> { + let writer = pin!(writer); let files: Vec<(&FileMetaLocal, u64)> = offer .iter() .zip(&response.response) @@ -50,28 +48,28 @@ pub async fn send_files( } // Wrap the writer to report progress over `progress_tx` - let mut writer = ProgressWrapper::new( - BufWriter::with_capacity(FILE_BUFFER_SIZE, writer), - total_bytes, - files.len() as u64, - progress_callback, - ); + let mut writer = + ProgressWrapper::new(writer, total_bytes, files.len() as u64, progress_callback); + + // 64 KiB copy buffer + let mut buf = vec![0; 0x10000]; // iterate over all the files for (offer, start) in files { // report the file path writer.progress.current_file.clone_from(&offer.short_path); - let mut file = tokio::fs::File::open(&offer.local_path).await?; + let mut file = std::fs::File::open(&offer.local_path)?; // confirm file length matches metadata length - if file.metadata().await?.len() != offer.len { + if file.metadata()?.len() != offer.len { return Err(Error::UnexpectedFileLen); } // copy the file into the writer - file.seek(SeekFrom::Start(start)).await?; - tokio::io::copy(&mut file, &mut writer).await?; + file.seek(SeekFrom::Start(start))?; + + file_to_net(&mut file, &mut writer, offer.len - start, &mut buf).await?; // report the number of processed files writer.progress.processed_files += 1; @@ -85,20 +83,21 @@ pub async fn send_files( /// Receives the requested files from `reader`. /// /// - `offer` is the [`FileOfferMsg`] offered by the peer. -/// - `response` is your corresponding [`FileResponseMsg`]. +/// - `response` is the [`FileResponseMsg`] that you've sent in response. /// - `save_path` is the directory where the files should be saved. /// - `reader` is the IO stream on which the files will be received. /// - `progress_callback` is an function that gets frequently -/// called with [`TransferReport`] to report progress. +/// called with [`TransferReport`] to report progress. /// /// The accepted files must be sent in order, sequentially, back-to-back. pub async fn receive_files( offer: &FileOfferMsg, response: &FileResponseMsg, save_path: &Path, - reader: impl AsyncRead + Unpin, + reader: impl AsyncBufRead, progress_callback: impl FnMut(&TransferReport), ) -> Result<(), Error> { + let reader = pin!(reader); let files: Vec<(&FileMeta, u64)> = offer .files .iter() @@ -131,45 +130,89 @@ pub async fn receive_files( if start == 0 { // create a directory and TMP file if let Some(parent) = tmp_path.parent() { - tokio::fs::create_dir_all(parent).await?; + std::fs::create_dir_all(parent)?; } - let file = tokio::fs::File::create(&tmp_path).await?; - - // buffer the writer - let buf_size = std::cmp::min(FILE_BUFFER_SIZE, offer.len as usize); - let mut file = BufWriter::with_capacity(buf_size, file); - - // only take the length of the file from the reader - let mut reader = (&mut reader).take(offer.len); + let mut file = std::fs::File::create(&tmp_path)?; // copy from the reader into the file - tokio::io::copy(&mut reader, &mut file).await?; + net_to_file(&mut reader, &mut file, offer.len).await?; // resume interrupted download } else { // open the partially downloaded file in append mode - let file = tokio::fs::OpenOptions::new() - .append(true) - .open(&tmp_path) - .await?; - if file.metadata().await?.len() != start { + let mut file = std::fs::OpenOptions::new().append(true).open(&tmp_path)?; + if file.metadata()?.len() != start { return Err(Error::UnexpectedFileLen); } - // buffer the writer - let buf_size = std::cmp::min(FILE_BUFFER_SIZE, offer.len as usize - start as usize); - let mut file = BufWriter::with_capacity(buf_size, file); + net_to_file(&mut reader, &mut file, offer.len - start).await?; + } + reader.progress.processed_files += 1; + std::fs::rename(tmp_path, offer.get_unoccupied_save_path(save_path)?)?; + } - // only take the length of the remaining part of the file from the reader - let mut reader = (&mut reader).take(offer.len - start); + Ok(()) +} - // copy from the reader into the file - tokio::io::copy(&mut reader, &mut file).await?; +/// We're using this instead of [`tokio::io::copy()`]. +/// +/// [`tokio::io::copy()`] spawns a task on a thread +/// during every file read/write. This occurs 1000s of times, +/// introducing unnecessary overhead. +/// +/// This function is similar, but uses standard blocking +/// reads from `src`. This is made on the assumption that each read +/// won't block everything for too long, so this +/// function should still be cancellable. +async fn file_to_net( + mut src: impl std::io::Read, + mut dst: impl tokio::io::AsyncWrite + Unpin, + mut amt: u64, + buf: &mut [u8], +) -> std::io::Result<()> { + while amt > 0 { + let to_read = std::cmp::min(amt, buf.len() as u64) as usize; + let bytes_read = src.read(&mut buf[0..to_read])?; + if bytes_read == 0 { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "Peer interrupted transfer.", + )); } - reader.progress.processed_files += 1; - tokio::fs::rename(tmp_path, offer.get_unoccupied_save_path(save_path).await?).await?; + amt -= bytes_read as u64; + dst.write_all(&buf[0..to_read]).await?; } + Ok(()) +} +/// We're using this instead of [`tokio::io::copy_buf()`]. +/// +/// [`tokio::io::copy_buf()`] spawns a task on a thread +/// during every file read/write. This occurs 1000s of times, +/// introducing unnecessary overhead. +/// +/// This function is similar, but uses standard blocking +/// writes to `dst`. This is made on the assumption that each write +/// won't block everything for too long, so this +/// function should still be cancellable. +async fn net_to_file( + mut src: impl tokio::io::AsyncBufRead + Unpin, + mut dst: impl std::io::Write, + mut amt: u64, +) -> std::io::Result<()> { + while amt > 0 { + let buf = src.fill_buf().await?; + if buf.is_empty() { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "Peer interrupted transfer.", + )); + } + let to_write = std::cmp::min(amt, buf.len() as u64) as usize; + let written = dst.write(&buf[0..to_write])?; + src.consume(written); + amt -= written as u64; + } Ok(()) } diff --git a/gday_file_transfer/tests/test_file_meta.rs b/gday_file_transfer/tests/test_file_meta.rs index 5ef4d4a..6f42c9d 100644 --- a/gday_file_transfer/tests/test_file_meta.rs +++ b/gday_file_transfer/tests/test_file_meta.rs @@ -29,19 +29,18 @@ async fn test_file_meta_1() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz")); // unoccupied path should increment the appended number by one - let save_path = file_meta.get_unoccupied_save_path(dir_path).await.unwrap(); + let save_path = file_meta.get_unoccupied_save_path(dir_path).unwrap(); assert_eq!(save_path, dir_path.join("fol der/file (2).tar.gz")); // last occupied path let save_path = file_meta .get_last_occupied_save_path(dir_path) - .await .unwrap() .unwrap(); assert_eq!(save_path, dir_path.join("fol der/file (1).tar.gz")); // the file exists, but has the wrong size - let already_exists = file_meta.already_exists(dir_path).await.unwrap(); + let already_exists = file_meta.already_exists(dir_path).unwrap(); assert!(!already_exists); // the path should be suffixed with "part" and the length of the file @@ -49,7 +48,7 @@ async fn test_file_meta_1() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz.part5")); // a partial download does exist - let partial_exists = file_meta.partial_download_exists(dir_path).await.unwrap(); + let partial_exists = file_meta.partial_download_exists(dir_path).unwrap(); assert_eq!(partial_exists, Some(2)); } @@ -80,19 +79,18 @@ async fn test_file_meta_2() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz")); // unoccupied path should increment the appended number by one - let save_path = file_meta.get_unoccupied_save_path(dir_path).await.unwrap(); + let save_path = file_meta.get_unoccupied_save_path(dir_path).unwrap(); assert_eq!(save_path, dir_path.join("fol der/file (2).tar.gz")); // last occupied path let save_path = file_meta .get_last_occupied_save_path(dir_path) - .await .unwrap() .unwrap(); assert_eq!(save_path, dir_path.join("fol der/file (1).tar.gz")); // the file exists with the right size - let already_exists = file_meta.already_exists(dir_path).await.unwrap(); + let already_exists = file_meta.already_exists(dir_path).unwrap(); assert!(already_exists); // the path should be suffixed with "part" and the length of the file @@ -100,7 +98,7 @@ async fn test_file_meta_2() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz.part5")); // the partial download file has the wrong size suffix - let partial_exists = file_meta.partial_download_exists(dir_path).await.unwrap(); + let partial_exists = file_meta.partial_download_exists(dir_path).unwrap(); assert_eq!(partial_exists, None); } @@ -121,18 +119,15 @@ async fn test_file_meta_empty() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz")); // unoccupied path should increment the appended number by one - let save_path = file_meta.get_unoccupied_save_path(dir_path).await.unwrap(); + let save_path = file_meta.get_unoccupied_save_path(dir_path).unwrap(); assert_eq!(save_path, dir_path.join("fol der/file.tar.gz")); // last occupied path - let save_path = file_meta - .get_last_occupied_save_path(dir_path) - .await - .unwrap(); + let save_path = file_meta.get_last_occupied_save_path(dir_path).unwrap(); assert!(save_path.is_none()); // the file doesn't exist yet - let already_exists = file_meta.already_exists(dir_path).await.unwrap(); + let already_exists = file_meta.already_exists(dir_path).unwrap(); assert!(!already_exists); // the path should be suffixed with "part" and the length of the file @@ -140,6 +135,6 @@ async fn test_file_meta_empty() { assert_eq!(save_path, dir_path.join("fol der/file.tar.gz.part5")); // a partial download does not exist - let partial_exists = file_meta.partial_download_exists(dir_path).await.unwrap(); + let partial_exists = file_meta.partial_download_exists(dir_path).unwrap(); assert!(partial_exists.is_none()); } diff --git a/gday_file_transfer/tests/test_integration.rs b/gday_file_transfer/tests/test_integration.rs index a8ae507..574ecf1 100644 --- a/gday_file_transfer/tests/test_integration.rs +++ b/gday_file_transfer/tests/test_integration.rs @@ -62,26 +62,26 @@ async fn test_file_metas_errors() { // trying to get metadata about file that doesn't exist assert!(matches!( - get_file_metas(&[dir_path.join("dir/non-existent.txt")]).await, + get_file_metas(&[dir_path.join("dir/non-existent.txt")]), Err(gday_file_transfer::Error::IO(..)) )); // both paths end in the same name. // this would cause confusion with FileMetaLocal.short_path assert!(matches!( - get_file_metas(&[dir_path.join("file1"), dir_path.join("dir/file1")]).await, + get_file_metas(&[dir_path.join("file1"), dir_path.join("dir/file1")]), Err(gday_file_transfer::Error::PathsHaveSameName(..)) )); // one path is prefix of another. that's an error! assert!(matches!( - get_file_metas(&[dir_path.to_path_buf(), dir_path.join("dir")]).await, + get_file_metas(&[dir_path.to_path_buf(), dir_path.join("dir")]), Err(gday_file_transfer::Error::PathIsPrefix(..)) )); // one path is prefix of another. that's an error! assert!(matches!( - get_file_metas(&[dir_path.join("dir"), dir_path.to_path_buf()]).await, + get_file_metas(&[dir_path.join("dir"), dir_path.to_path_buf()]), Err(gday_file_transfer::Error::PathIsPrefix(..)) )); } @@ -93,9 +93,7 @@ async fn test_get_file_metas_1() { let test_dir = make_test_dir(); let dir_path = test_dir.path(); let dir_name = PathBuf::from(dir_path.file_name().unwrap()); - let mut result = gday_file_transfer::get_file_metas(&[dir_path.to_path_buf()]) - .await - .unwrap(); + let mut result = gday_file_transfer::get_file_metas(&[dir_path.to_path_buf()]).unwrap(); let mut expected = [ FileMetaLocal { @@ -166,7 +164,6 @@ async fn test_get_file_metas_2() { dir_path.join("dir/subdir2/file1"), dir_path.join("dir/subdir2/file2.txt"), ]) - .await .unwrap(); let mut expected = [ @@ -228,7 +225,7 @@ async fn file_transfer() { dir_a_path.join("file2.txt"), dir_a_path.join("dir/subdir1"), ]; - let file_metas = get_file_metas(&paths).await.unwrap(); + let file_metas = get_file_metas(&paths).unwrap(); let file_offer = FileOfferMsg::from(file_metas.clone()); // send offer, and read response @@ -266,9 +263,8 @@ async fn file_transfer() { // read the file offer message let file_offer: FileOfferMsg = read_from_async(&mut stream_b).await.unwrap(); - let response_msg = FileResponseMsg::accept_only_new_and_interrupted(&file_offer, dir_b.path()) - .await - .unwrap(); + let response_msg = + FileResponseMsg::accept_only_new_and_interrupted(&file_offer, dir_b.path()).unwrap(); assert_eq!(response_msg.get_num_not_rejected(), 3); assert_eq!(response_msg.get_num_partially_accepted(), 1); @@ -280,7 +276,7 @@ async fn file_transfer() { &file_offer, &response_msg, dir_b.path(), - &mut stream_b, + tokio::io::BufReader::new(stream_b), |_| {}, ) .await diff --git a/gday_file_transfer/tests/test_offer.rs b/gday_file_transfer/tests/test_offer.rs index e1b91bd..004c364 100644 --- a/gday_file_transfer/tests/test_offer.rs +++ b/gday_file_transfer/tests/test_offer.rs @@ -93,9 +93,7 @@ async fn test_file_offer() { assert_eq!(reject_all.get_num_not_rejected(), 0); assert_eq!(offer.get_transfer_size(&reject_all).unwrap(), 0); - let only_new = FileResponseMsg::accept_only_full_new_files(&offer, dir_path) - .await - .unwrap(); + let only_new = FileResponseMsg::accept_only_full_new_files(&offer, dir_path).unwrap(); assert_eq!( only_new.response, vec![None, Some(0), Some(0), Some(0), None, Some(0)] @@ -105,22 +103,8 @@ async fn test_file_offer() { assert_eq!(only_new.get_num_not_rejected(), 4); assert_eq!(offer.get_transfer_size(&only_new).unwrap(), 23); - let only_remaining = FileResponseMsg::accept_only_remaining_portions(&offer, dir_path) - .await - .unwrap(); - assert_eq!( - only_remaining.response, - vec![None, None, Some(4), None, Some(1), None] - ); - assert_eq!(only_remaining.get_num_fully_accepted(), 0); - assert_eq!(only_remaining.get_num_partially_accepted(), 2); - assert_eq!(only_remaining.get_num_not_rejected(), 2); - assert_eq!(offer.get_transfer_size(&only_remaining).unwrap(), 8); - let only_new_and_interrupted = - FileResponseMsg::accept_only_new_and_interrupted(&offer, dir_path) - .await - .unwrap(); + FileResponseMsg::accept_only_new_and_interrupted(&offer, dir_path).unwrap(); assert_eq!( only_new_and_interrupted.response, vec![None, Some(0), Some(4), Some(0), Some(1), Some(0)] diff --git a/gday_gui/Cargo.toml b/gday_gui/Cargo.toml deleted file mode 100644 index 9668d4d..0000000 --- a/gday_gui/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "gday_gui" -description = "GUI to securely send files (without a relay or port forwarding)." -homepage = "https://github.com/manforowicz/gday/tree/main/gday_gui" -categories = ["network-programming"] - -# Inherit these keys from workspace toml -authors.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -version.workspace = true - -[dependencies] -eframe = "0.28.1" -egui = { version = "0.28.1", features = ["accesskit"] } -env_logger = "0.11.5" -gday_encryption = { version = "0.2.1", path = "../gday_encryption" } -gday_file_transfer = { version = "0.2.1", path = "../gday_file_transfer" } -gday_hole_punch = { version = "0.2.1", path = "../gday_hole_punch" } -log = "0.4.22" -rfd = "0.14.1" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/gday_gui/README.md b/gday_gui/README.md deleted file mode 100644 index 30404ce..0000000 --- a/gday_gui/README.md +++ /dev/null @@ -1 +0,0 @@ -TODO \ No newline at end of file diff --git a/gday_gui/src/app.rs b/gday_gui/src/app.rs deleted file mode 100644 index 4c30100..0000000 --- a/gday_gui/src/app.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::PathBuf; - -use gday_hole_punch::server_connector; - -use crate::logic::connect_to_peer; - -/// TODO: COMMENT -#[derive(Debug, Default)] -pub struct GdayApp { - view: View, - paths_to_send: Vec, - send_code: Option, - - receive_code: String, - receive_path: Option, - custom_server_used: bool, - custom_server: ServerConfig, -} - -#[derive(Debug)] -enum View { - Home, - SendConfig, - SendConnecting, - SendTransfer, - ReceiveConfig, - ReceiveConnecting, - ReceiveTransfer, -} - -impl Default for View { - fn default() -> Self { - Self::Home - } -} - -#[derive(Debug)] -struct ServerConfig { - /// Use a custom gday server with this domain name. - server: String, - - /// Connect to a custom server port. - port: String, - - /// Use TLS - encrypted: bool, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - server: String::from(""), - port: server_connector::DEFAULT_PORT.to_string(), - encrypted: true, - } - } -} - -impl GdayApp { - pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { - Default::default() - } - - fn home(&mut self, ui: &mut egui::Ui) { - ui.heading("Gday"); - ui.label("A tool to directly send files."); - - ui.horizontal(|ui| { - if ui.button("Send files").clicked() { - self.view = View::SendConfig - } - if ui.button("Receive files").clicked() { - self.view = View::ReceiveConfig - } - }); - } - - fn send_config(&mut self, ui: &mut egui::Ui) {} - - fn send_connecting(&mut self, ui: &mut egui::Ui) {} - - fn send_transfer(&mut self, ui: &mut egui::Ui) {} - - fn receive_config(&mut self, ui: &mut egui::Ui) { - if ui.button("⏴").clicked() { - self.view = View::Home - } - - ui.label("To receive files, enter the code your mate gave you."); - - ui.text_edit_singleline(&mut self.receive_code); - - ui.label("Example: 1.1C30.C71E.A"); - - ui.checkbox(&mut self.custom_server_used, "Use a custom server"); - - if self.custom_server_used { - ui.group(|ui| { - ui.horizontal(|ui| { - ui.label("Server address (example: example.com)"); - ui.text_edit_singleline(&mut self.custom_server.server); - }); - ui.horizontal(|ui| { - ui.label("Server port (default: 2311)"); - ui.text_edit_singleline(&mut self.custom_server.port); - }); - ui.checkbox( - &mut self.custom_server.encrypted, - "Encrypt using TLS? (default: Yes)", - ); - }); - } - - ui.separator(); - - if ui.button("Receive").clicked() { - self.view = View::ReceiveConnecting; - - if self.custom_server_used { - // TODO - } - - todo!(); - } - } - - fn receive_connecting(&mut self, ui: &mut egui::Ui) {} - - fn receive_transfer(&mut self, ui: &mut egui::Ui) {} -} - -impl eframe::App for GdayApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| match self.view { - View::Home => self.home(ui), - View::SendConfig => self.send_config(ui), - View::SendConnecting => self.send_connecting(ui), - View::SendTransfer => self.send_transfer(ui), - View::ReceiveConfig => self.receive_config(ui), - View::ReceiveConnecting => self.receive_connecting(ui), - View::ReceiveTransfer => self.receive_transfer(ui), - }); - } -} diff --git a/gday_gui/src/logic.rs b/gday_gui/src/logic.rs deleted file mode 100644 index da708a8..0000000 --- a/gday_gui/src/logic.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::mpsc; - -use gday_encryption::EncryptedStream; -use gday_hole_punch::{server_connector, ContactSharer}; -use log::info; - -const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// Call [`ChannelLogger::init()`] to initialize [`log`] with this -/// logger. -/// -/// All messages logged with [`log`] will be sent to an [`std::sync::mpsc::Receiver`] -/// returned. -struct ChannelLogger { - tx: std::sync::mpsc::SyncSender<(log::Level, String)>, -} - -impl ChannelLogger { - /// All messages logged with [`log`] will be sent to the [`std::sync::mpsc::Receiver`] - /// returned. - /// - /// Panics if a [`log`] logger has already been set. - fn init() -> mpsc::Receiver<(log::Level, String)> { - let (tx, rx) = mpsc::sync_channel(10); - log::set_boxed_logger(Box::new(Self { tx })) - .expect("Another logger has already been initialized."); - rx - } -} - -impl log::Log for ChannelLogger { - fn enabled(&self, metadata: &log::Metadata) -> bool { - metadata.level() >= log::Level::Debug - } - - fn log(&self, record: &log::Record) { - if self.enabled(record.metadata()) { - let _ = self - .tx - .try_send((record.level(), record.args().to_string())); - } - } - - fn flush(&self) {} -} - -pub fn connect_to_peer( - peer_code: gday_hole_punch::PeerCode, - custom_server: Option<(String, u16, bool)>, - is_creator: bool, -) -> Result, Box> { - todo!() -} diff --git a/gday_gui/src/main.rs b/gday_gui/src/main.rs deleted file mode 100644 index d1e40cd..0000000 --- a/gday_gui/src/main.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! TODO: COMMENT -#![forbid(unsafe_code)] -#![warn(clippy::all)] - -mod app; -mod logic; - -fn main() -> eframe::Result { - env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). - - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([400.0, 300.0]) - .with_min_inner_size([200.0, 200.0]), - ..Default::default() - }; - eframe::run_native( - "Gday GUI", - native_options, - Box::new(|cc| Ok(Box::new(app::GdayApp::new(cc)))), - ) -} diff --git a/gday_hole_punch/Cargo.toml b/gday_hole_punch/Cargo.toml index 93e675c..1bbe661 100644 --- a/gday_hole_punch/Cargo.toml +++ b/gday_hole_punch/Cargo.toml @@ -14,18 +14,18 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -blake3 = "1.5.1" gday_contact_exchange_protocol = { version = "^0.2.1", path = "../gday_contact_exchange_protocol" } log = "0.4.22" -pin-project = "1.1.5" +pin-project = "1.1.7" rand = "0.8.5" -serde = "1.0.204" -socket2 = { version = "0.5.7", features = ["all"] } +serde = "1.0.215" +sha2 = "0.10.8" +socket2 = { version = "0.5.8" } spake2 = { version = "0.4.0", features = ["std"] } -thiserror = "1.0.61" -tokio = { version = "1.38.0", features = ["net", "rt", "time"] } +thiserror = "2.0.3" +tokio = { version = "1.41.1", features = ["net", "rt", "time"] } tokio-rustls = "0.26.0" -webpki-roots = "0.26.3" +webpki-roots = "0.26.7" [dev-dependencies] gday_server = { path = "../gday_server" } diff --git a/gday_hole_punch/README.md b/gday_hole_punch/README.md index 9147469..55cf4d7 100644 --- a/gday_hole_punch/README.md +++ b/gday_hole_punch/README.md @@ -1,5 +1,3 @@ -Note: this crate is still in early-development, so expect breaking changes. - # gday_hole_punch [![Crates.io Version](https://img.shields.io/crates/v/gday_hole_punch)](https://crates.io/crates/gday_hole_punch) [![docs.rs](https://img.shields.io/docsrs/gday_hole_punch)](https://docs.rs/gday_hole_punch/) diff --git a/gday_hole_punch/src/contact_sharer.rs b/gday_hole_punch/src/contact_sharer.rs index 0a0bbc4..3760976 100644 --- a/gday_hole_punch/src/contact_sharer.rs +++ b/gday_hole_punch/src/contact_sharer.rs @@ -2,116 +2,114 @@ use crate::{server_connector::ServerConnection, Error}; use gday_contact_exchange_protocol::{ read_from_async, write_to_async, ClientMsg, FullContact, ServerMsg, }; +use sha2::Digest; +use std::future::Future; -/// Used to exchange socket addresses with a peer via a Gday server. -#[derive(Debug)] -pub struct ContactSharer<'a> { - room_code: u64, +/// Shares contacts on `room_code` in the gday server +/// that `server_connection` is connected to. +/// +/// If `is_creator`, tries creating the room, otherwise tries joining it. +/// +/// Returns +/// - Your [`FullContact`], as +/// determined by the server +/// - A future that when awaited will evaluate to +/// the peer's [`FullContact`]. +pub async fn share_contacts<'a>( + server_connection: &'a mut ServerConnection, + room_code: &[u8], is_creator: bool, - connection: &'a mut ServerConnection, -} +) -> Result< + ( + FullContact, + impl Future> + 'a, + ), + Error, +> { + // Hash the `room_code` to get a 32-bit long code + let mut hasher = sha2::Sha256::new(); + hasher.update(room_code); + let room_code: [u8; 32] = hasher.finalize().into(); -impl<'a> ContactSharer<'a> { - /// Enters a new room with `room_code` in the gday server - /// that `server_connection` connects to. - /// - /// If `is_creator`, tries creating the room, otherwise tries joining it. - /// - /// Sends local socket addresses to the server. - /// - /// Panics if both `v4` and `v6` in `server_connection` are `None`. - /// - /// Returns - /// - The [`ContactSharer`]. - /// - The [`FullContact`] of this endpoint, as - /// determined by the server - pub async fn enter_room( - server_connection: &'a mut ServerConnection, - room_code: u64, - is_creator: bool, - ) -> Result<(Self, FullContact), Error> { - // set reuse addr and reuse port, so that these sockets - // can be later reused for hole punching - server_connection.configure()?; + // set reuse addr and reuse port, so that these sockets + // can be later reused for hole punching + server_connection.enable_reuse()?; - if is_creator { - // choose a stream to talk to the server with - let messenger = &mut server_connection.streams()[0]; + if is_creator { + // choose a stream to talk to the server with + let messenger = &mut server_connection.streams()[0]; - // try creating a room in the server - write_to_async(ClientMsg::CreateRoom { room_code }, messenger).await?; - let response: ServerMsg = read_from_async(messenger).await?; - if response != ServerMsg::RoomCreated { - return Err(Error::UnexpectedServerReply(response)); - } + // try creating a room in the server + write_to_async(ClientMsg::CreateRoom { room_code }, messenger).await?; + let response: ServerMsg = read_from_async(messenger).await?; + if response != ServerMsg::RoomCreated { + return Err(Error::UnexpectedServerReply(response)); } + } - let mut this = Self { - room_code, - is_creator, - connection: server_connection, - }; - - // send personal socket addresses to the server - let contact = this.share_contact().await?; + // send personal socket addresses to the server + let my_contact = share_contact(server_connection, room_code, is_creator).await?; - Ok((this, contact)) - } + Ok((my_contact, get_peer_contact(server_connection))) +} - /// Private helper function. - /// Sends personal contact information the the server, and - /// returns it's response. - async fn share_contact(&mut self) -> Result { - let local_contact = self.connection.local_contact()?; +/// Private helper function. +/// Sends personal contact information the the server, and +/// returns its response. +async fn share_contact( + connection: &mut ServerConnection, + room_code: [u8; 32], + is_creator: bool, +) -> Result { + let local_contact = connection.local_contact()?; - // Get all connections to the server - let mut streams = self.connection.streams(); + // Get all connections to the server + let mut streams = connection.streams(); - // For each connection, have the server record its - // public address - for stream in &mut streams { - let msg = ClientMsg::RecordPublicAddr { - room_code: self.room_code, - is_creator: self.is_creator, - }; - write_to_async(msg, stream).await?; - let reply: ServerMsg = read_from_async(stream).await?; - if reply != ServerMsg::ReceivedAddr { - return Err(Error::UnexpectedServerReply(reply)); - } + // For each connection, have the server record its + // public address + for stream in &mut streams { + let msg = ClientMsg::RecordPublicAddr { + room_code, + is_creator, + }; + write_to_async(msg, stream).await?; + let reply: ServerMsg = read_from_async(stream).await?; + if reply != ServerMsg::ReceivedAddr { + return Err(Error::UnexpectedServerReply(reply)); } + } - // tell the server that we're done - // sending socket addresses - let msg = ClientMsg::ReadyToShare { - room_code: self.room_code, - is_creator: self.is_creator, - local_contact, - }; - write_to_async(msg, streams[0]).await?; + // tell the server that we're done + // sending socket addresses + let msg = ClientMsg::ReadyToShare { + room_code, + is_creator, + local_contact, + }; + write_to_async(msg, streams[0]).await?; - // Get our local contact info from the server - let reply: ServerMsg = read_from_async(streams[0]).await?; - let ServerMsg::ClientContact(my_contact) = reply else { - return Err(Error::UnexpectedServerReply(reply)); - }; + // Get our local contact info from the server + let reply: ServerMsg = read_from_async(streams[0]).await?; + let ServerMsg::ClientContact(my_contact) = reply else { + return Err(Error::UnexpectedServerReply(reply)); + }; - Ok(my_contact) - } + Ok(my_contact) +} - /// Blocks until the Gday server sends the contact information the - /// other peer submitted. Returns the peer's [`FullContact`], as - /// determined by the server - pub async fn get_peer_contact(self) -> Result { - // This is the same stream we used to send DoneSending, - // so the server should respond on it, - // once the other peer is also done. - let stream = &mut self.connection.streams()[0]; - let reply: ServerMsg = read_from_async(stream).await?; - let ServerMsg::PeerContact(peer) = reply else { - return Err(Error::UnexpectedServerReply(reply)); - }; +/// Blocks until the Gday server sends the contact information the +/// other peer submitted. Returns the peer's [`FullContact`], as +/// determined by the server. +async fn get_peer_contact(connection: &mut ServerConnection) -> Result { + // This is the same stream we used to send DoneSending, + // so the server should respond on it, + // once the other peer is also done. + let stream = &mut connection.streams()[0]; + let reply: ServerMsg = read_from_async(stream).await?; + let ServerMsg::PeerContact(peer) = reply else { + return Err(Error::UnexpectedServerReply(reply)); + }; - Ok(peer) - } + Ok(peer) } diff --git a/gday_hole_punch/src/hole_puncher.rs b/gday_hole_punch/src/hole_puncher.rs index 1f60a2a..995d1b0 100644 --- a/gday_hole_punch/src/hole_puncher.rs +++ b/gday_hole_punch/src/hole_puncher.rs @@ -1,6 +1,7 @@ use crate::Error; use gday_contact_exchange_protocol::{Contact, FullContact}; use log::{debug, trace}; +use sha2::Digest; use socket2::{SockRef, TcpKeepalive}; use spake2::{Ed25519Group, Identity, Password, Spake2}; use std::{net::SocketAddr, time::Duration}; @@ -15,22 +16,23 @@ type PeerConnection = (tokio::net::TcpStream, [u8; 32]); /// How often a connection attempt is made during hole punching. const RETRY_INTERVAL: Duration = Duration::from_millis(200); -/// Tries to establish a TCP connection with the other peer by using +/// Tries to connect to the other peer using /// [TCP hole punching](https://en.wikipedia.org/wiki/TCP_hole_punching). /// +/// Call this function _after_ you've gotten the peer's contacts with [`crate::share_contacts()`]. +/// +/// Arguments: /// - `local_contact` should be the `local` field of your [`FullContact`] -/// that [`crate::ContactSharer`] returned when you created or joined a room. -/// Panics if both `v4` and `v6` are `None`. -/// - `peer_contact` should be the [`FullContact`] returned by [`crate::ContactSharer::get_peer_contact()`]. -/// - `shared_secret` should be a randomized secret that both peers know. -/// It will be used to verify the peer's identity, and derive a stronger shared key -/// using [SPAKE2](https://docs.rs/spake2/). -/// - Gives up after `timeout` time, and returns [`Error::HolePunchTimeout`]. +/// that [`crate::share_contacts()`] returned. +/// - `peer_contact` should be the peer's [`FullContact`] returned by the future from [`crate::share_contacts()`]. +/// - `shared_secret` should be a secret that both peers know. +/// It will be used to verify the peer's identity, and derive a stronger shared key +/// using [SPAKE2](https://docs.rs/spake2/). /// /// Returns: /// - An authenticated [`std::net::TcpStream`] connected to the other peer. /// - A `[u8; 32]` shared key that was derived using -/// [SPAKE2](https://docs.rs/spake2/) and the weaker `shared_secret`. +/// [SPAKE2](https://docs.rs/spake2/) from the weaker `shared_secret`. pub async fn try_connect_to_peer( local_contact: Contact, peer_contact: FullContact, @@ -88,14 +90,11 @@ pub async fn try_connect_to_peer( Some(Err(..)) => panic!("Tokio join error."), // No tasks were spawned - None => panic!( - "local_contact passed to try_connect_to_peer() \ - had None for both v4 and v6" - ), + None => Err(Error::LocalContactEmpty), } } -/// Tries to TCP connect to `local` to `peer`, +/// Tries to TCP connect from `local` to `peer`, /// and authenticate using `shared_secret`. async fn try_connect>( local: T, @@ -131,7 +130,7 @@ async fn try_accept( trace!("Waiting to accept connections on {local}."); let local_socket = get_local_socket(local)?; - let listener = local_socket.listen(1024)?; + let listener = local_socket.listen(128)?; let (stream, addr) = loop { if let Ok(ok) = listener.accept().await { @@ -185,11 +184,11 @@ async fn verify_peer( stream.read_exact(&mut peer_challenge).await?; // reply with the solution hash to the peer's challenge - let mut hasher = blake3::Hasher::new(); - hasher.update(&shared_key); - hasher.update(&peer_challenge); + let mut hasher = sha2::Sha256::new(); + hasher.update(shared_key); + hasher.update(peer_challenge); let my_hash = hasher.finalize(); - stream.write_all(my_hash.as_bytes()).await?; + stream.write_all(&my_hash).await?; stream.flush().await?; // receive peer's hash to my challenge @@ -197,13 +196,13 @@ async fn verify_peer( stream.read_exact(&mut peer_hash).await?; // confirm peer's hash to my challenge - let mut hasher = blake3::Hasher::new(); - hasher.update(&shared_key); - hasher.update(&my_challenge); + let mut hasher = sha2::Sha256::new(); + hasher.update(shared_key); + hasher.update(my_challenge); let expected = hasher.finalize(); // Peer authentication failed - if expected != peer_hash { + if expected != peer_hash.into() { return Err(Error::PeerAuthenticationFailed); } diff --git a/gday_hole_punch/src/lib.rs b/gday_hole_punch/src/lib.rs index 8da024f..9879511 100644 --- a/gday_hole_punch/src/lib.rs +++ b/gday_hole_punch/src/lib.rs @@ -1,6 +1,4 @@ -//! Note: this crate is still in early-development, so expect breaking changes. -//! -//! Lets 2 peers behind [NAT (network address translation)](https://en.wikipedia.org/wiki/Network_address_translation) +//! Lets 2 peers, possibly behind [NAT (network address translation)](https://en.wikipedia.org/wiki/Network_address_translation), //! try to establish a direct authenticated TCP connection. //! Uses [TCP hole punching](https://en.wikipedia.org/wiki/TCP_hole_punching) //! and a helper [gday_server](https://crates.io/crates/gday_server) to do this. @@ -9,39 +7,41 @@ //! # Example //! ```no_run //! # use gday_hole_punch::server_connector; -//! # use gday_hole_punch::ContactSharer; //! # use gday_hole_punch::try_connect_to_peer; //! # use gday_hole_punch::PeerCode; +//! # use gday_hole_punch::share_contacts; //! # use std::str::FromStr; //! # //! # let rt = tokio::runtime::Builder::new_current_thread().build().unwrap(); //! # rt.block_on( async { -//! let servers = server_connector::DEFAULT_SERVERS; //! let timeout = std::time::Duration::from_secs(5); -//! let room_code = 123; -//! let shared_secret = 456; //! //! //////// Peer 1 //////// //! //! // Connect to a random server in the default server list //! let (mut server_connection, server_id) = server_connector::connect_to_random_server( -//! servers, +//! server_connector::DEFAULT_SERVERS, //! timeout, //! ).await?; //! -//! // PeerCode useful for giving rendezvous info to peer -//! let peer_code = PeerCode { server_id, room_code, shared_secret }; -//! let code_to_share = peer_code.to_string(); +//! // PeerCode useful for giving rendezvous info to peer, +//! // over an existing channel like email. +//! let peer_code = PeerCode { +//! server_id, +//! room_code: "roomcode".to_string(), +//! shared_secret: "shared_secret".to_string() +//! }; +//! let code_to_share = String::try_from(&peer_code)?; //! //! // Create a room in the server, and get my contact from it -//! let (contact_sharer, my_contact) = ContactSharer::enter_room( +//! let (my_contact, peer_contact_future) = share_contacts( //! &mut server_connection, -//! room_code, +//! peer_code.room_code.as_bytes(), //! true, //! ).await?; //! //! // Wait for the server to send the peer's contact -//! let peer_contact = contact_sharer.get_peer_contact().await?; +//! let peer_contact = peer_contact_future.await?; //! //! // Use TCP hole-punching to connect to the peer, //! // verify their identity with the shared_secret, @@ -49,33 +49,35 @@ //! let (tcp_stream, strong_key) = try_connect_to_peer( //! my_contact.local, //! peer_contact, -//! &shared_secret.to_be_bytes(), +//! peer_code.shared_secret.as_bytes(), //! ).await?; //! //! //////// Peer 2 (on a different computer) //////// //! +//! // Get the peer_code that Peer 1 sent, for example +//! // over email. //! let peer_code = PeerCode::from_str(&code_to_share)?; //! //! // Connect to the same server as Peer 1 //! let mut server_connection = server_connector::connect_to_server_id( -//! servers, +//! server_connector::DEFAULT_SERVERS, //! peer_code.server_id, //! timeout, //! ).await?; //! //! // Join the same room in the server, and get my local contact -//! let (contact_sharer, my_contact) = ContactSharer::enter_room( +//! let (my_contact, peer_contact_future) = share_contacts( //! &mut server_connection, -//! peer_code.room_code, +//! peer_code.room_code.as_bytes(), //! false, //! ).await?; //! -//! let peer_contact = contact_sharer.get_peer_contact().await?; +//! let peer_contact = peer_contact_future.await?; //! //! let (tcp_stream, strong_key) = try_connect_to_peer( //! my_contact.local, //! peer_contact, -//! &peer_code.shared_secret.to_be_bytes(), +//! peer_code.shared_secret.as_bytes(), //! ).await?; //! //! # Ok::<(), Box>(()) @@ -90,12 +92,11 @@ mod hole_puncher; mod peer_code; pub mod server_connector; -pub use contact_sharer::ContactSharer; +pub use contact_sharer::share_contacts; +use gday_contact_exchange_protocol::ServerMsg; pub use hole_puncher::try_connect_to_peer; pub use peer_code::PeerCode; -use gday_contact_exchange_protocol::ServerMsg; - /// `gday_hole_punch` error #[derive(thiserror::Error, Debug)] #[non_exhaustive] @@ -112,34 +113,40 @@ pub enum Error { #[error("Unexpected reply from server: {0}")] UnexpectedServerReply(ServerMsg), + /// Both `v4` and `v6` fields of the given local Contact were None. + #[error("Both `v4` and `v6` fields of the given local Contact were None.")] + LocalContactEmpty, + + /// Both `v4` and `v6` fields of the given ServerConnection were None. + #[error("Both `v4` and `v6` fields of a ServerConnection were None.")] + ServerConnectionEmpty, + + /// ServerConnection has mismatched streams. Either v4 had an IPv6 stream, or vice-versa. + #[error( + "ServerConnection has mismatched streams. Either v4 had an IPv6 stream, or vice-versa." + )] + ServerConnectionMismatch, + /// Connected to peer, but key exchange failed #[error( "Connected to peer, but key exchange failed: {0}. \ - Ensure your peer has the same shared secret." + Check for typos in your peer code and try again." )] SpakeFailed(#[from] spake2::Error), - /// Connected to peer, but couldn't verify their shared secret. + /// Connected to peer, but they had a different shared secret. #[error( - "Connected to peer, but couldn't verify their shared secret. \ - Ensure your peer has the same shared secret." + "Connected to peer, but they had a different shared secret. \ + Check for typos in your peer code and try again." )] PeerAuthenticationFailed, - /// Couldn't resolve contact exchange server domain name - #[error("Couldn't resolve contact exchange server domain name '{0}'")] - CouldntResolveServer(String), - - /// TLS error with contact exchange server - #[error("TLS error with contact exchange server: {0}")] - Rustls(#[from] tokio_rustls::rustls::Error), - - /// No contact exchange server with this ID found in the given list - #[error("No contact exchange server with ID '{0}' exists in this server list.")] + /// No contact exchange server with this ID found in server list + #[error("No contact exchange server with ID '{0}' exists in server list.")] ServerIDNotFound(u64), /// Couldn't connect to any of the contact exchange servers listed - #[error("Couldn't connect to any of the contact exchange servers listed.")] + #[error("Couldn't connect to any of the contact exchange servers in the list.")] CouldntConnectToServers, /// Invalid server DNS name for TLS @@ -151,20 +158,25 @@ pub enum Error { #[error( "Timed out while trying to connect to peer, likely due to an uncooperative \ NAT (network address translator). \ - Try from a different network, enable IPv6, or switch to a tool that transfers \ - files over a relay to circumvent NATs, such as magic-wormhole." + Enable IPv6 or try from a different network. \ + Or use a tool such as magic-wormhole that transfers \ + over a relay to evade NATs." )] HolePunchTimeout, - /// Couldn't parse [`PeerCode`] - #[error("Couldn't parse your code: {0}. Check it for typos!")] - CouldntParsePeerCode(#[from] std::num::ParseIntError), + /// Couldn't parse server ID of [`PeerCode`] + #[error("Couldn't parse the server ID in your code: {0}. Check it for typos!")] + CouldntParseServerID(#[from] std::num::ParseIntError), - /// Incorrect checksum when parsing [`PeerCode`] - #[error("Your code's checksum (last digit) is incorrect. Check it for typos!")] - IncorrectChecksumPeerCode, + /// The room_code or shared_secret of the peer code contained a period. + /// Periods aren't allowed because they're used as delimeters. + #[error( + "The room_code or shared_secret of the peer code contained a period. \ + Periods aren't allowed because they're used as delimeters." + )] + PeerCodeContainedPeriod, - /// Couldn't parse [`PeerCode`] + /// Wrong number of settings in [`PeerCode`]. #[error("Wrong number of segments in your code. Check it for typos!")] WrongNumberOfSegmentsPeerCode, } diff --git a/gday_hole_punch/src/peer_code.rs b/gday_hole_punch/src/peer_code.rs index 6b9dd99..2309032 100644 --- a/gday_hole_punch/src/peer_code.rs +++ b/gday_hole_punch/src/peer_code.rs @@ -1,56 +1,79 @@ use crate::Error; +use rand::Rng; use serde::{Deserialize, Serialize}; -use std::fmt::Display; use std::str::FromStr; /// Info that 2 peers must share before they can exchange contacts. /// -/// Use [`PeerCode::fmt()`] and [`PeerCode::try_from()`] +/// Use [`String::try_from()`] and [`PeerCode::from_str()`] /// to convert to and from a short human-readable code. -#[derive(PartialEq, Eq, Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct PeerCode { /// The ID of the gday contact exchange server /// that the peers will connect to. - /// /// Use `0` to indicate a custom server. - /// Pass to [`crate::server_connector::connect_to_server_id()`] - /// to connect to the server. + /// + /// Usually the first peer will get this value from [`crate::server_connector::connect_to_random_server()`] + /// and the other peer will pass this value to [`crate::server_connector::connect_to_server_id()`] pub server_id: u64, /// The room code within the server. /// - /// Pass to [`crate::ContactSharer`] to specify - /// which room to exchange contacts in. - pub room_code: u64, + /// Usually the first peer will randomize this value. + /// + /// Both peers pass this value to [`crate::share_contacts()`] + /// to specify which room to exchange contacts in. + pub room_code: String, /// The shared secret that the peers will use to confirm - /// each other's identity. + /// each other's identity, and derive a stronger key from. /// - /// Pass to [`crate::try_connect_to_peer()`] to authenticate - /// the other peer when hole-punching. - pub shared_secret: u64, + /// Usually the first peer will randomize this value. + /// + /// Both peers pass this value to [`crate::try_connect_to_peer()`] + /// to authenticate the other peer when hole-punching. + pub shared_secret: String, } impl PeerCode { - /// Calculates a simple hash of the three fields, mod 17 - /// `(self.server_id * 3 + self.room_code * 2 + self.shared_secret) % 17` - fn get_checksum(&self) -> u64 { - ((self.server_id % 17) * 3 + (self.room_code % 17) * 2 + (self.shared_secret % 17)) % 17 + /// Returns a [`PeerCode`] with this `server_id` + /// and a random `room_code` and `shared_secret`, + /// both of length `len` characters, + /// built from the alphabet `2345689abcdefghjkmnpqrstvwxyz`. + pub fn random(server_id: u64, len: usize) -> Self { + const ALPHABET: &[u8] = b"2345689abcdefghjkmnpqrstvwxyz"; + + let mut rng = rand::thread_rng(); + let range = rand::distributions::Uniform::new(0, ALPHABET.len()); + + let room_code: String = (0..len) + .map(|_| ALPHABET[rng.sample(range)] as char) + .collect(); + + let shared_secret: String = (0..len) + .map(|_| ALPHABET[rng.sample(range)] as char) + .collect(); + + Self { + server_id, + room_code, + shared_secret, + } } } -impl Display for PeerCode { - /// Display as `"server_id.room_code.shared_secret.checksum"` - /// where each field is in hexadecimal form. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{:X}.{:X}.{:X}.{:X}", - self.server_id, - self.room_code, - self.shared_secret, - self.get_checksum() - ) +impl TryFrom<&PeerCode> for String { + type Error = Error; + + fn try_from(value: &PeerCode) -> Result { + if value.room_code.contains('.') || value.shared_secret.contains('.') { + Err(Error::PeerCodeContainedPeriod) + } else { + Ok(format!( + "{}.{}.{}", + value.server_id, value.room_code, value.shared_secret, + )) + } } } @@ -58,54 +81,28 @@ impl std::str::FromStr for PeerCode { type Err = Error; /// Converts `str` of hexadecimal form: - /// `"server_id.room_code.shared_secret.checksum"` into a [`PeerCode`]. - /// - /// The checksum is optional. + /// `"server_id.room_code.shared_secret"` into a [`PeerCode`]. fn from_str(str: &str) -> Result { // split `str` into period-separated substrings - let mut substrings = str.trim().split('.'); - - // decode each segment independently - let mut segments = [0, 0, 0]; - for segment in &mut segments { - let Some(substring) = substrings.next() else { - // return error if less than 4 substrings - return Err(Error::WrongNumberOfSegmentsPeerCode); - }; - *segment = u64::from_str_radix(substring, 16)?; - } - - // set fields to segments - let peer_code = PeerCode { - server_id: segments[0], - room_code: segments[1], - shared_secret: segments[2], - }; - - // check checksum - if let Some(substring) = substrings.next() { - let checksum = u64::from_str_radix(substring, 16)?; - // verify checksum - if checksum != peer_code.get_checksum() { - return Err(Error::IncorrectChecksumPeerCode); - } - } + let substrings: Vec<&str> = str.split('.').collect(); - // return error if there are too many substrings - if substrings.next().is_some() { + if substrings.len() != 3 { return Err(Error::WrongNumberOfSegmentsPeerCode); } - Ok(peer_code) + // set fields to segments + Ok(PeerCode { + server_id: substrings[0].parse()?, + room_code: substrings[1].to_owned(), + shared_secret: substrings[2].to_owned(), + }) } } impl TryFrom<&str> for PeerCode { type Error = Error; /// Converts `str` of hexadecimal form: - /// `"server_id.room_code.shared_secret.checksum"` into a [`PeerCode`]. - /// - /// The checksum is optional. + /// `"server_id.room_code.shared_secret"` into a [`PeerCode`]. fn try_from(str: &str) -> Result { Self::from_str(str) } @@ -122,26 +119,26 @@ mod tests { fn test_encode() { let peer_code = PeerCode { server_id: 27, - room_code: 314, - shared_secret: 15, + room_code: " hel lo123".to_string(), + shared_secret: "coded ".to_string(), }; - let message = peer_code.to_string(); - assert_eq!(message, "1B.13A.F.A"); + let message = String::try_from(&peer_code).unwrap(); + assert_eq!(message, "27. hel lo123.coded "); } #[test] fn test_decode() { // some uppercase, some lowercase, and spacing - let message = " 1b.13A.f.a "; + let message = "83221.room codefoo.secret123 "; let received1 = PeerCode::from_str(message).unwrap(); let received2: PeerCode = message.parse().unwrap(); let received3 = PeerCode::try_from(message).unwrap(); let expected = PeerCode { - server_id: 27, - room_code: 314, - shared_secret: 15, + server_id: 83221, + room_code: "room codefoo".to_string(), + shared_secret: "secret123 ".to_string(), }; assert_eq!(received1, expected); @@ -150,51 +147,48 @@ mod tests { } #[test] - fn checksum_test() { - // checksum omitted - let received = PeerCode::from_str(" 1b.13A.f ").unwrap(); - let expected = PeerCode { - server_id: 27, - room_code: 314, - shared_secret: 15, - }; - assert_eq!(received, expected); - - // checksum incorrect - let received = PeerCode::from_str(" 1c.13A.f.3 "); - assert!(matches!(received, Err(Error::IncorrectChecksumPeerCode))); - } - - #[test] - fn invalid_encodings() { + fn invalid_decodes() { // invalid character q - let received = PeerCode::from_str(" 21.q.3 "); - assert!(matches!(received, Err(Error::CouldntParsePeerCode(..)))); + let received = PeerCode::from_str("asd.q.3"); + assert!(matches!(received, Err(Error::CouldntParseServerID(..)))); // too many segments - let received = PeerCode::from_str(" 1b.13A.f.a.4 "); + let received = PeerCode::from_str("1.13A.f.a"); assert!(matches!( received, Err(Error::WrongNumberOfSegmentsPeerCode) )); // too little segments - let received = PeerCode::from_str(" 1b.13A "); + let received = PeerCode::from_str("1.13A"); assert!(matches!( received, Err(Error::WrongNumberOfSegmentsPeerCode) )); } + #[test] + fn invalid_encodes() { + let peer_code = PeerCode { + server_id: 0, + room_code: "hi.there".to_string(), + shared_secret: "what.".to_string(), + }; + + let result = String::try_from(&peer_code); + + assert!(matches!(result, Err(Error::PeerCodeContainedPeriod))) + } + #[test] fn test_zeros() { let peer_code = PeerCode { - room_code: 0, server_id: 0, - shared_secret: 0, + room_code: "".to_string(), + shared_secret: "".to_string(), }; - let str: String = peer_code.to_string(); + let str = String::try_from(&peer_code).unwrap(); let received = PeerCode::from_str(&str).unwrap(); assert_eq!(peer_code, received); } @@ -202,12 +196,12 @@ mod tests { #[test] fn test_large() { let peer_code = PeerCode { - room_code: u64::MAX, server_id: u64::MAX, - shared_secret: u64::MAX, + room_code: " j fisd;af ljks da; ".to_string(), + shared_secret: "r f98032 fsf 02f a".to_string(), }; - let str: String = peer_code.to_string(); + let str = String::try_from(&peer_code).unwrap(); let received = PeerCode::from_str(&str).unwrap(); assert_eq!(peer_code, received); } diff --git a/gday_hole_punch/src/server_connector.rs b/gday_hole_punch/src/server_connector.rs index 1748029..10ddeb2 100644 --- a/gday_hole_punch/src/server_connector.rs +++ b/gday_hole_punch/src/server_connector.rs @@ -1,13 +1,14 @@ //! Functions for connecting to a Gday server. use crate::Error; use gday_contact_exchange_protocol::Contact; -use log::{debug, warn}; +use log::{debug, error, warn}; use rand::seq::SliceRandom; use socket2::SockRef; use std::fmt::Debug; use std::io::ErrorKind; use std::net::SocketAddr::{V4, V6}; use std::{net::SocketAddr, sync::Arc, time::Duration}; +use tokio::io::AsyncWriteExt; use tokio::net::{TcpStream, ToSocketAddrs}; pub use gday_contact_exchange_protocol::DEFAULT_PORT; @@ -23,17 +24,19 @@ pub const DEFAULT_SERVERS: &[ServerInfo] = &[ServerInfo { prefer: true, }]; -/// Information about a single Gday server. +/// Information about a single public Gday server +/// that serves over TLS on [`DEFAULT_PORT`] /// -/// A public gday server should only serve -/// encrypted TLS and listen on [`DEFAULT_PORT`]. +/// See [`DEFAULT_SERVERS`] for a list +/// of [`ServerInfo`] #[derive(Debug, Clone)] pub struct ServerInfo { /// The DNS name of the server. pub domain_name: &'static str, /// The unique ID of the server. /// - /// Helpful when telling the other peer which server to connect to. + /// Used in [`crate::PeerCode`] when telling + /// the other peer which server to connect to. /// Should NOT be zero, since peers can use that value to represent /// a custom server. pub id: u64, @@ -66,7 +69,7 @@ impl ServerStream { } } - /// Enables SO_REUSEADDR and SO_REUSEPORT + /// Enables SO_REUSEADDR and SO_REUSEPORT (if applicable) /// so that this socket can be reused for /// hole punching. fn enable_reuse(&self) { @@ -130,31 +133,31 @@ impl tokio::io::AsyncWrite for ServerStream { } } -/// Can hold both an IPv4 and IPv6 [`ServerStream`] to a Gday server. +/// Connection to a Gday server. /// -/// Methods may panic if `v4` and `v6` don't actually correspond to IPv4 and IPv6 streams. +/// Can hold an IPv4 and/or IPv6 [`ServerStream`] to a Gday server. #[derive(Debug)] pub struct ServerConnection { pub v4: Option, pub v6: Option, } -// some private helper functions used by ContactSharer +// some private helper functions used by contact_sharer impl ServerConnection { /// Enables `SO_REUSEADDR` and `SO_REUSEPORT` so that the ports of /// these sockets can be reused for hole punching. /// /// Returns an error if both streams are `None`. /// Returns an error if a `v4` is passed where `v6` should, or vice versa. - pub(super) fn configure(&self) -> Result<(), Error> { + pub(super) fn enable_reuse(&self) -> Result<(), Error> { if self.v4.is_none() && self.v6.is_none() { - panic!("ServerConnection had None for both v4 and v6 streams."); + return Err(Error::ServerConnectionEmpty); } if let Some(stream) = &self.v4 { let addr = stream.local_addr()?; if !matches!(addr, V4(_)) { - panic!("ServerConnection had IPv6 stream where IPv4 stream was expected."); + return Err(Error::ServerConnectionMismatch); }; stream.enable_reuse(); } @@ -162,7 +165,7 @@ impl ServerConnection { if let Some(stream) = &self.v6 { let addr = stream.local_addr()?; if !matches!(addr, V6(_)) { - panic!("ServerConnection had IPv4 stream where IPv6 stream was expected."); + return Err(Error::ServerConnectionMismatch); }; stream.enable_reuse(); } @@ -172,7 +175,7 @@ impl ServerConnection { /// Returns a [`Vec`] of all the [`ServerStream`]s in this connection. /// Will return `v6` followed by `v4` pub(super) fn streams(&mut self) -> Vec<&mut ServerStream> { - let mut streams = Vec::new(); + let mut streams = Vec::with_capacity(2); if let Some(stream) = &mut self.v6 { streams.push(stream); @@ -186,14 +189,14 @@ impl ServerConnection { } /// Returns the local [`Contact`] of this server stream. - pub(super) fn local_contact(&self) -> std::io::Result { + pub fn local_contact(&self) -> Result { let mut contact = Contact { v4: None, v6: None }; if let Some(stream) = &self.v4 { if let SocketAddr::V4(addr_v4) = stream.local_addr()? { contact.v4 = Some(addr_v4); } else { - panic!("ServerConnection had IPv6 stream where IPv4 stream was expected."); + return Err(Error::ServerConnectionMismatch); } } @@ -201,15 +204,29 @@ impl ServerConnection { if let SocketAddr::V6(addr_v6) = stream.local_addr()? { contact.v6 = Some(addr_v6); } else { - panic!("ServerConnection had IPv4 stream where IPv6 stream was expected."); + return Err(Error::ServerConnectionMismatch); } } Ok(contact) } + + /// Calls shutdown on the underlying streams to gracefully + /// close the connection. + pub async fn shutdown(&mut self) -> std::io::Result<()> { + if let Some(stream) = &mut self.v4 { + stream.shutdown().await?; + } + if let Some(stream) = &mut self.v6 { + stream.shutdown().await?; + } + Ok(()) + } } -/// In random order, sequentially try connecting to the given `servers`. +/// In random order, sequentially try connecting to `servers`. +/// +/// You may pass [`DEFAULT_SERVERS`] as `servers`. /// /// Ignores servers that don't have `prefer == true`. /// Connects to port [`DEFAULT_PORT`] via TLS. @@ -224,13 +241,21 @@ pub async fn connect_to_random_server( servers: &[ServerInfo], timeout: Duration, ) -> Result<(ServerConnection, u64), Error> { + // Filter out non-preferred servers let preferred: Vec<&ServerInfo> = servers.iter().filter(|s| s.prefer).collect(); + + // Get the domain names of the preferred servers let preferred_names: Vec<&str> = preferred.iter().map(|s| s.domain_name).collect(); + + // Try connecting to the them in a random order let (conn, i) = connect_to_random_domain_name(&preferred_names, timeout).await?; Ok((conn, preferred[i].id)) } -/// Try connecting to the server with this `server_id` and returning a [`ServerConnection`]. +/// Tries connecting to the server with this `server_id` +/// +/// You may pass [`DEFAULT_SERVERS`] as `servers`. +/// /// Connects to port [`DEFAULT_PORT`] via TLS. /// Gives up after `timeout` time. /// @@ -248,6 +273,7 @@ pub async fn connect_to_server_id( } /// In random order, sequentially tries connecting to the given `domain_names`. +/// /// Connects to port [`DEFAULT_PORT`] via TLS. /// Tries the next connection after `timeout` time. /// @@ -267,16 +293,19 @@ pub async fn connect_to_random_domain_name( for i in indices { let server = domain_names[i]; - let streams = match connect_tls(server.to_string(), DEFAULT_PORT, timeout).await { - Ok(streams) => streams, + match connect_tls(server.to_string(), DEFAULT_PORT, timeout).await { + Ok(streams) => return Ok((streams, i)), Err(err) => { recent_error = err; warn!("Couldn't connect to \"{server}:{DEFAULT_PORT}\": {recent_error}"); continue; } }; - return Ok((streams, i)); } + error!( + "Couldn't connect to any of the {} contact exchange servers.", + domain_names.len() + ); Err(recent_error) } @@ -284,7 +313,7 @@ pub async fn connect_to_random_domain_name( /// /// - Returns a [`ServerConnection`] with all the successful TLS streams. /// - Gives up connecting to each TCP address after `timeout` time. -/// - Returns an error if every attempt failed. +/// - Returns an error if couldn't connect to any of IPv4 and IPv6. /// - Returns an error for any issues with TLS. pub async fn connect_tls( domain_name: String, @@ -330,7 +359,7 @@ pub async fn connect_tls( /// /// - Returns a [`ServerConnection`] with all the successful TCP streams. /// - Gives up connecting to each TCP address after `timeout` time. -/// - Returns an error if every attempt failed. +/// - Returns an error if couldn't connect to any of IPv4 and IPv6. pub async fn connect_tcp( addrs: impl ToSocketAddrs + Debug, timeout: Duration, @@ -355,7 +384,7 @@ pub async fn connect_tcp( } else { Some(Err(std::io::Error::new( ErrorKind::TimedOut, - format!("Timed out while trying to connect to {addrs:?}."), + format!("Timed out while trying to connect to server {addrs:?}."), ))) } } else { @@ -369,7 +398,7 @@ pub async fn connect_tcp( } else { Some(Err(std::io::Error::new( ErrorKind::TimedOut, - format!("Timed out while trying to connect to {addrs:?}."), + format!("Timed out while trying to connect to server {addrs:?}."), ))) } } else { diff --git a/gday_hole_punch/tests/test_integration.rs b/gday_hole_punch/tests/test_integration.rs index 17c20db..be2941b 100644 --- a/gday_hole_punch/tests/test_integration.rs +++ b/gday_hole_punch/tests/test_integration.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] #![warn(clippy::all)] -use gday_hole_punch::{server_connector, try_connect_to_peer, ContactSharer, PeerCode}; +use gday_hole_punch::{server_connector, share_contacts, try_connect_to_peer, PeerCode}; use std::str::FromStr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -31,8 +31,8 @@ async fn test_integration() { // Rendezvous settings let peer_code = PeerCode { server_id: 0, - room_code: 123, - shared_secret: 456, + room_code: "123".to_string(), + shared_secret: "456".to_string(), }; // Connect to the server @@ -41,17 +41,17 @@ async fn test_integration() { .unwrap(); // Create a room in the server, and get my contact from it - let (contact_sharer, my_contact) = - ContactSharer::enter_room(&mut server_connection, peer_code.room_code, true) + let (my_contact, peer_contact_fut) = + share_contacts(&mut server_connection, peer_code.room_code.as_bytes(), true) .await .unwrap(); // Send PeerCode to peer - let code_to_share = peer_code.to_string(); + let code_to_share = String::try_from(&peer_code).unwrap(); code_tx.send(code_to_share).unwrap(); // Wait for the server to send the peer's contact - let peer_contact = contact_sharer.get_peer_contact().await.unwrap(); + let peer_contact = peer_contact_fut.await.unwrap(); // Use TCP hole-punching to connect to the peer, // verify their identity with the shared_secret, @@ -59,7 +59,7 @@ async fn test_integration() { let (mut tcp_stream, strong_key) = try_connect_to_peer( my_contact.local, peer_contact, - &peer_code.shared_secret.to_be_bytes(), + peer_code.shared_secret.as_bytes(), ) .await .unwrap(); @@ -83,19 +83,22 @@ async fn test_integration() { .unwrap(); // Join the same room in the server, and get my local contact - let (contact_sharer, my_contact) = - ContactSharer::enter_room(&mut server_connection, peer_code.room_code, false) - .await - .unwrap(); + let (my_contact, peer_contact_fut) = share_contacts( + &mut server_connection, + peer_code.room_code.as_bytes(), + false, + ) + .await + .unwrap(); // Get peer's contact - let peer_contact = contact_sharer.get_peer_contact().await.unwrap(); + let peer_contact = peer_contact_fut.await.unwrap(); // Use hole-punching to connect to peer. let (mut tcp_stream, strong_key) = try_connect_to_peer( my_contact.local, peer_contact, - &peer_code.shared_secret.to_be_bytes(), + peer_code.shared_secret.as_bytes(), ) .await .unwrap(); diff --git a/gday_server/Cargo.toml b/gday_server/Cargo.toml index d3b5e01..7122f36 100644 --- a/gday_server/Cargo.toml +++ b/gday_server/Cargo.toml @@ -14,12 +14,12 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = { version = "4.5.9", features = ["derive"] } -socket2 = { version = "0.5.7", features = ["all"] } -tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } -tokio-rustls = { version = "0.26.0", features = ["ring", "logging", "tls12"], default-features = false } +clap = { version = "4.5.21", features = ["derive"] } +socket2 = { version = "0.5.8" } +tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +tokio-rustls = { version = "0.26.0" } gday_contact_exchange_protocol = { version = "0.2.1", path = "../gday_contact_exchange_protocol" } -thiserror = "1.0.61" +thiserror = "2.0.3" log = "0.4.22" -env_logger = "0.11.3" -rustls-pemfile = "2.1.2" +env_logger = "0.11.5" +rustls-pemfile = "2.2.0" diff --git a/gday_server/README.md b/gday_server/README.md index 51f9967..11cfdcc 100644 --- a/gday_server/README.md +++ b/gday_server/README.md @@ -1,5 +1,3 @@ -Note: this crate is still in early-development, so expect breaking changes. - # gday_server [![Crates.io Version](https://img.shields.io/crates/v/gday_server)](https://crates.io/crates/gday_server) [![docs.rs](https://img.shields.io/docsrs/gday_server)](https://docs.rs/gday_server/) @@ -10,8 +8,7 @@ A server that runs the [gday_contact_exchange_protocol](https://docs.rs/gday_con To run the executable directly: -1. Go to [releases](https://github.com/manforowicz/gday/releases) -and download the correct file for your platform. +1. Download an executable from [releases](https://github.com/manforowicz/gday/releases). 2. Extract it (on Linux: `tar xf `). 3. Run it: `./gday_server` @@ -33,29 +30,36 @@ Options: -k, --key PEM file of private TLS server key -c, --certificate PEM file of signed TLS server certificate -u, --unencrypted Use unencrypted TCP instead of TLS - -a, --address
Custom socket address on which to listen. [default: `[::]:2311` for TLS, `[::]:2310` when --unencrypted] - -t, --timeout Number of seconds before a new room is deleted [default: 3600] - -r, --request-limit Max number of requests an IP address can send in a minute before they're rejected [default: 60] - -v, --verbosity Log verbosity. (trace, debug, info, warn, error) [default: info] + -a, --addresses Socket addresses on which to listen [default: 0.0.0.0:2311 [::]:2311] + -t, --timeout Number of seconds before a new room is deleted [default: 600] + -r, --request-limit Max number of create room requests and requests with an invalid room code an IP address can send per minute before they're rejected [default: 10] + -v, --verbosity Log verbosity. (trace, debug, info, warn, error) [default: debug] -h, --help Print help -V, --version Print version ``` ## Deployment -One of the strengths of gday is its decentralized nature. Want to add your own server to the list of [default servers](https://docs.rs/gday_hole_punch/latest/gday_hole_punch/server_connector/constant.DEFAULT_SERVERS.html)? Here's how: 1. Get a [virtual private server](https://en.wikipedia.org/wiki/Virtual_private_server) (VPS) from a hosting service. It must have public IPv4 and IPv6 addresses and not be behind [NAT](https://en.wikipedia.org/wiki/Network_address_translation). -2. Buy/configure a domain name to point at your VPS. + +2. Buy and configure a domain name to point at your VPS. + 3. On the VPS, get a TLS certificate using [certbot](https://certbot.eff.org/) with your domain name. + 4. On the VPS, use a tool such as `wget` to download gday_server from the [releases page](https://github.com/manforowicz/gday/releases). + 5. On the VPS, run the `gday_server` with the correct TLS arguments. -6. On a local device, verify you can use `gday` with your server domain name passed as an argument. + +6. On a local device, verify you can use `gday` with your `--server` domain name passed as an argument. + 7. On the VPS, follow instructions in [gday_server.service](https://github.com/manforowicz/gday/blob/main/other/gday_server.service) to set up a [systemd service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html). + 8. Verify `gday_server` auto-starts in the background, even when you reboot the server. + 9. Submit an [issue](https://github.com/manforowicz/gday/issues), asking for your server to be added to the [default server list](https://docs.rs/gday_hole_punch/latest/gday_hole_punch/server_connector/constant.DEFAULT_SERVERS.html). ## Related diff --git a/gday_server/src/connection_handler.rs b/gday_server/src/connection_handler.rs index 5a367c0..0bc6592 100644 --- a/gday_server/src/connection_handler.rs +++ b/gday_server/src/connection_handler.rs @@ -1,6 +1,6 @@ use crate::state::{self, State}; use gday_contact_exchange_protocol::{read_from_async, write_to_async, ClientMsg, ServerMsg}; -use log::{info, warn}; +use log::{error, info, warn}; use std::net::SocketAddr; use tokio::{ io::{AsyncRead, AsyncWrite}, @@ -14,18 +14,10 @@ use tokio_rustls::TlsAcceptor; /// Logs information and errors with [`log`]. pub async fn handle_connection( mut tcp_stream: TcpStream, + origin: SocketAddr, tls_acceptor: Option, state: State, ) { - // try establishing a TLS connectio - let origin = match tcp_stream.peer_addr() { - Ok(origin) => origin, - Err(err) => { - warn!("Couldn't get client's IP address: {err}"); - return; - } - }; - if let Some(tls_acceptor) = tls_acceptor { let mut tls_stream = match tls_acceptor.accept(tcp_stream).await { Ok(tls_stream) => tls_stream, @@ -34,17 +26,9 @@ pub async fn handle_connection( return; } }; - handle_requests(&mut tls_stream, state, origin) - .await - .unwrap_or_else(|err| { - info!("Dropping connection with '{origin}' because: {err}"); - }); + let _ = handle_requests(&mut tls_stream, state, origin).await; } else { - handle_requests(&mut tcp_stream, state, origin) - .await - .unwrap_or_else(|err| { - info!("Dropping connection with '{origin}' because: {err}"); - }); + let _ = handle_requests(&mut tcp_stream, state, origin).await; } } @@ -60,30 +44,38 @@ async fn handle_requests( match result { Ok(()) => (), Err(HandleMessageError::State(state::Error::NoSuchRoomCode)) => { + warn!("Replying with ServerMsg::ErrorNoSuchRoomCode."); write_to_async(ServerMsg::ErrorNoSuchRoomCode, stream).await?; } Err(HandleMessageError::Receiver(_)) => { + warn!("Replying with ServerMsg::ErrorPeerTimedOut."); write_to_async(ServerMsg::ErrorPeerTimedOut, stream).await?; } Err(HandleMessageError::State(state::Error::RoomCodeTaken)) => { + warn!("Replying with ServerMsg::ErrorRoomTaken."); write_to_async(ServerMsg::ErrorRoomTaken, stream).await?; } Err(HandleMessageError::State(state::Error::TooManyRequests)) => { + warn!("Replying with ServerMsg::ErrorTooManyRequests and disconnecting."); write_to_async(ServerMsg::ErrorTooManyRequests, stream).await?; return result; } Err(HandleMessageError::State(state::Error::CantUpdateDoneClient)) => { + warn!("Replying with ServerMsg::ErrorUnexpectedMsg."); write_to_async(ServerMsg::ErrorUnexpectedMsg, stream).await?; } - Err(HandleMessageError::Protocol(_)) => { + Err(HandleMessageError::Protocol(ref err)) => { + warn!("Replying with ServerMsg::ErrorSyntax and disconnecting, because: {err}"); write_to_async(ServerMsg::ErrorSyntax, stream).await?; return result; } - Err(HandleMessageError::UnknownMessage(_)) => { + Err(HandleMessageError::UnknownMessage(msg)) => { + warn!("Replying with ServerMsg::ErrorSyntax because received unknown message: {msg:?}"); write_to_async(ServerMsg::ErrorSyntax, stream).await?; + return result; } Err(HandleMessageError::IO(_)) => { - write_to_async(ServerMsg::ErrorConnection, stream).await?; + info!("'{origin}' disconnected."); return result; } } @@ -149,11 +141,15 @@ async fn handle_message( // responds to the client with their own contact info write_to_async(ServerMsg::ClientContact(client_contact), stream).await?; + info!("Sent client '{origin}' their contact of '{client_contact}'."); + // wait for the peer to be done sending as well let peer_contact = rx.await?; // send the peer's contact info to this client write_to_async(ServerMsg::PeerContact(peer_contact), stream).await?; + + info!("Sent client '{origin}' their peer's contact of '{client_contact}'."); } unknown_msg => return Err(HandleMessageError::UnknownMessage(unknown_msg)), } diff --git a/gday_server/src/lib.rs b/gday_server/src/lib.rs index dea574a..db42bd2 100644 --- a/gday_server/src/lib.rs +++ b/gday_server/src/lib.rs @@ -1,5 +1,3 @@ -//! Note: this crate is still in early-development, so expect breaking changes. -//! //! Runs a server for the [`gday_contact_exchange_protocol`]. //! Lets two users exchange their public and (optionally) private socket addresses. #![forbid(unsafe_code)] @@ -57,7 +55,7 @@ pub struct Args { pub request_limit: u32, /// Log verbosity. (trace, debug, info, warn, error) - #[arg(short, long, default_value = "info")] + #[arg(short, long, default_value = "debug")] pub verbosity: log::LevelFilter, } @@ -104,7 +102,6 @@ pub fn start_server(args: Args) -> Result<(Vec, JoinSet<()>), Error> // log the addresses being listened on info!("Listening on these addresses: {addresses:?}"); - info!("Is encrypted?: {}", tls_acceptor.is_some()); info!( "Critical requests per minute per IP address limit: {}", @@ -136,18 +133,19 @@ async fn run_single_server( ) { loop { // try to accept another connection - let (stream, addr) = match tcp_listener.accept().await { + let (stream, origin) = match tcp_listener.accept().await { Ok(ok) => ok, Err(err) => { warn!("Error accepting incoming TCP connection: {err}."); continue; } }; - debug!("Accepted incoming TCP connection from {addr}."); + debug!("Accepted incoming TCP connection from {origin}."); // spawn a thread to handle the connection tokio::spawn(handle_connection( stream, + origin, tls_acceptor.clone(), state.clone(), )); @@ -158,8 +156,6 @@ async fn run_single_server( /// /// Sets the socket's TCP keepalive so that unresponsive /// connections close after 10 minutes to save resources. -/// -/// TODO: Check if I need to check for eintr errors? fn get_tcp_listener(addr: SocketAddr) -> Result { // create a socket let socket = socket2::Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP)) @@ -189,11 +185,6 @@ fn get_tcp_listener(addr: SocketAddr) -> Result source, })?; - socket.set_nonblocking(true).map_err(|source| Error { - msg: "Couldn't set TCP socket to non blocking".to_string(), - source, - })?; - socket.bind(&addr.into()).map_err(|source| Error { msg: format!("Couldn't bind socket to address {addr}"), source, @@ -206,6 +197,11 @@ fn get_tcp_listener(addr: SocketAddr) -> Result let listener: std::net::TcpListener = socket.into(); + listener.set_nonblocking(true).map_err(|source| Error { + msg: "Couldn't set TCP socket to non blocking".to_string(), + source, + })?; + // convert to a tokio listener let listener = tokio::net::TcpListener::from_std(listener).map_err(|source| Error { msg: "Couldn't create async TCP listener".to_string(), diff --git a/gday_server/src/main.rs b/gday_server/src/main.rs index e45a39a..a75e336 100644 --- a/gday_server/src/main.rs +++ b/gday_server/src/main.rs @@ -1,5 +1,3 @@ -//! Note: this crate is still in early-development, so expect breaking changes. -//! //! Runs a server for the [`gday_contact_exchange_protocol`]. //! Lets two users exchange their public and (optionally) private socket addresses. #![forbid(unsafe_code)] @@ -21,6 +19,7 @@ async fn main() { .await .expect("No addresses provided.") .expect("Server thread panicked."); + error!("Server crashed."); } Err(err) => { error!("{err}"); diff --git a/gday_server/src/state.rs b/gday_server/src/state.rs index 5ff16ad..8aee3c1 100644 --- a/gday_server/src/state.rs +++ b/gday_server/src/state.rs @@ -60,10 +60,11 @@ impl Room { /// is acquired at any given time. This is to prevent deadlock. #[derive(Clone, Debug)] pub struct State { - /// Maps room_code to rooms - rooms: Arc>>, + /// Maps room code to rooms + rooms: Arc>>, - /// Maps IP addresses to the number of requests they sent this minute. + /// Maps IP addresses to the number of critical + /// requests they sent this minute. request_counts: Arc>>, /// Maximum number of requests an IP address can @@ -108,9 +109,9 @@ impl State { /// Creates a new room with `room_code`. /// /// - Returns [`Error::TooManyRequests`] if `origin`'s - /// request limit is exceeded. + /// request limit is exceeded. /// - Returns [`Error::RoomCodeTaken`] if the room already exists. - pub fn create_room(&mut self, room_code: u64, origin: IpAddr) -> Result<(), Error> { + pub fn create_room(&mut self, room_code: [u8; 32], origin: IpAddr) -> Result<(), Error> { self.increment_request_count(origin)?; { @@ -142,10 +143,10 @@ impl State { /// /// - Returns [`Error::NoSuchRoomCode`] if no room with `room_code` exists. /// - Returns [`Error::TooManyRequests`] if `origin`'s - /// request limit is exceeded. + /// request limit is exceeded. pub fn update_client( &mut self, - room_code: u64, + room_code: [u8; 32], is_creator: bool, endpoint: SocketAddr, public: bool, @@ -193,10 +194,10 @@ impl State { /// once that peer is also ready. /// /// - Returns [`Error::TooManyRequests`] if the max - /// allowable number of requests per minute is exceeded. + /// allowable number of requests per minute is exceeded. pub fn set_client_done( &mut self, - room_code: u64, + room_code: [u8; 32], is_creator: bool, origin: IpAddr, ) -> Result<(FullContact, oneshot::Receiver), Error> { @@ -256,7 +257,7 @@ impl State { if *conns_count >= *self.max_requests_per_minute { Err(Error::TooManyRequests) } else { - *conns_count += 1; + *conns_count = conns_count.saturating_add(1); Ok(()) } } @@ -323,7 +324,7 @@ mod tests { }, }; - const ROOM: u64 = 1234; + const ROOM: [u8; 32] = *b"hduejdlameu7493mzajfjdlf;sdafsda"; // Client 1 creates a new room state1.create_room(ROOM, origin1).unwrap(); @@ -400,15 +401,15 @@ mod tests { // 100 requests for i in 1..=100 { - state1.create_room(i, origin1).unwrap(); + state1.create_room([i; 32], origin1).unwrap(); // unrelated requests that shouldn't hit limit - state2.create_room(i + 1000, origin2).unwrap(); + state2.create_room([i + 100; 32], origin2).unwrap(); } // 101th request should hit limit assert!(matches!( - state2.create_room(101, origin1), + state2.create_room([101; 32], origin1), Err(Error::TooManyRequests) )); } @@ -423,7 +424,7 @@ mod tests { let example_endpoint = "12.213.31.13:342".parse().unwrap(); - const ROOM: u64 = 1234; + const ROOM: [u8; 32] = *b"jkfd;ew8t9spfjsdiafdjsafadsafdsa"; state1.create_room(ROOM, origin1).unwrap(); diff --git a/gday_server/tests/test_integration.rs b/gday_server/tests/test_integration.rs index 5edea4f..93dd798 100644 --- a/gday_server/tests/test_integration.rs +++ b/gday_server/tests/test_integration.rs @@ -16,8 +16,8 @@ async fn test_integration() { verbosity: log::LevelFilter::Off, }; let (server_addrs, _joinset) = gday_server::start_server(args).unwrap(); - let server_addr_1 = server_addrs[0]; - let server_addr_2 = server_addrs[1]; + let server_ipv4 = *server_addrs.iter().find(|a| a.is_ipv4()).unwrap(); + let server_ipv6 = *server_addrs.iter().find(|a| a.is_ipv6()).unwrap(); tokio::task::spawn_blocking(move || { let local_contact_1 = Contact { @@ -31,66 +31,78 @@ async fn test_integration() { }; // connect to the server - let mut stream1 = std::net::TcpStream::connect(server_addr_1).unwrap(); - let mut stream2 = std::net::TcpStream::connect(server_addr_2).unwrap(); + let mut stream_v4 = std::net::TcpStream::connect(server_ipv4).unwrap(); + let mut stream_v6 = std::net::TcpStream::connect(server_ipv6).unwrap(); // successfully create a room - write_to(ClientMsg::CreateRoom { room_code: 123 }, &mut stream1).unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [123; 32], + }, + &mut stream_v4, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::RoomCreated); // room taken - write_to(ClientMsg::CreateRoom { room_code: 123 }, &mut stream1).unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [123; 32], + }, + &mut stream_v4, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::ErrorRoomTaken); // room doesn't exist write_to( ClientMsg::RecordPublicAddr { - room_code: 789, + room_code: [234; 32], is_creator: true, }, - &mut stream2, + &mut stream_v6, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); assert_eq!(response, ServerMsg::ErrorNoSuchRoomCode); // record public address write_to( ClientMsg::RecordPublicAddr { - room_code: 123, + room_code: [123; 32], is_creator: true, }, - &mut stream1, + &mut stream_v4, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::ReceivedAddr); // record public address write_to( ClientMsg::RecordPublicAddr { - room_code: 123, + room_code: [123; 32], is_creator: false, }, - &mut stream2, + &mut stream_v6, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); assert_eq!(response, ServerMsg::ReceivedAddr); // set creator to done write_to( ClientMsg::ReadyToShare { local_contact: local_contact_1, - room_code: 123, + room_code: [123; 32], is_creator: true, }, - &mut stream1, + &mut stream_v4, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); let ServerMsg::ClientContact(client_contact) = response else { panic!("Server replied with {response:?} instead of ClientContact"); }; @@ -99,53 +111,65 @@ async fn test_integration() { // can't update client once it is done write_to( ClientMsg::RecordPublicAddr { - room_code: 123, + room_code: [123; 32], is_creator: true, }, - &mut stream2, + &mut stream_v6, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); assert_eq!(response, ServerMsg::ErrorUnexpectedMsg); // successfully create an unrelated room - write_to(ClientMsg::CreateRoom { room_code: 456 }, &mut stream2).unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [234; 32], + }, + &mut stream_v6, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); assert_eq!(response, ServerMsg::RoomCreated); // set joiner to done write_to( ClientMsg::ReadyToShare { local_contact: local_contact_2, - room_code: 123, + room_code: [123; 32], is_creator: false, }, - &mut stream2, + &mut stream_v6, ) .unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); let ServerMsg::ClientContact(client_contact) = response else { panic!("Server replied with {response:?} instead of ClientContact"); }; assert_eq!(client_contact.local, local_contact_2); // ensure peer contact 1 properly exchanged - let response: ServerMsg = read_from(&mut stream1).unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); let ServerMsg::PeerContact(peer_contact) = response else { panic!("Server replied with {response:?} instead of PeerContact"); }; assert_eq!(peer_contact.local, local_contact_2); // ensure peer contact 2 properly exchanged - let response: ServerMsg = read_from(&mut stream2).unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); let ServerMsg::PeerContact(peer_contact) = response else { panic!("Server replied with {response:?} instead of PeerContact"); }; assert_eq!(peer_contact.local, local_contact_1); // ensure the room was closed, and can be reopened - write_to(ClientMsg::CreateRoom { room_code: 123 }, &mut stream1).unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [123; 32], + }, + &mut stream_v4, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::RoomCreated); }) .await @@ -165,36 +189,59 @@ async fn test_request_limit() { verbosity: log::LevelFilter::Off, }; let (server_addrs, _joinset) = gday_server::start_server(args).unwrap(); - let server_addr_1 = server_addrs[0]; - let server_addr_2 = server_addrs[1]; + let server_ipv4 = *server_addrs.iter().find(|a| a.is_ipv4()).unwrap(); + let server_ipv6 = *server_addrs.iter().find(|a| a.is_ipv6()).unwrap(); tokio::task::spawn_blocking(move || { // connect to the server - let mut stream1 = std::net::TcpStream::connect(server_addr_1).unwrap(); - let mut stream2 = std::net::TcpStream::connect(server_addr_2).unwrap(); + let mut stream_v4 = std::net::TcpStream::connect(server_ipv4).unwrap(); + let mut stream_v6 = std::net::TcpStream::connect(server_ipv6).unwrap(); for room_code in 1..=10 { // successfully create a room - write_to(ClientMsg::CreateRoom { room_code }, &mut stream1).unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [room_code; 32], + }, + &mut stream_v4, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::RoomCreated); } // request limit hit - write_to(ClientMsg::CreateRoom { room_code: 11 }, &mut stream1).unwrap(); - let response: ServerMsg = read_from(&mut stream1).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [11; 32], + }, + &mut stream_v4, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v4).unwrap(); assert_eq!(response, ServerMsg::ErrorTooManyRequests); // ensure the server closed the connection - let result = write_to(ClientMsg::CreateRoom { room_code: 100 }, &mut stream1); + let result = write_to( + ClientMsg::CreateRoom { + room_code: [100; 32], + }, + &mut stream_v4, + ); assert!(matches!( result, Err(gday_contact_exchange_protocol::Error::IO(_)) )); // ensure other connections are unaffected - write_to(ClientMsg::CreateRoom { room_code: 200 }, &mut stream2).unwrap(); - let response: ServerMsg = read_from(&mut stream2).unwrap(); + write_to( + ClientMsg::CreateRoom { + room_code: [200; 32], + }, + &mut stream_v6, + ) + .unwrap(); + let response: ServerMsg = read_from(&mut stream_v6).unwrap(); assert_eq!(response, ServerMsg::RoomCreated); }) .await diff --git a/other/demo.sh b/other/demo.sh index a8d97f0..6b230a2 100755 --- a/other/demo.sh +++ b/other/demo.sh @@ -1,15 +1,24 @@ #!/usr/bin/env bash -# Custom script for starting a tmux 2-pane asciinema recording. -# Intended for recording gday demos. +# Script for recording gday demos. # Requires: asciinema, tmux + +# Creates temporary folders and +# starts a tmux 2-pane asciinema recording. + # Use ctrl+b to switch between panes. -# Use ctrl+d to end the recording. +# Press ctrl+d multiple times to end the recording. # Create the demo folders if they don't exist yet. mkdir tmp cd tmp + mkdir peer_1 +mkdir peer_1/folder +echo "Hello everyone!" > peer_1/file.mp4 +echo "Testing" > peer_1/folder/img.jpg +echo "Hi there!" > peer_1/folder/word.docx + mkdir peer_2 # Start a new session detached @@ -28,5 +37,9 @@ tmux send-keys -t demo_session:0.1 'clear' C-m # Select the left pane tmux select-pane -t demo_session:0.0 +cd ../ + # Start recording asciinema rec -c "tmux attach -t demo_session" --overwrite demo.cast + +rm -r tmp diff --git a/other/gday_server.service b/other/gday_server.service index 8354163..bf714f1 100644 --- a/other/gday_server.service +++ b/other/gday_server.service @@ -1,6 +1,6 @@ # systemd service that runs a gday_server # -# Put your gday_server executable in ~/gday_server/gday_server. +# Put your gday_server executable in /root/gday_server. # # Save this file as: # '/etc/systemd/system/gday_server.service' @@ -21,7 +21,7 @@ # 'sudo journalctl -u gday_server -f' # # View stderr and stdout log files in real time: -# 'tail -f ~/gday_server/stdout.log' +# 'tail -f /root/logs/stderr.log' [Unit] @@ -35,20 +35,21 @@ After=network.target [Service] # Command to execute (modify as needed) -ExecStart=/home/ubuntu/gday_server/gday_server --key /etc/letsencrypt/live/gday.manforowicz.com/privkey.pem --certificate /etc/letsencrypt/live/gday.manforowicz.com/fullchain.pem +ExecStart=/root/gday_server --key /etc/letsencrypt/live/gday.manforowicz.com/privkey.pem --certificate /etc/letsencrypt/live/gday.manforowicz.com/fullchain.pem # Auto-restart the service if it crashes Restart=always # How long to wait between restarts +# (to avoid wasting resources if inifinite crash loop occurs) RestartSec=20 # Run the service as root, so it can access the certificates User=root # Pipe stdout and stderr into custom log files (modify as needed) -StandardOutput=append:/home/ubuntu/gday_server/stdout.log -StandardError=append:/home/ubuntu/gday_server/stderr.log +StandardOutput=append:/root/logs/stdout.log +StandardError=append:/root/logs/stderr.log [Install] diff --git a/other/rate_limiter.sh b/other/rate_limiter.sh new file mode 100755 index 0000000..31baefc --- /dev/null +++ b/other/rate_limiter.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Some VPS providers charge per GB of network egress. +# The Gday Server only exchanges contacts, so it doesn't +# transmit lots of data to network. +# +# However, as always, there's the remote possibility of a +# DDOS causing excessive network usage on our VPS. +# +# We can protect ourselves against this +# by limiting the VPS's data rate. +# +# This bash script limits data rate to 1 mbit/s with bursts allowed. +# +# To make this script run on every startup of the VPS, put +# the following into /etc/systemd/system/rate_limiter.service: + +########################################## +#[Unit] +# Description=rate_limiter +# After=network.target +# +# [Service] +# Type=oneshot +# ExecStart=/root/rate_limiter.sh +# RemainAfterExit=yes +# +# [Install] +# WantedBy=multi-user.target +########################################## + +# Then reload the dameon: +# 'sudo systemctl daemon-reload' +# +# Enable the service so that it starts on boot: +# 'sudo systemctl enable rate_limiter' +# +# Start the service right now: +# 'sudo systemctl start rate_limiter' +# +# Verify the status of the service: +# 'sudo systemctl status rate_limiter' + + +# Clear existing rules +tc qdisc del dev eth0 root 2>/dev/null + +# Limit eth0 rate to 1 mbit/s. +# This should ensure monthly egress doesn't exceed ~330 GB. +tc qdisc add dev eth0 root tbf rate 1mbit burst 1mbit latency 100ms