diff --git a/.cargo/config.toml b/.cargo/config.toml index a88db5f38..28cde74ec 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,7 @@ [alias] cov = "llvm-cov" cov-lcov = "llvm-cov --lcov --output-path=./.coverage/lcov.info" +cov-codecov = "llvm-cov --codecov --output-path=./.coverage/codecov.json" cov-html = "llvm-cov --html" time = "build --timings --all-targets" diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 9f51f3124..7416df71e 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -46,7 +46,7 @@ jobs: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: compose name: Compose diff --git a/.github/workflows/contract.yaml b/.github/workflows/contract.yaml deleted file mode 100644 index 2777417e3..000000000 --- a/.github/workflows/contract.yaml +++ /dev/null @@ -1,58 +0,0 @@ -name: Contract - -on: - push: - pull_request: - -env: - CARGO_TERM_COLOR: always - -jobs: - contract: - name: Contract - runs-on: ubuntu-latest - - strategy: - matrix: - toolchain: [nightly, stable] - - steps: - - id: checkout - name: Checkout Repository - uses: actions/checkout@v4 - - - id: setup - name: Setup Toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ matrix.toolchain }} - components: llvm-tools-preview - - - id: cache - name: Enable Job Cache - uses: Swatinem/rust-cache@v2 - - - id: tools - name: Install Tools - uses: taiki-e/install-action@v2 - with: - tool: cargo-llvm-cov, cargo-nextest - - - id: pretty-test - name: Install pretty-test - run: cargo install cargo-pretty-test - - - id: contract - name: Run contract - run: | - cargo test --lib --bins - cargo pretty-test --lib --bins - - - id: summary - name: Generate contract Summary - run: | - echo "### Tracker Living Contract! :rocket:" >> $GITHUB_STEP_SUMMARY - cargo pretty-test --lib --bins --color=never >> $GITHUB_STEP_SUMMARY - echo '```console' >> $GITHUB_STEP_SUMMARY - echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 28c1be6d0..2c8d63d6c 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -4,35 +4,25 @@ on: push: branches: - develop - pull_request_target: - branches: - - develop env: CARGO_TERM_COLOR: always jobs: report: - name: Report + name: Generate Coverage Report environment: coverage runs-on: ubuntu-latest env: CARGO_INCREMENTAL: "0" - RUSTFLAGS: "-Z profile -C codegen-units=1 -C opt-level=0 -C link-dead-code -C overflow-checks=off -Z panic_abort_tests -C panic=abort" - RUSTDOCFLAGS: "-Z profile -C codegen-units=1 -C opt-level=0 -C link-dead-code -C overflow-checks=off -Z panic_abort_tests -C panic=abort" + RUSTFLAGS: "-Cinstrument-coverage" steps: - - id: checkout_push - if: github.event_name == 'push' - name: Checkout Repository (Push) - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v6 - - id: checkout_pull_request_target - if: github.event_name == 'pull_request_target' - name: Checkout Repository (Pull Request Target) - uses: actions/checkout@v4 - with: - ref: "refs/pull/${{ github.event.pull_request.number }}/head" + - name: Install LLVM tools + run: sudo apt-get update && sudo apt-get install -y llvm - id: setup name: Setup Toolchain @@ -49,37 +39,19 @@ jobs: name: Install Tools uses: taiki-e/install-action@v2 with: - tool: grcov - - - id: check - name: Run Build Checks - run: cargo check --tests --benches --examples --workspace --all-targets --all-features - - - id: clean - name: Clean Build Directory - run: cargo clean - - - id: build - name: Pre-build Main Project - run: cargo build --workspace --all-targets --all-features --jobs 2 - - - id: build_tests - name: Pre-build Tests - run: cargo build --workspace --all-targets --all-features --tests --jobs 2 - - - id: test - name: Run Unit Tests - run: cargo test --tests --workspace --all-targets --all-features + tool: grcov,cargo-llvm-cov - id: coverage name: Generate Coverage Report - uses: alekitto/grcov@v0.2 + run: | + cargo clean + cargo llvm-cov --all-features --workspace --codecov --output-path ./codecov.json - id: upload name: Upload Coverage Report - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ${{ steps.coverage.outputs.report }} verbose: true - fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ github.workspace }}/codecov.json + fail_ci_if_error: true \ No newline at end of file diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 6aa66e985..b544d1da2 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -17,7 +17,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: setup name: Setup Toolchain @@ -35,10 +35,14 @@ jobs: needs: test runs-on: ubuntu-latest + strategy: + matrix: + toolchain: [stable] + steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: setup name: Setup Toolchain @@ -51,11 +55,29 @@ jobs: env: CARGO_REGISTRY_TOKEN: "${{ secrets.TORRUST_UPDATE_CARGO_REGISTRY_TOKEN }}" run: | + cargo publish -p bittorrent-http-tracker-core + cargo publish -p bittorrent-http-tracker-protocol + cargo publish -p bittorrent-tracker-client + cargo publish -p bittorrent-tracker-core + cargo publish -p bittorrent-udp-tracker-core + cargo publish -p bittorrent-udp-tracker-protocol + cargo publish -p torrust-axum-health-check-api-server + cargo publish -p torrust-axum-http-tracker-server + cargo publish -p torrust-axum-rest-tracker-api-server + cargo publish -p torrust-axum-server + cargo publish -p torrust-rest-tracker-api-client + cargo publish -p torrust-rest-tracker-api-core + cargo publish -p torrust-torrust-server-lib + cargo publish -p torrust-tracker + cargo publish -p torrust-tracker-client + cargo publish -p torrust-tracker-clock + cargo publish -p torrust-tracker-configuration cargo publish -p torrust-tracker-contrib-bencode + cargo publish -p torrust-tracker-events cargo publish -p torrust-tracker-located-error + cargo publish -p torrust-tracker-metrics cargo publish -p torrust-tracker-primitives - cargo publish -p torrust-tracker-clock - cargo publish -p torrust-tracker-configuration - cargo publish -p torrust-tracker-torrent-repository + cargo publish -p torrust-tracker-swarm-coordination-registry cargo publish -p torrust-tracker-test-helpers - cargo publish -p torrust-tracker + cargo publish -p torrust-tracker-torrent-benchmarking + cargo publish -p torrust-udp-tracker-server diff --git a/.github/workflows/generate_coverage_pr.yaml b/.github/workflows/generate_coverage_pr.yaml new file mode 100644 index 000000000..a3f97dbf2 --- /dev/null +++ b/.github/workflows/generate_coverage_pr.yaml @@ -0,0 +1,84 @@ +name: Generate Coverage Report (PR) + +on: + pull_request: + branches: + - develop + +env: + CARGO_TERM_COLOR: always + +jobs: + coverage: + name: Generate Coverage Report + environment: coverage + runs-on: ubuntu-latest + env: + CARGO_INCREMENTAL: "0" + RUSTFLAGS: "-Cinstrument-coverage" + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install LLVM tools + run: sudo apt-get update && sudo apt-get install -y llvm + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly + components: llvm-tools-preview + + - id: cache + name: Enable Workflow Cache + uses: Swatinem/rust-cache@v2 + + - id: tools + name: Install Tools + uses: taiki-e/install-action@v2 + with: + tool: grcov,cargo-llvm-cov + + - id: coverage + name: Generate Coverage Report + run: | + cargo clean + cargo llvm-cov --all-features --workspace --codecov --output-path ./codecov.json + + - name: Store PR number and commit SHA + run: | + echo "Storing PR number ${{ github.event.number }}" + echo "${{ github.event.number }}" > pr_number.txt + + echo "Storing commit SHA ${{ github.event.pull_request.head.sha }}" + echo "${{ github.event.pull_request.head.sha }}" > commit_sha.txt + + # Workaround for https://github.com/orgs/community/discussions/25220 + # Triggered sub-workflow is not able to detect the original commit/PR which is available + # in this workflow. + - name: Store PR number + uses: actions/upload-artifact@v7 + with: + name: pr_number + path: pr_number.txt + + - name: Store commit SHA + uses: actions/upload-artifact@v7 + with: + name: commit_sha + path: commit_sha.txt + + # This stores the coverage report in artifacts. The actual upload to Codecov + # is executed by a different workflow `upload_coverage.yml`. The reason for this + # split is because `on.pull_request` workflows don't have access to secrets. + - name: Store coverage report in artifacts + uses: actions/upload-artifact@v7 + with: + name: codecov_report + path: ./codecov.json + + - run: | + echo "The coverage report was stored in Github artifacts." + echo "It will be uploaded to Codecov using [upload_coverage.yml] workflow shortly." diff --git a/.github/workflows/labels.yaml b/.github/workflows/labels.yaml index bb8283f30..a312c335f 100644 --- a/.github/workflows/labels.yaml +++ b/.github/workflows/labels.yaml @@ -25,7 +25,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: sync name: Apply Labels from File diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index abe6f0a60..c9328d890 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -15,7 +15,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: setup name: Setup Toolchain @@ -44,7 +44,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: setup name: Setup Toolchain @@ -69,7 +69,7 @@ jobs: - id: lint name: Run Lint Checks - run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic + run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features - id: docs name: Lint Documentation @@ -85,6 +85,27 @@ jobs: name: Check Unused Dependencies run: cargo machete + build: + name: Build on ${{ matrix.os }} (${{ matrix.toolchain }}) + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [nightly, stable] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - id: setup + name: Setup Toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.toolchain }} + + - name: Build project + run: cargo build --verbose unit: name: Units @@ -98,7 +119,7 @@ jobs: steps: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: setup name: Setup Toolchain @@ -119,12 +140,16 @@ jobs: - id: test-docs name: Run Documentation Tests - run: cargo test --doc + run: cargo test --doc --workspace - id: test name: Run Unit Tests run: cargo test --tests --benches --examples --workspace --all-targets --all-features + - id: database + name: Run MySQL Database Tests + run: TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test --package bittorrent-tracker-core + e2e: name: E2E runs-on: ubuntu-latest @@ -148,7 +173,7 @@ jobs: - id: checkout name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - id: test name: Run E2E Tests diff --git a/.github/workflows/upload_coverage_pr.yaml b/.github/workflows/upload_coverage_pr.yaml new file mode 100644 index 000000000..8b0006a6d --- /dev/null +++ b/.github/workflows/upload_coverage_pr.yaml @@ -0,0 +1,119 @@ +name: Upload Coverage Report (PR) + +on: + # This workflow is triggered after every successfull execution + # of `Generate Coverage Report` workflow. + workflow_run: + workflows: ["Generate Coverage Report (PR)"] + types: + - completed + +permissions: + actions: write + contents: write + issues: write + pull-requests: write + +jobs: + coverage: + name: Upload Coverage Report + environment: coverage + runs-on: ubuntu-latest + steps: + - name: "Download existing coverage report" + id: prepare_report + uses: actions/github-script@v8 + with: + script: | + var fs = require('fs'); + + // List artifacts of the workflow run that triggered this workflow + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + let codecovReport = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "codecov_report"; + }); + + if (codecovReport.length != 1) { + throw new Error("Unexpected number of {codecov_report} artifacts: " + codecovReport.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: codecovReport[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('codecov_report.zip', Buffer.from(download.data)); + + let prNumber = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr_number"; + }); + + if (prNumber.length != 1) { + throw new Error("Unexpected number of {pr_number} artifacts: " + prNumber.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: prNumber[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('pr_number.zip', Buffer.from(download.data)); + + let commitSha = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "commit_sha"; + }); + + if (commitSha.length != 1) { + throw new Error("Unexpected number of {commit_sha} artifacts: " + commitSha.length); + } + + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: commitSha[0].id, + archive_format: 'zip', + }); + fs.writeFileSync('commit_sha.zip', Buffer.from(download.data)); + + - id: parse_previous_artifacts + run: | + unzip codecov_report.zip + unzip pr_number.zip + unzip commit_sha.zip + + echo "Detected PR is: $(> "$GITHUB_OUTPUT" + echo "override_commit=$(> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ steps.parse_previous_artifacts.outputs.override_commit || '' }} + path: repo_root + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ github.workspace }}/codecov.json + fail_ci_if_error: true + # Manual overrides for these parameters are needed because automatic detection + # in codecov-action does not work for non-`pull_request` workflows. + # In `main` branch push, these default to empty strings since we want to run + # the analysis on HEAD. + override_commit: ${{ steps.parse_previous_artifacts.outputs.override_commit || '' }} + override_pr: ${{ steps.parse_previous_artifacts.outputs.override_pr || '' }} + working-directory: ${{ github.workspace }}/repo_root + # Location where coverage report files are searched for + directory: ${{ github.workspace }} diff --git a/.gitignore b/.gitignore index b60b28991..fd83ee918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +*.code-workspace **/*.rs.bk /.coverage/ /.idea/ @@ -12,5 +13,8 @@ /tracker.* /tracker.toml callgrind.out +codecov.json +integration_tests_sqlite3.db +lcov.info perf.data* -*.code-workspace \ No newline at end of file +rustc-ice-*.txt diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..506a52259 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,26 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "github_token", + "description": "GitHub Personal Access Token", + "password": true + } + ], + "servers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index caa48dd01..d27d562e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,34 +2,20 @@ "[rust]": { "editor.formatOnSave": true }, - "[ignore]": { "rust-analyzer.cargo.extraEnv" : { - "RUSTFLAGS": "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests", - "RUSTDOCFLAGS": "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests", - "CARGO_INCREMENTAL": "0", - "RUST_BACKTRACE": "1" - }}, + "[ignore]": { + "rust-analyzer.cargo.extraEnv": { + "RUSTFLAGS": "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests", + "RUSTDOCFLAGS": "-Z profile -C codegen-units=1 -C inline-threshold=0 -C link-dead-code -C overflow-checks=off -C panic=abort -Z panic_abort_tests", + "CARGO_INCREMENTAL": "0", + "RUST_BACKTRACE": "1" + } + }, "rust-analyzer.checkOnSave": true, "rust-analyzer.check.command": "clippy", "rust-analyzer.check.allTargets": true, - "rust-analyzer.check.extraArgs": [ - "--", - "-D", - "clippy::correctness", - "-D", - "clippy::suspicious", - "-W", - "clippy::complexity", - "-W", - "clippy::perf", - "-W", - "clippy::style", - "-W", - "clippy::pedantic" - ], "evenBetterToml.formatter.allowedBlankLines": 1, "evenBetterToml.formatter.columnWidth": 130, "evenBetterToml.formatter.trailingNewline": true, "evenBetterToml.formatter.reorderKeys": true, "evenBetterToml.formatter.reorderArrays": true, - } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 56978738f..6894e2bcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 3 [[package]] name = "addr2line" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" @@ -23,28 +23,16 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", + "getrandom 0.2.17", "once_cell", "version_check", - "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -65,16 +53,19 @@ dependencies = [ ] [[package]] -name = "allocator-api2" -version = "0.2.18" +name = "alloca" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -93,9 +84,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -108,43 +99,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.89" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "aquatic_peer_id" @@ -157,7 +158,7 @@ dependencies = [ "quickcheck", "regex", "serde", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -169,14 +170,17 @@ dependencies = [ "aquatic_peer_id", "byteorder", "either", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] [[package]] name = "arrayvec" @@ -184,6 +188,22 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-attributes" version = "1.1.2" @@ -207,9 +227,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -219,30 +239,27 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.12" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec134f64e2bc57411226dfc4e52dec859ddfc7e711fc5e07b612584f000e4aa" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ - "brotli", - "flate2", - "futures-core", - "memchr", + "compression-codecs", + "compression-core", "pin-project-lite", "tokio", - "zstd", - "zstd-safe", ] [[package]] name = "async-executor" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", "fastrand", "futures-lite", + "pin-project-lite", "slab", ] @@ -252,7 +269,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-io", "async-lock", @@ -264,11 +281,11 @@ dependencies = [ [[package]] name = "async-io" -version = "2.3.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", @@ -277,26 +294,25 @@ dependencies = [ "polling", "rustix", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.3.1", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-std" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-attributes", "async-channel 1.9.0", @@ -319,6 +335,28 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -327,20 +365,20 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.82" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "atomic" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ "bytemuck", ] @@ -353,47 +391,42 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.9.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f95446d919226d587817a7d21379e6eb099b97b45110a7f272a444ca5c54070" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", - "mirai-annotations", - "paste", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.21.2" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ddc4a5b231dd6958b140ff3151b6412b3f4321fab354f399eec8f14b06df62" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ - "bindgen 0.69.4", "cc", "cmake", "dunce", "fs_extra", - "libc", - "paste", ] [[package]] name = "axum" -version = "0.7.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", "axum-core", "axum-macros", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -406,14 +439,13 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", - "tower 0.5.1", + "tower", "tower-layer", "tower-service", "tracing", @@ -421,9 +453,9 @@ dependencies = [ [[package]] name = "axum-client-ip" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72188bed20deb981f3a4a9fe674e5980fd9e9c2bd880baa94715ad5d60d64c67" +checksum = "dff8ee1869817523c8f91c20bf17fd932707f66c2e7e0b0f811b29a227289562" dependencies = [ "axum", "forwarded-header-value", @@ -432,20 +464,18 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.1", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -453,22 +483,24 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" dependencies = [ "axum", "axum-core", "bytes", + "form_urlencoded", + "futures-core", "futures-util", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "serde", + "serde_core", "serde_html_form", - "tower 0.5.1", + "serde_path_to_error", "tower-layer", "tower-service", "tracing", @@ -476,44 +508,42 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "axum-server" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56bac90848f6a9393ac03c63c640925c4b7c8ca21654de40d53f55964667c7d8" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" dependencies = [ "arc-swap", "bytes", - "futures-util", + "either", + "fs-err", "http", "http-body", - "http-body-util", "hyper", "hyper-util", "pin-project-lite", "rustls", - "rustls-pemfile", "rustls-pki-types", "tokio", "tokio-rustls", - "tower 0.4.13", "tower-service", ] [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -521,7 +551,16 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", ] [[package]] @@ -538,9 +577,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bigdecimal" -version = "0.4.5" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d712318a27c7150326677b321a5fa91b55f6d9034ffd67f20319e147d40cee" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -557,50 +596,191 @@ checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" [[package]] name = "bindgen" -version = "0.69.4" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", + "itertools 0.13.0", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", - "syn 2.0.77", - "which", + "syn 2.0.117", ] [[package]] -name = "bindgen" -version = "0.70.1" +name = "bit-vec" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4ff8b16e6076c3e14220b39fbc1fabb6737522281a388998046859400895f" + +[[package]] +name = "bitflags" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bittorrent-http-tracker-core" +version = "3.0.0-develop" dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.77", + "aquatic_udp_protocol", + "bittorrent-http-tracker-protocol", + "bittorrent-primitives", + "bittorrent-tracker-core", + "criterion 0.5.1", + "formatjson", + "futures", + "mockall", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "tracing", ] [[package]] -name = "bitflags" -version = "2.6.0" +name = "bittorrent-http-tracker-protocol" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "bittorrent-primitives", + "bittorrent-tracker-core", + "derive_more", + "multimap", + "percent-encoding", + "serde", + "serde_bencode", + "thiserror 2.0.18", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-contrib-bencode", + "torrust-tracker-located-error", + "torrust-tracker-primitives", +] + +[[package]] +name = "bittorrent-primitives" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "fdc1bd0462f0af0b57abd5f5f8f32b904ba0a17cc8be1714db160a054552f242" +dependencies = [ + "aquatic_udp_protocol", + "binascii", + "serde", + "serde_json", + "thiserror 1.0.69", + "zerocopy 0.7.35", +] + +[[package]] +name = "bittorrent-tracker-client" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "bittorrent-primitives", + "derive_more", + "hyper", + "percent-encoding", + "reqwest", + "serde", + "serde_bencode", + "serde_bytes", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "torrust-tracker-configuration", + "torrust-tracker-located-error", + "torrust-tracker-primitives", + "tracing", + "zerocopy 0.7.35", +] + +[[package]] +name = "bittorrent-tracker-core" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "bittorrent-primitives", + "chrono", + "derive_more", + "local-ip-address", + "mockall", + "r2d2", + "r2d2_mysql", + "r2d2_sqlite", + "rand 0.10.0", + "serde", + "serde_json", + "testcontainers", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "torrust-rest-tracker-api-client", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-located-error", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "tracing", + "url", +] + +[[package]] +name = "bittorrent-udp-tracker-core" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "bittorrent-primitives", + "bittorrent-tracker-core", + "bittorrent-udp-tracker-protocol", + "bloom", + "blowfish", + "cipher", + "criterion 0.5.1", + "futures", + "generic-array", + "lazy_static", + "mockall", + "rand 0.10.0", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "tracing", + "zerocopy 0.7.35", +] + +[[package]] +name = "bittorrent-udp-tracker-protocol" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "torrust-tracker-clock", + "torrust-tracker-primitives", +] [[package]] name = "bitvec" @@ -625,11 +805,11 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", "futures-lite", @@ -637,45 +817,137 @@ dependencies = [ ] [[package]] -name = "borsh" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6362ed55def622cddc70a4746a68554d7b687713770de539e59a739b249f8ed" -dependencies = [ - "borsh-derive", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.5.1" +name = "bloom" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" +checksum = "d00ac8e5056d6d65376a3c1aa5c7c34850d6949ace17f0266953a254eb3d6fe8" dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.77", - "syn_derive", + "bit-vec", ] [[package]] -name = "brotli" -version = "6.0.0" +name = "blowfish" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "byteorder", + "cipher", ] [[package]] -name = "brotli-decompressor" -version = "4.0.1" +name = "bollard" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -698,9 +970,9 @@ checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecheck" @@ -726,9 +998,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.18.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -738,15 +1010,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.2" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" -version = "1.1.9" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" dependencies = [ "serde", ] @@ -759,24 +1031,31 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] name = "cc" -version = "1.1.21" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -788,9 +1067,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -798,17 +1077,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-targets", + "windows-link", ] [[package]] @@ -838,6 +1127,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -851,9 +1150,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.18" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -861,9 +1160,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.18" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -873,36 +1172,46 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.18" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" -version = "0.1.51" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] [[package]] name = "compact_str" @@ -917,6 +1226,26 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -926,6 +1255,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -936,6 +1274,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -944,18 +1292,27 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -970,7 +1327,7 @@ dependencies = [ "cast", "ciborium", "clap", - "criterion-plot", + "criterion-plot 0.5.0", "futures", "is-terminal", "itertools 0.10.5", @@ -988,6 +1345,32 @@ dependencies = [ "walkdir", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot 0.8.2", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + [[package]] name = "criterion-plot" version = "0.5.0" @@ -998,6 +1381,16 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -1013,18 +1406,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1041,9 +1434,9 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] @@ -1060,21 +1453,21 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -1082,37 +1475,72 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ - "darling_core", - "darling_macro", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] name = "darling_core" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.77", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] @@ -1131,46 +1559,85 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", ] [[package]] name = "derive_more" -version = "1.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "1.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.77", + "rustc_version", + "syn 2.0.117", "unicode-xid", ] [[package]] name = "derive_utils" -version = "0.14.2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65f152f4b8559c4da5d574bafc7af85454d706b4c5fe8b530d508cacbb6807ea" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1181,6 +1648,28 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + [[package]] name = "downcast" version = "0.11.0" @@ -1193,45 +1682,71 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] -name = "env_logger" -version = "0.8.4" +name = "env_filter" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", ] [[package]] -name = "equivalent" -version = "1.0.1" +name = "env_logger" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", ] [[package]] @@ -1242,9 +1757,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1253,11 +1768,11 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.3.1", + "event-listener 5.4.1", "pin-project-lite", ] @@ -1275,9 +1790,20 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.1.1" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ferroid" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand 0.9.2", + "web-time", +] [[package]] name = "figment" @@ -1290,16 +1816,33 @@ dependencies = [ "pear", "serde", "tempfile", - "toml", + "toml 0.8.23", "uncased", "version_check", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" -version = "1.0.33" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "libz-sys", @@ -1312,6 +1855,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1329,13 +1884,23 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "formatjson" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3ba17cfe2aff8969f35b2bffec13b34756c51ea53eadcc5d5446f71370e2ed" +dependencies = [ + "miette", + "thiserror 1.0.69", +] + [[package]] name = "forwarded-header-value" version = "0.1.1" @@ -1343,20 +1908,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" dependencies = [ "nonempty", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "fragile" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "frunk" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874b6a17738fc273ec753618bac60ddaeac48cb1d7684c3e7bd472e57a28b817" +checksum = "28aef0f9aa070bce60767c12ba9cb41efeaf1a2bc6427f87b7d83f11239a16d7" dependencies = [ "frunk_core", "frunk_derives", @@ -1366,46 +1931,56 @@ dependencies = [ [[package]] name = "frunk_core" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3529a07095650187788833d585c219761114005d5976185760cf794d265b6a5c" +checksum = "476eeaa382e3462b84da5d6ba3da97b5786823c2d0d3a0d04ef088d073da225c" dependencies = [ "serde", ] [[package]] name = "frunk_derives" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e99b8b3c28ae0e84b604c75f721c21dc77afb3706076af5e8216d15fd1deaae3" +checksum = "a0b4095fc99e1d858e5b8c7125d2638372ec85aa0fe6c807105cf10b0265ca6c" dependencies = [ "frunk_proc_macro_helpers", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "frunk_proc_macro_helpers" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05a956ef36c377977e512e227dcad20f68c2786ac7a54dacece3746046fea5ce" +checksum = "1952b802269f2db12ab7c0bd328d0ae8feaabf19f352a7b0af7bb0c5693abfce" dependencies = [ "frunk_core", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "frunk_proc_macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67e86c2c9183662713fea27ea527aad20fb15fee635a71081ff91bf93df4dc51" +checksum = "3462f590fa236005bd7ca4847f81438bd6fe0febd4d04e11968d4c2e96437e78" dependencies = [ "frunk_core", "frunk_proc_macro_helpers", "quote", - "syn 2.0.77", + "syn 2.0.117", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", ] [[package]] @@ -1422,9 +1997,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1437,9 +2012,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1447,15 +2022,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1464,15 +2039,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" -version = "2.3.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -1483,26 +2058,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -1512,9 +2087,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1524,7 +2099,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1540,26 +2114,68 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "gimli" -version = "0.31.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" @@ -1575,9 +2191,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.6" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -1585,7 +2201,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.5.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1594,12 +2210,13 @@ dependencies = [ [[package]] name = "half" -version = "2.4.1" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy 0.8.40", ] [[package]] @@ -1608,7 +2225,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash 0.7.8", + "ahash", ] [[package]] @@ -1616,25 +2233,35 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash 0.8.11", "allocator-api2", + "equivalent", + "foldhash 0.1.5", ] [[package]] -name = "hashlink" -version = "0.9.1" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "hashbrown 0.14.5", + "foldhash 0.2.0", ] [[package]] -name = "heck" -version = "0.4.1" +name = "hashlink" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -1644,15 +2271,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1662,27 +2283,26 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-literal" -version = "0.4.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" [[package]] name = "home" -version = "0.5.9" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "http" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1698,12 +2318,12 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "pin-project-lite", @@ -1711,9 +2331,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1723,13 +2343,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.4.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -1737,18 +2358,33 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" -version = "0.27.3" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http", "hyper", "hyper-util", @@ -1760,51 +2396,69 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "bytes", - "http-body-util", "hyper", "hyper-util", - "native-tls", + "pin-project-lite", "tokio", - "tokio-native-tls", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.8" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", + "system-configuration", "tokio", - "tower 0.4.13", "tower-service", "tracing", + "windows-registry", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1818,6 +2472,93 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1826,12 +2567,23 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.5.0" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1847,13 +2599,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.16.1", "serde", + "serde_core", ] [[package]] @@ -1862,37 +2615,62 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-enum" -version = "1.1.3" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b53d712d99a73eec59ee5e4fe6057f8052142d38eeafbbffcb06b36d738a6e" +checksum = "7de9008599afe8527a8c9d70423437363b321649161e98473f433de802d76107" dependencies = [ "derive_utils", ] [[package]] name = "ipnet" -version = "2.10.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "is-terminal" -version = "0.4.13" +name = "iri-string" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", + "memchr", + "serde", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -1905,43 +2683,67 @@ dependencies = [ [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.70" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1961,38 +2763,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "lazycell" -version = "1.3.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets", + "windows-link", ] [[package]] name = "libm" -version = "0.2.8" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -2001,9 +2815,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.20" +version = "1.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" dependencies = [ "cc", "pkg-config", @@ -2012,61 +2826,101 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ip-address" -version = "0.6.3" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3669cf5561f8d27e8fc84cc15e58350e70f557d4d65f70e3154e54cd2f8e1782" +checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" dependencies = [ "libc", "neli", - "thiserror", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.22" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] [[package]] name = "lru" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "mime" @@ -2082,36 +2936,30 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.2" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ - "hermit-abi 0.3.9", "libc", "wasi", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] -[[package]] -name = "mirai-annotations" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" - [[package]] name = "mockall" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c28b3fb6d753d28c20e826cd46ee611fda1cf3cde03a443a974043247c065a" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" dependencies = [ "cfg-if", "downcast", @@ -2123,21 +2971,21 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "341014e7f530314e9a1fdbc7400b244efea7122662c96bfa248c31da5bfb2020" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" dependencies = [ "serde", ] @@ -2162,27 +3010,27 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "socket2", + "socket2 0.5.10", "twox-hash", "url", ] [[package]] name = "mysql-common-derive" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afe0450cc9344afff34915f8328600ab5ae19260802a334d0f72d2d5bdda3bfe" +checksum = "63c3512cf11487168e0e9db7157801bf5273be13055a9cc95356dc9e0035e49c" dependencies = [ - "darling", - "heck 0.4.1", + "darling 0.20.11", + "heck", "num-bigint", "proc-macro-crate", - "proc-macro-error", + "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", "termcolor", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2193,7 +3041,7 @@ checksum = "478b0ff3f7d67b79da2b96f56f334431aef65e15ba4b29dd74a4236e29582bdc" dependencies = [ "base64 0.21.7", "bigdecimal", - "bindgen 0.70.1", + "bindgen", "bitflags", "bitvec", "btoi", @@ -2208,7 +3056,7 @@ dependencies = [ "mysql-common-derive", "num-bigint", "num-traits", - "rand", + "rand 0.8.5", "regex", "rust_decimal", "saturating", @@ -2218,7 +3066,7 @@ dependencies = [ "sha2", "smallvec", "subprocess", - "thiserror", + "thiserror 1.0.69", "time", "uuid", "zstd", @@ -2235,9 +3083,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -2252,27 +3100,31 @@ dependencies = [ [[package]] name = "neli" -version = "0.6.4" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1100229e06604150b3becd61a4965d5c70f3be1759544ea7274166f4be41ef43" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ + "bitflags", "byteorder", + "derive_builder", + "getset", "libc", "log", "neli-proc-macros", + "parking_lot", ] [[package]] name = "neli-proc-macros" -version = "0.1.3" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c168194d373b1e134786274020dae7fc5513d565ea2ebb9bc9ff17ffb69106d4" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" dependencies = [ "either", "proc-macro2", "quote", "serde", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2293,12 +3145,25 @@ checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -2311,11 +3176,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2326,6 +3200,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2337,30 +3233,36 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oorandom" -version = "11.1.4" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.66" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags", "cfg-if", @@ -2379,20 +3281,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.103" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -2401,10 +3303,20 @@ dependencies = [ ] [[package]] -name = "overload" -version = "0.1.1" +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "page_size" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] [[package]] name = "parking" @@ -2414,9 +3326,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2424,22 +3336,41 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets", + "windows-link", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", ] [[package]] -name = "paste" -version = "1.0.15" +name = "parse-display-derive" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] [[package]] name = "pear" @@ -2461,39 +3392,39 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "pem" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_shared", ] [[package]] name = "phf_codegen" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", "phf_shared", @@ -2501,48 +3432,48 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] name = "phf_shared" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", ] [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2552,9 +3483,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2563,9 +3494,15 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plotters" @@ -2597,17 +3534,40 @@ dependencies = [ [[package]] name = "polling" -version = "3.7.3" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.4.0", + "hermit-abi", "pin-project-lite", "rustix", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", ] [[package]] @@ -2618,18 +3578,18 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.40", ] [[package]] name = "predicates" -version = "3.1.2" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -2637,68 +3597,76 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.8" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" -version = "0.2.22" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "proc-macro-crate" -version = "3.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro-error-attr", "proc-macro2", "quote", - "syn 1.0.109", - "version_check", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2711,11 +3679,43 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", "version_check", "yansi", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2738,50 +3738,112 @@ dependencies = [ [[package]] name = "quickcheck" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ "env_logger", "log", - "rand", + "rand 0.10.0", ] [[package]] -name = "quote" -version = "1.0.37" +name = "quinn" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r2d2" -version = "0.8.10" +name = "quinn-proto" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "r2d2_mysql" -version = "25.0.0" +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93963fe09ca35b0311d089439e944e42a6cb39bf8ea323782ddb31240ba2ae87" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "mysql", - "r2d2", -] - -[[package]] + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_mysql" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93963fe09ca35b0311d089439e944e42a6cb39bf8ea323782ddb31240ba2ae87" +dependencies = [ + "mysql", + "r2d2", +] + +[[package]] name = "r2d2_sqlite" -version = "0.25.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" dependencies = [ "r2d2", "rusqlite", @@ -2801,8 +3863,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -2812,7 +3895,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2821,14 +3914,29 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -2836,9 +3944,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -2846,18 +3954,47 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" -version = "1.10.6" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2867,9 +4004,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2878,9 +4015,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relative-path" @@ -2899,76 +4036,75 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.7" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", - "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", - "system-configuration", + "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-registry", ] [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "ringbuf" -version = "0.4.4" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f7f1b88601a8ee13cabf203611ccdf64345dc1c5d24de8b11e1a678ee619b6" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" dependencies = [ "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", ] [[package]] name = "rkyv" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", "bytecheck", @@ -2984,32 +4120,71 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rstest" -version = "0.22.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros 0.25.0", + "rustc_version", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ - "futures", "futures-timer", - "rstest_macros", + "futures-util", + "rstest_macros 0.26.1", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", "rustc_version", + "syn 2.0.117", + "unicode-ident", ] [[package]] name = "rstest_macros" -version = "0.22.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" dependencies = [ "cfg-if", "glob", @@ -3019,15 +4194,15 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.77", + "syn 2.0.117", "unicode-ident", ] [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags", "fallible-iterator", @@ -3035,19 +4210,20 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] name = "rust_decimal" -version = "1.36.0" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.5", "rkyv", "serde", "serde_json", @@ -3055,15 +4231,15 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -3076,25 +4252,27 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.37" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.13" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3102,26 +4280,59 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.1.3" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "base64 0.22.1", + "openssl-probe", "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -3131,15 +4342,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3158,11 +4369,11 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.24" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3174,6 +4385,30 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3188,12 +4423,12 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3201,9 +4436,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3211,16 +4446,17 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -3236,80 +4472,101 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "serde_html_form" -version = "0.2.6" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", - "indexmap 2.5.0", + "indexmap 2.13.0", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.13.0", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_path_to_error" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] name = "serde_repr" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "serde_spanned" -version = "0.6.7" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3324,17 +4581,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.9.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.5.0", - "serde", - "serde_derive", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3342,14 +4600,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.9.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] @@ -3359,18 +4617,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3391,13 +4649,20 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simdutf8" version = "0.1.5" @@ -3406,40 +4671,59 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] -name = "spin" -version = "0.9.8" +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3454,37 +4738,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "subprocess" -version = "0.2.9" +name = "structmeta" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2e86926081dda636c546d8c5e641661049d7562a68f5488be4a1f7f66f6086" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" dependencies = [ - "libc", - "winapi", + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", ] [[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" +name = "structmeta-derive" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", +] + +[[package]] +name = "subprocess" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c56e8662b206b9892d7a5a3f2ecdbcb455d3d6b259111373b7e08b8055158a8" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", ] +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" -version = "2.0.77" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", @@ -3492,40 +4809,44 @@ dependencies = [ ] [[package]] -name = "syn_derive" -version = "0.1.8" +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ - "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.77", + "unicode-ident", ] [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] -name = "sync_wrapper" -version = "1.0.1" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "futures-core", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3564,15 +4885,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.12.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3584,73 +4905,153 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "termtree" -version = "0.4.1" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "testcontainers" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c0624faaa317c56d6d19136580be889677259caf5c897941c6f446b4655068" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] name = "thiserror" -version = "1.0.64" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "1.0.64" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -3663,9 +5064,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -3678,58 +5079,57 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "native-tls", + "rustls", "tokio", ] [[package]] -name = "tokio-rustls" -version = "0.26.0" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "rustls", - "rustls-pki-types", + "futures-core", + "pin-project-lite", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3740,210 +5140,565 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.19" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" -version = "0.22.21" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.13.0", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", "winnow", ] [[package]] -name = "torrust-tracker" -version = "3.0.0-rc.1" +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "anyhow", - "aquatic_udp_protocol", + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", "axum", - "axum-client-ip", - "axum-extra", - "axum-server", - "camino", - "chrono", - "clap", - "crossbeam-skiplist", - "dashmap", - "derive_more", - "figment", - "futures", - "futures-util", - "hex-literal", + "base64 0.22.1", + "bytes", + "h2", + "http", "http-body", + "http-body-util", "hyper", + "hyper-timeout", "hyper-util", - "lazy_static", - "local-ip-address", - "mockall", - "multimap", - "parking_lot", "percent-encoding", - "pin-project-lite", - "r2d2", - "r2d2_mysql", - "r2d2_sqlite", - "rand", - "regex", + "pin-project", + "socket2 0.6.2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "torrust-axum-health-check-api-server" +version = "3.0.0-develop" +dependencies = [ + "axum", + "axum-server", + "futures", + "hyper", "reqwest", - "ringbuf", "serde", - "serde_bencode", - "serde_bytes", "serde_json", - "serde_repr", - "serde_with", - "thiserror", "tokio", + "torrust-axum-health-check-api-server", + "torrust-axum-http-tracker-server", + "torrust-axum-rest-tracker-api-server", + "torrust-axum-server", + "torrust-server-lib", "torrust-tracker-clock", "torrust-tracker-configuration", - "torrust-tracker-contrib-bencode", - "torrust-tracker-located-error", "torrust-tracker-primitives", "torrust-tracker-test-helpers", - "torrust-tracker-torrent-repository", - "tower 0.4.13", + "torrust-udp-tracker-server", "tower-http", - "trace", "tracing", "tracing-subscriber", "url", - "uuid", - "zerocopy", ] [[package]] -name = "torrust-tracker-clock" -version = "3.0.0-rc.1" +name = "torrust-axum-http-tracker-server" +version = "3.0.0-develop" dependencies = [ - "chrono", - "lazy_static", + "aquatic_udp_protocol", + "axum", + "axum-client-ip", + "axum-server", + "bittorrent-http-tracker-core", + "bittorrent-http-tracker-protocol", + "bittorrent-primitives", + "bittorrent-tracker-core", + "derive_more", + "futures", + "hyper", + "local-ip-address", + "percent-encoding", + "rand 0.10.0", + "reqwest", + "serde", + "serde_bencode", + "serde_bytes", + "serde_repr", + "tokio", + "tokio-util", + "torrust-axum-server", + "torrust-server-lib", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "tower", + "tower-http", + "tracing", + "uuid", + "zerocopy 0.7.35", ] [[package]] -name = "torrust-tracker-configuration" -version = "3.0.0-rc.1" +name = "torrust-axum-rest-tracker-api-server" +version = "3.0.0-develop" dependencies = [ - "camino", + "aquatic_udp_protocol", + "axum", + "axum-extra", + "axum-server", + "bittorrent-http-tracker-core", + "bittorrent-primitives", + "bittorrent-tracker-core", + "bittorrent-udp-tracker-core", "derive_more", - "figment", + "futures", + "hyper", + "local-ip-address", + "mockall", + "reqwest", "serde", "serde_json", "serde_with", - "thiserror", - "toml", - "torrust-tracker-located-error", - "url", - "uuid", -] + "thiserror 2.0.18", + "tokio", + "torrust-axum-server", + "torrust-rest-tracker-api-client", + "torrust-rest-tracker-api-core", + "torrust-server-lib", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "torrust-udp-tracker-server", + "tower", + "tower-http", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "torrust-axum-server" +version = "3.0.0-develop" +dependencies = [ + "axum-server", + "camino", + "futures-util", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "thiserror 2.0.18", + "tokio", + "torrust-server-lib", + "torrust-tracker-configuration", + "torrust-tracker-located-error", + "tower", + "tracing", +] + +[[package]] +name = "torrust-rest-tracker-api-client" +version = "3.0.0-develop" +dependencies = [ + "hyper", + "reqwest", + "serde", + "thiserror 2.0.18", + "url", + "uuid", +] + +[[package]] +name = "torrust-rest-tracker-api-core" +version = "3.0.0-develop" +dependencies = [ + "bittorrent-http-tracker-core", + "bittorrent-tracker-core", + "bittorrent-udp-tracker-core", + "tokio", + "tokio-util", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "torrust-udp-tracker-server", +] + +[[package]] +name = "torrust-server-lib" +version = "3.0.0-develop" +dependencies = [ + "derive_more", + "rstest 0.25.0", + "tokio", + "torrust-tracker-primitives", + "tower-http", + "tracing", +] + +[[package]] +name = "torrust-tracker" +version = "3.0.0-develop" +dependencies = [ + "anyhow", + "axum-server", + "bittorrent-http-tracker-core", + "bittorrent-primitives", + "bittorrent-tracker-client", + "bittorrent-tracker-core", + "bittorrent-udp-tracker-core", + "chrono", + "clap", + "local-ip-address", + "mockall", + "rand 0.10.0", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "torrust-axum-health-check-api-server", + "torrust-axum-http-tracker-server", + "torrust-axum-rest-tracker-api-server", + "torrust-axum-server", + "torrust-rest-tracker-api-client", + "torrust-rest-tracker-api-core", + "torrust-server-lib", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", + "torrust-udp-tracker-server", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "torrust-tracker-client" +version = "3.0.0-develop" +dependencies = [ + "anyhow", + "aquatic_udp_protocol", + "bittorrent-primitives", + "bittorrent-tracker-client", + "clap", + "futures", + "hex-literal", + "hyper", + "reqwest", + "serde", + "serde_bencode", + "serde_bytes", + "serde_json", + "thiserror 2.0.18", + "tokio", + "torrust-tracker-configuration", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "torrust-tracker-clock" +version = "3.0.0-develop" +dependencies = [ + "chrono", + "lazy_static", + "torrust-tracker-primitives", + "tracing", +] + +[[package]] +name = "torrust-tracker-configuration" +version = "3.0.0-develop" +dependencies = [ + "camino", + "derive_more", + "figment", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "torrust-tracker-located-error", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] [[package]] name = "torrust-tracker-contrib-bencode" -version = "3.0.0-rc.1" +version = "3.0.0-develop" +dependencies = [ + "criterion 0.8.2", + "thiserror 2.0.18", +] + +[[package]] +name = "torrust-tracker-events" +version = "3.0.0-develop" dependencies = [ - "criterion", - "thiserror", + "futures", + "mockall", + "tokio", ] [[package]] name = "torrust-tracker-located-error" -version = "3.0.0-rc.1" +version = "3.0.0-develop" +dependencies = [ + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "torrust-tracker-metrics" +version = "3.0.0-develop" dependencies = [ - "thiserror", + "approx", + "chrono", + "derive_more", + "formatjson", + "pretty_assertions", + "rstest 0.25.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "torrust-tracker-primitives", "tracing", ] [[package]] name = "torrust-tracker-primitives" -version = "3.0.0-rc.1" +version = "3.0.0-develop" dependencies = [ "aquatic_udp_protocol", "binascii", + "bittorrent-primitives", "derive_more", + "rstest 0.25.0", "serde", "tdyne-peer-id", "tdyne-peer-id-registry", - "thiserror", - "zerocopy", + "thiserror 2.0.18", + "torrust-tracker-configuration", + "url", + "zerocopy 0.7.35", +] + +[[package]] +name = "torrust-tracker-swarm-coordination-registry" +version = "3.0.0-develop" +dependencies = [ + "aquatic_udp_protocol", + "async-std", + "bittorrent-primitives", + "chrono", + "criterion 0.8.2", + "crossbeam-skiplist", + "futures", + "mockall", + "rand 0.10.0", + "rstest 0.26.1", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-test-helpers", + "tracing", ] [[package]] name = "torrust-tracker-test-helpers" -version = "3.0.0-rc.1" +version = "3.0.0-develop" dependencies = [ - "rand", + "rand 0.10.0", "torrust-tracker-configuration", + "tracing", + "tracing-subscriber", ] [[package]] -name = "torrust-tracker-torrent-repository" -version = "3.0.0-rc.1" +name = "torrust-tracker-torrent-repository-benchmarking" +version = "3.0.0-develop" dependencies = [ "aquatic_udp_protocol", "async-std", - "criterion", + "bittorrent-primitives", + "criterion 0.8.2", "crossbeam-skiplist", "dashmap", "futures", "parking_lot", - "rstest", + "rstest 0.26.1", "tokio", "torrust-tracker-clock", "torrust-tracker-configuration", "torrust-tracker-primitives", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +name = "torrust-udp-tracker-server" +version = "3.0.0-develop" dependencies = [ - "futures-core", + "aquatic_udp_protocol", + "bittorrent-primitives", + "bittorrent-tracker-client", + "bittorrent-tracker-core", + "bittorrent-udp-tracker-core", + "derive_more", + "futures", "futures-util", - "pin-project", - "pin-project-lite", + "local-ip-address", + "mockall", + "rand 0.10.0", + "ringbuf", + "serde", + "thiserror 2.0.18", "tokio", - "tower-layer", - "tower-service", + "tokio-util", + "torrust-server-lib", + "torrust-tracker-clock", + "torrust-tracker-configuration", + "torrust-tracker-events", + "torrust-tracker-metrics", + "torrust-tracker-primitives", + "torrust-tracker-swarm-coordination-registry", + "torrust-tracker-test-helpers", "tracing", + "url", + "uuid", + "zerocopy 0.7.35", ] [[package]] name = "tower" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.13.0", "pin-project-lite", - "sync_wrapper 0.1.2", + "slab", + "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3951,19 +5706,22 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.1" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", "bitflags", "bytes", "futures-core", + "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -3982,22 +5740,11 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "trace" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ad0c048e114d19d1140662762bfdb10682f3bc806d8be18af846600214dd9af" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4007,20 +5754,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -4039,9 +5786,9 @@ dependencies = [ [[package]] name = "tracing-serde" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ "serde", "tracing-core", @@ -4049,9 +5796,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "serde", @@ -4077,15 +5824,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", - "rand", + "rand 0.8.5", "static_assertions", ] [[package]] name = "typenum" -version = "1.17.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uncased" @@ -4097,25 +5844,34 @@ dependencies = [ ] [[package]] -name = "unicode-bidi" -version = "0.3.15" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-ident" -version = "1.0.13" +name = "unicode-linebreak" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] -name = "unicode-normalization" -version = "0.1.24" +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -4129,18 +5885,58 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" -version = "2.5.2" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -4149,25 +5945,27 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.10.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom", - "rand", + "getrandom 0.4.1", + "js-sys", + "rand 0.9.2", + "wasm-bindgen", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.9.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "vcpkg" @@ -4202,53 +6000,60 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.93" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "cfg-if", - "once_cell", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.93" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "bumpalo", - "log", + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.77", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4256,43 +6061,87 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.77", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "which" -version = "4.4.2" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ - "either", - "home", - "once_cell", - "rustix", + "rustls-pki-types", ] [[package]] @@ -4313,11 +6162,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4328,41 +6177,81 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "windows-targets", + "proc-macro2", + "quote", + "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-registry" -version = "0.2.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ + "windows-link", "windows-result", "windows-strings", - "windows-targets", ] [[package]] name = "windows-result" -version = "0.2.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.1.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-result", - "windows-targets", + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -4371,16 +6260,40 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -4389,73 +6302,274 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "0.6.18" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "wyz" version = "0.5.1" @@ -4465,12 +6579,45 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -4478,7 +6625,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive 0.8.40", ] [[package]] @@ -4489,38 +6645,109 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.77", + "syn 2.0.117", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.1" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.13+zstd.1.5.6" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 4aa87e6e3..dbc39bdf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ repository.workspace = true rust-version.workspace = true version.workspace = true +[lib] +name = "torrust_tracker_lib" + [workspace.package] authors = ["Nautilus Cyberneering , Mick van Dijke "] categories = ["network-programming", "web-programming"] @@ -27,80 +30,47 @@ license = "AGPL-3.0-only" publish = true repository = "https://github.com/torrust/torrust-tracker" rust-version = "1.72" -version = "3.0.0-rc.1" +version = "3.0.0-develop" [dependencies] anyhow = "1" -aquatic_udp_protocol = "0" -axum = { version = "0", features = ["macros"] } -axum-client-ip = "0" -axum-extra = { version = "0", features = ["query"] } -axum-server = { version = "0", features = ["tls-rustls"] } -camino = { version = "1", features = ["serde", "serde1"] } +axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "packages/http-tracker-core" } +bittorrent-tracker-core = { version = "3.0.0-develop", path = "packages/tracker-core" } +bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "packages/udp-tracker-core" } chrono = { version = "0", default-features = false, features = ["clock"] } clap = { version = "4", features = ["derive", "env"] } -crossbeam-skiplist = "0" -dashmap = "6" -derive_more = { version = "1", features = ["as_ref", "constructor", "from"] } -figment = "0" -futures = "0" -futures-util = "0" -hex-literal = "0" -http-body = "1" -hyper = "1" -hyper-util = { version = "0", features = ["http1", "http2", "tokio"] } -lazy_static = "1" -multimap = "0" -parking_lot = "0" -percent-encoding = "2" -pin-project-lite = "0" -r2d2 = "0" -r2d2_mysql = "25" -r2d2_sqlite = { version = "0", features = ["bundled"] } rand = "0" regex = "1" reqwest = { version = "0", features = ["json"] } -ringbuf = "0" serde = { version = "1", features = ["derive"] } -serde_bencode = "0" -serde_bytes = "0" serde_json = { version = "1", features = ["preserve_order"] } -serde_repr = "0" -serde_with = { version = "3", features = ["json"] } -thiserror = "1" +thiserror = "2.0.12" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } -torrust-tracker-clock = { version = "3.0.0-rc.1", path = "packages/clock" } -torrust-tracker-configuration = { version = "3.0.0-rc.1", path = "packages/configuration" } -torrust-tracker-contrib-bencode = { version = "3.0.0-rc.1", path = "contrib/bencode" } -torrust-tracker-located-error = { version = "3.0.0-rc.1", path = "packages/located-error" } -torrust-tracker-primitives = { version = "3.0.0-rc.1", path = "packages/primitives" } -torrust-tracker-torrent-repository = { version = "3.0.0-rc.1", path = "packages/torrent-repository" } -tower = { version = "0", features = ["timeout"] } -tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } -trace = "0" +tokio-util = "0.7.15" +torrust-axum-health-check-api-server = { version = "3.0.0-develop", path = "packages/axum-health-check-api-server" } +torrust-axum-http-tracker-server = { version = "3.0.0-develop", path = "packages/axum-http-tracker-server" } +torrust-axum-rest-tracker-api-server = { version = "3.0.0-develop", path = "packages/axum-rest-tracker-api-server" } +torrust-axum-server = { version = "3.0.0-develop", path = "packages/axum-server" } +torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "packages/rest-tracker-api-core" } +torrust-server-lib = { version = "3.0.0-develop", path = "packages/server-lib" } +torrust-tracker-clock = { version = "3.0.0-develop", path = "packages/clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "packages/configuration" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "packages/swarm-coordination-registry" } +torrust-udp-tracker-server = { version = "3.0.0-develop", path = "packages/udp-tracker-server" } tracing = "0" tracing-subscriber = { version = "0", features = ["json"] } -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["v4"] } -zerocopy = "0" - -[package.metadata.cargo-machete] -ignored = ["crossbeam-skiplist", "dashmap", "figment", "parking_lot", "serde_bytes"] [dev-dependencies] +bittorrent-primitives = "0.1.0" +bittorrent-tracker-client = { version = "3.0.0-develop", path = "packages/tracker-client" } local-ip-address = "0" mockall = "0" -torrust-tracker-test-helpers = { version = "3.0.0-rc.1", path = "packages/test-helpers" } +torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "packages/rest-tracker-api-client" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "packages/test-helpers" } [workspace] -members = [ - "contrib/bencode", - "packages/configuration", - "packages/located-error", - "packages/primitives", - "packages/test-helpers", - "packages/torrent-repository", -] +members = ["console/tracker-client", "packages/torrent-repository-benchmarking"] [profile.dev] debug = 1 @@ -115,3 +85,14 @@ opt-level = 3 [profile.release-debug] debug = true inherits = "release" + +[lints.clippy] +complexity = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +style = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } + +# temp allow this lint +needless_return = "allow" diff --git a/Containerfile b/Containerfile index 263053390..e926a5202 100644 --- a/Containerfile +++ b/Containerfile @@ -3,13 +3,13 @@ # Torrust Tracker ## Builder Image -FROM docker.io/library/rust:bookworm AS chef +FROM docker.io/library/rust:trixie AS chef WORKDIR /tmp RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash RUN cargo binstall --no-confirm cargo-chef cargo-nextest ## Tester Image -FROM docker.io/library/rust:slim-bookworm AS tester +FROM docker.io/library/rust:slim-trixie AS tester WORKDIR /tmp RUN apt-get update; apt-get install -y curl sqlite3; apt-get autoclean @@ -21,7 +21,7 @@ RUN mkdir -p /app/share/torrust/default/database/; \ sqlite3 /app/share/torrust/default/database/tracker.sqlite3.db "VACUUM;" ## Su Exe Compile -FROM docker.io/library/gcc:bookworm AS gcc +FROM docker.io/library/gcc:trixie AS gcc COPY ./contrib/dev-tools/su-exec/ /usr/local/src/su-exec/ RUN cc -Wall -Werror -g /usr/local/src/su-exec/su-exec.c -o /usr/local/bin/su-exec; chmod +x /usr/local/bin/su-exec @@ -91,7 +91,7 @@ RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin ## Runtime -FROM gcr.io/distroless/cc-debian12:debug AS runtime +FROM gcr.io/distroless/cc-debian13:debug AS runtime RUN ["/busybox/cp", "-sp", "/busybox/sh","/busybox/cat","/busybox/ls","/busybox/env", "/bin/"] COPY --from=gcc --chmod=0555 /usr/local/bin/su-exec /bin/su-exec diff --git a/README.md b/README.md index 6d611d9a5..bb102355b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,24 @@ - [x] Support [newTrackon][newtrackon] checks. - [x] Persistent `SQLite3` or `MySQL` Databases. +## Tracker Demo + +Experience the **Torrust Tracker** in action with our comprehensive demo environment! The [Torrust Demo][torrust-demo] repository provides a complete setup showcasing the tracker's capabilities in a real-world scenario. + +The demo takes full advantage of the tracker's powerful metrics system and seamless integration with [Prometheus][prometheus]. This allows you to monitor tracker performance, peer statistics, and system health in real-time. You can build sophisticated Grafana dashboards to visualize all aspects of your tracker's operation. + +![Sample Grafana Dashboard](./docs/media/demo/torrust-tracker-grafana-dashboard.png) + +**Demo Features:** + +- Complete Docker Compose setup. +- Pre-configured Prometheus metrics collection. +- Sample Grafana dashboards for monitoring. +- Real-time tracker statistics and performance metrics. +- Easy deployment for testing and evaluation. + +Visit the [Torrust Demo repository][torrust-demo] to get started with your own tracker instance and explore the monitoring capabilities. + ## Roadmap Core: @@ -40,16 +58,16 @@ Protocols: Integrations: -- [ ] Monitoring (Prometheus). +- [x] Monitoring (Prometheus). Utils: -- [ ] Tracker client. -- [ ] Tracker checker. +- [ ] Tracker client. WIP. +- [ ] Tracker checker. WIP. Others: -- [ ] Support for Windows. +- [ ] Intensive testing for Windows. - [ ] Docker images for other architectures. @@ -65,6 +83,12 @@ Others: - [BEP 27]: Private Torrents. - [BEP 48]: Tracker Protocol Extension: Scrape. +## Architecture + +![Torrust Tracker Layers with main packages](./docs/media/packages/torrust-tracker-layers-with-packages.png) + +There is also extra [documentation about the packages](./docs/packages.md). + ## Getting Started ### Container Version @@ -98,7 +122,7 @@ podman run -it docker.io/torrust/tracker:develop # Checkout repository into a new folder: git clone https://github.com/torrust/torrust-tracker.git -# Change into directory and create a empty database file: +# Change into directory and create an empty database file: cd torrust-tracker mkdir -p ./storage/tracker/lib/database/ touch ./storage/tracker/lib/database/sqlite3.db @@ -167,6 +191,8 @@ Some specific sections: - [Tracker (HTTP/TLS)][HTTP] - [Tracker (UDP)][UDP] +There is also extra documentation in the [docs](./docs) folder. + ## Benchmarking - [Benchmarking](./docs/benchmarking.md) @@ -266,3 +292,5 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [Naim A.]: https://github.com/naim94a/udpt [greatest-ape]: https://github.com/greatest-ape/aquatic [Power2All]: https://github.com/power2all +[torrust-demo]: https://github.com/torrust/torrust-demo +[prometheus]: https://prometheus.io/ diff --git a/cSpell.json b/cSpell.json index 6a9da0324..81421e050 100644 --- a/cSpell.json +++ b/cSpell.json @@ -5,6 +5,7 @@ "alekitto", "appuser", "Arvid", + "ASMS", "asyn", "autoclean", "AUTOINCREMENT", @@ -14,6 +15,7 @@ "bdecode", "bencode", "bencoded", + "bencoding", "beps", "binascii", "binstall", @@ -30,7 +32,10 @@ "canonicalized", "certbot", "chrono", + "Cinstrument", + "ciphertext", "clippy", + "cloneable", "codecov", "codegen", "completei", @@ -52,14 +57,18 @@ "downloadedi", "dtolnay", "elif", + "endianness", "Eray", "filesd", "flamegraph", + "formatjson", "Freebox", "Frostegård", "gecos", + "Gibibytes", "Grcov", "hasher", + "healthcheck", "heaptrack", "hexlify", "hlocalhost", @@ -81,6 +90,7 @@ "kcachegrind", "kexec", "keyout", + "Kibibytes", "kptr", "lcov", "leecher", @@ -91,12 +101,14 @@ "LOGNAME", "Lphant", "matchmakes", + "Mebibytes", "metainfo", "middlewares", "misresolved", "mockall", "multimap", "myacicontext", + "ñaca", "Naim", "nanos", "newkey", @@ -117,6 +129,7 @@ "proto", "Quickstart", "Radeon", + "Rakshasa", "Rasterbar", "realpath", "reannounce", @@ -132,6 +145,7 @@ "routable", "rstest", "rusqlite", + "rustc", "RUSTDOCFLAGS", "RUSTFLAGS", "rustfmt", @@ -151,7 +165,9 @@ "Swiftbit", "taiki", "tdyne", + "Tebibytes", "tempfile", + "testcontainers", "thiserror", "tlsv", "Torrentstorm", @@ -160,7 +176,10 @@ "trackerid", "Trackon", "typenum", + "udpv", "Unamed", + "underflows", + "Unsendable", "untuple", "uroot", "Vagaa", diff --git a/console/tracker-client/Cargo.toml b/console/tracker-client/Cargo.toml new file mode 100644 index 000000000..d4ab7c9e3 --- /dev/null +++ b/console/tracker-client/Cargo.toml @@ -0,0 +1,39 @@ +[package] +description = "A collection of console clients to make requests to BitTorrent trackers." +keywords = ["bittorrent", "client", "tracker"] +license = "LGPL-3.0" +name = "torrust-tracker-client" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +anyhow = "1" +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +bittorrent-tracker-client = { version = "3.0.0-develop", path = "../../packages/tracker-client" } +clap = { version = "4", features = ["derive", "env"] } +futures = "0" +hex-literal = "1" +hyper = "1" +reqwest = { version = "0", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_bencode = "0" +serde_bytes = "0" +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../../packages/configuration" } +tracing = "0" +tracing-subscriber = { version = "0", features = ["json"] } +url = { version = "2", features = ["serde"] } + +[package.metadata.cargo-machete] +ignored = ["serde_bytes"] diff --git a/console/tracker-client/README.md b/console/tracker-client/README.md new file mode 100644 index 000000000..87722657f --- /dev/null +++ b/console/tracker-client/README.md @@ -0,0 +1,199 @@ +# Torrust Tracker Client + +A collection of console clients to make requests to BitTorrent trackers. + +> **Disclaimer**: This project is actively under development. We’re currently extracting and refining common functionality from the[Torrust Tracker](https://github.com/torrust/torrust-tracker) to make it available to the BitTorrent community in Rust. While these tools are functional, they are not yet ready for use in production or third-party projects. + +There are currently three console clients available: + +- UDP Client +- HTTP Client +- Tracker Checker + +> **Notice**: [Console apps are planned to be merge into a single tracker client in the short-term](https://github.com/torrust/torrust-tracker/discussions/660). + +## UDP Client + +`Announce` request: + +```text +cargo run --bin udp_tracker_client announce udp://127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +``` + +`Announce` response: + +```json +{ + "AnnounceIpv4": { + "transaction_id": -888840697, + "announce_interval": 120, + "leechers": 0, + "seeders": 1, + "peers": [] + } +} +``` + +`Scrape` request: + +```text +cargo run --bin udp_tracker_client scrape udp://127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +``` + +`Scrape` response: + +```json +{ + "Scrape": { + "transaction_id": -888840697, + "torrent_stats": [ + { + "seeders": 1, + "completed": 0, + "leechers": 0 + } + ] + } +} +``` + +## HTTP Client + +`Announce` request: + +```text +cargo run --bin http_tracker_client announce http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq +``` + +`Announce` response: + +```json +{ + "complete": 1, + "incomplete": 0, + "interval": 120, + "min interval": 120, + "peers": [] +} +``` + +`Scrape` request: + +```text + cargo run --bin http_tracker_client scrape http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq +``` + +`Scrape` response: + +```json +{ + "9c38422213e30bff212b30c360d26f9a02136422": { + "complete": 1, + "downloaded": 1, + "incomplete": 0 + } +} +``` + +## Tracker Checker + +The Tracker Checker is a tool to check the health of a list of trackers. + +```console +TORRUST_CHECKER_CONFIG='{ + "udp_trackers": ["127.0.0.1:6969"], + "http_trackers": ["http://127.0.0.1:7070"], + "health_checks": ["http://127.0.0.1:1212/api/health_check"] + }' cargo run --bin tracker_checker +``` + +Output: + +```json +[ + { + "Udp": { + "Ok": { + "remote_addr": "127.0.0.1:6969", + "results": [ + [ + "Setup", + { + "Ok": null + } + ], + [ + "Connect", + { + "Ok": null + } + ], + [ + "Announce", + { + "Ok": null + } + ], + [ + "Scrape", + { + "Ok": null + } + ] + ] + } + } + }, + { + "Health": { + "Ok": { + "url": "http://127.0.0.1:1212/api/health_check", + "result": { + "Ok": "200 OK" + } + } + } + }, + { + "Http": { + "Ok": { + "url": "http://127.0.0.1:7070/", + "results": [ + [ + "Announce", + { + "Ok": null + } + ], + [ + "Scrape", + { + "Ok": null + } + ] + ] + } + } + } +] +``` + +## License + +**Copyright (c) 2024 The Torrust Developers.** + +This program is free software: you can redistribute it and/or modify it under the terms of the [GNU Lesser General Public License][LGPL_3_0] as published by the [Free Software Foundation][FSF], version 3. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Lesser General Public License][LGPL_3_0] for more details. + +You should have received a copy of the *GNU Lesser General Public License* along with this program. If not, see . + +Some files include explicit copyright notices and/or license notices. + +### Legacy Exception + +For prosperity, versions of Torrust BitTorrent Tracker Client that are older than five years are automatically granted the [MIT-0][MIT_0] license in addition to the existing [LGPL-3.0-only][LGPL_3_0] license. + +[LGPL_3_0]: ./LICENSE +[MIT_0]: ./docs/licenses/LICENSE-MIT_0 +[FSF]: https://www.fsf.org/ diff --git a/console/tracker-client/docs/licenses/LICENSE-MIT_0 b/console/tracker-client/docs/licenses/LICENSE-MIT_0 new file mode 100644 index 000000000..fc06cc4fe --- /dev/null +++ b/console/tracker-client/docs/licenses/LICENSE-MIT_0 @@ -0,0 +1,14 @@ +MIT No Attribution + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/bin/http_tracker_client.rs b/console/tracker-client/src/bin/http_tracker_client.rs similarity index 68% rename from src/bin/http_tracker_client.rs rename to console/tracker-client/src/bin/http_tracker_client.rs index 0de040549..be1b4821d 100644 --- a/src/bin/http_tracker_client.rs +++ b/console/tracker-client/src/bin/http_tracker_client.rs @@ -1,5 +1,5 @@ //! Program to make request to HTTP trackers. -use torrust_tracker::console::clients::http::app; +use torrust_tracker_client::console::clients::http::app; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/src/bin/tracker_checker.rs b/console/tracker-client/src/bin/tracker_checker.rs similarity index 67% rename from src/bin/tracker_checker.rs rename to console/tracker-client/src/bin/tracker_checker.rs index 87aeedeac..3ff78eec1 100644 --- a/src/bin/tracker_checker.rs +++ b/console/tracker-client/src/bin/tracker_checker.rs @@ -1,5 +1,5 @@ //! Program to check running trackers. -use torrust_tracker::console::clients::checker::app; +use torrust_tracker_client::console::clients::checker::app; #[tokio::main] async fn main() { diff --git a/src/bin/udp_tracker_client.rs b/console/tracker-client/src/bin/udp_tracker_client.rs similarity index 68% rename from src/bin/udp_tracker_client.rs rename to console/tracker-client/src/bin/udp_tracker_client.rs index 909b296ca..caf5ab0dc 100644 --- a/src/bin/udp_tracker_client.rs +++ b/console/tracker-client/src/bin/udp_tracker_client.rs @@ -1,5 +1,5 @@ //! Program to make request to UDP trackers. -use torrust_tracker::console::clients::udp::app; +use torrust_tracker_client::console::clients::udp::app; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/src/console/clients/checker/app.rs b/console/tracker-client/src/console/clients/checker/app.rs similarity index 98% rename from src/console/clients/checker/app.rs rename to console/tracker-client/src/console/clients/checker/app.rs index 395f65df9..88ce5a8ac 100644 --- a/src/console/clients/checker/app.rs +++ b/console/tracker-client/src/console/clients/checker/app.rs @@ -114,7 +114,7 @@ fn setup_config(args: Args) -> Result { } fn load_config_from_file(path: &PathBuf) -> Result { - let file_content = std::fs::read_to_string(path).with_context(|| format!("can't read config file {path:?}"))?; + let file_content = std::fs::read_to_string(path).with_context(|| format!("can't read config file {}", path.display()))?; parse_from_json(&file_content).context("invalid config format") } diff --git a/src/console/clients/checker/checks/health.rs b/console/tracker-client/src/console/clients/checker/checks/health.rs similarity index 100% rename from src/console/clients/checker/checks/health.rs rename to console/tracker-client/src/console/clients/checker/checks/health.rs diff --git a/src/console/clients/checker/checks/http.rs b/console/tracker-client/src/console/clients/checker/checks/http.rs similarity index 88% rename from src/console/clients/checker/checks/http.rs rename to console/tracker-client/src/console/clients/checker/checks/http.rs index 0904f4e6e..1a69d9c22 100644 --- a/src/console/clients/checker/checks/http.rs +++ b/console/tracker-client/src/console/clients/checker/checks/http.rs @@ -1,14 +1,14 @@ use std::str::FromStr as _; use std::time::Duration; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_client::http::client::responses::announce::Announce; +use bittorrent_tracker_client::http::client::responses::scrape; +use bittorrent_tracker_client::http::client::{requests, Client}; use serde::Serialize; -use torrust_tracker_primitives::info_hash::InfoHash; use url::Url; use crate::console::clients::http::Error; -use crate::shared::bit_torrent::tracker::http::client::responses::announce::Announce; -use crate::shared::bit_torrent::tracker::http::client::responses::scrape; -use crate::shared::bit_torrent::tracker::http::client::{requests, Client}; #[derive(Debug, Clone, Serialize)] pub struct Checks { @@ -61,7 +61,7 @@ pub async fn run(http_trackers: Vec, timeout: Duration) -> Vec Result { - let info_hash_str = "9c38422213e30bff212b30c360d26f9a02136422".to_string(); // # DevSkim: ignore DS173237 + let info_hash_str = "9c38422213e30bff212b30c360d26f9a02136422".to_string(); // DevSkim: ignore DS173237 let info_hash = InfoHash::from_str(&info_hash_str).expect("a valid info-hash is required"); let client = Client::new(url.clone(), timeout).map_err(|err| Error::HttpClientError { err })?; @@ -86,7 +86,7 @@ async fn check_http_announce(url: &Url, timeout: Duration) -> Result Result { - let info_hashes: Vec = vec!["9c38422213e30bff212b30c360d26f9a02136422".to_string()]; // # DevSkim: ignore DS173237 + let info_hashes: Vec = vec!["9c38422213e30bff212b30c360d26f9a02136422".to_string()]; // DevSkim: ignore DS173237 let query = requests::scrape::Query::try_from(info_hashes).expect("a valid array of info-hashes is required"); let client = Client::new(url.clone(), timeout).map_err(|err| Error::HttpClientError { err })?; diff --git a/src/console/clients/checker/checks/mod.rs b/console/tracker-client/src/console/clients/checker/checks/mod.rs similarity index 100% rename from src/console/clients/checker/checks/mod.rs rename to console/tracker-client/src/console/clients/checker/checks/mod.rs diff --git a/src/console/clients/checker/checks/structs.rs b/console/tracker-client/src/console/clients/checker/checks/structs.rs similarity index 100% rename from src/console/clients/checker/checks/structs.rs rename to console/tracker-client/src/console/clients/checker/checks/structs.rs diff --git a/src/console/clients/checker/checks/udp.rs b/console/tracker-client/src/console/clients/checker/checks/udp.rs similarity index 93% rename from src/console/clients/checker/checks/udp.rs rename to console/tracker-client/src/console/clients/checker/checks/udp.rs index 4044b4c52..611afafc4 100644 --- a/src/console/clients/checker/checks/udp.rs +++ b/console/tracker-client/src/console/clients/checker/checks/udp.rs @@ -29,7 +29,8 @@ pub async fn run(udp_trackers: Vec, timeout: Duration) -> Vec, timeout: Duration) -> Vec { checks.results.push((Check::Setup, Err(err))); results.push(Err(checks)); - break; + continue; } }; @@ -65,7 +66,7 @@ pub async fn run(udp_trackers: Vec, timeout: Duration) -> Vec { checks.results.push((Check::Connect, Err(err))); results.push(Err(checks)); - break; + continue; } }; @@ -117,8 +118,8 @@ mod tests { let socket_addr = resolve_socket_addr(&Url::parse("udp://localhost:8080").unwrap()); assert!( - socket_addr == SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080) - || socket_addr == SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8080) + socket_addr == SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) + || socket_addr == SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080) ); } @@ -127,8 +128,8 @@ mod tests { let socket_addr = resolve_socket_addr(&Url::parse("udp://localhost:8080").unwrap()); assert!( - socket_addr == SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080) - || socket_addr == SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8080) + socket_addr == SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) + || socket_addr == SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080) ); } } diff --git a/src/console/clients/checker/config.rs b/console/tracker-client/src/console/clients/checker/config.rs similarity index 100% rename from src/console/clients/checker/config.rs rename to console/tracker-client/src/console/clients/checker/config.rs diff --git a/src/console/clients/checker/console.rs b/console/tracker-client/src/console/clients/checker/console.rs similarity index 100% rename from src/console/clients/checker/console.rs rename to console/tracker-client/src/console/clients/checker/console.rs diff --git a/src/console/clients/checker/logger.rs b/console/tracker-client/src/console/clients/checker/logger.rs similarity index 100% rename from src/console/clients/checker/logger.rs rename to console/tracker-client/src/console/clients/checker/logger.rs diff --git a/src/console/clients/checker/mod.rs b/console/tracker-client/src/console/clients/checker/mod.rs similarity index 100% rename from src/console/clients/checker/mod.rs rename to console/tracker-client/src/console/clients/checker/mod.rs diff --git a/src/console/clients/checker/printer.rs b/console/tracker-client/src/console/clients/checker/printer.rs similarity index 100% rename from src/console/clients/checker/printer.rs rename to console/tracker-client/src/console/clients/checker/printer.rs diff --git a/src/console/clients/checker/service.rs b/console/tracker-client/src/console/clients/checker/service.rs similarity index 100% rename from src/console/clients/checker/service.rs rename to console/tracker-client/src/console/clients/checker/service.rs diff --git a/src/console/clients/http/app.rs b/console/tracker-client/src/console/clients/http/app.rs similarity index 88% rename from src/console/clients/http/app.rs rename to console/tracker-client/src/console/clients/http/app.rs index a54db5f8b..105b18bff 100644 --- a/src/console/clients/http/app.rs +++ b/console/tracker-client/src/console/clients/http/app.rs @@ -17,15 +17,14 @@ use std::str::FromStr; use std::time::Duration; use anyhow::Context; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_client::http::client::requests::announce::QueryBuilder; +use bittorrent_tracker_client::http::client::responses::announce::Announce; +use bittorrent_tracker_client::http::client::responses::scrape; +use bittorrent_tracker_client::http::client::{requests, Client}; use clap::{Parser, Subcommand}; use reqwest::Url; use torrust_tracker_configuration::DEFAULT_TIMEOUT; -use torrust_tracker_primitives::info_hash::InfoHash; - -use crate::shared::bit_torrent::tracker::http::client::requests::announce::QueryBuilder; -use crate::shared::bit_torrent::tracker::http::client::responses::announce::Announce; -use crate::shared::bit_torrent::tracker::http::client::responses::scrape; -use crate::shared::bit_torrent::tracker::http::client::{requests, Client}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] diff --git a/src/console/clients/http/mod.rs b/console/tracker-client/src/console/clients/http/mod.rs similarity index 84% rename from src/console/clients/http/mod.rs rename to console/tracker-client/src/console/clients/http/mod.rs index eaa71957f..917c94fa8 100644 --- a/src/console/clients/http/mod.rs +++ b/console/tracker-client/src/console/clients/http/mod.rs @@ -1,10 +1,9 @@ use std::sync::Arc; +use bittorrent_tracker_client::http::client::responses::scrape::BencodeParseError; use serde::Serialize; use thiserror::Error; -use crate::shared::bit_torrent::tracker::http::client::responses::scrape::BencodeParseError; - pub mod app; #[derive(Debug, Clone, Error, Serialize)] @@ -12,7 +11,7 @@ pub mod app; pub enum Error { #[error("Http request did not receive a response within the timeout: {err:?}")] HttpClientError { - err: crate::shared::bit_torrent::tracker::http::client::Error, + err: bittorrent_tracker_client::http::client::Error, }, #[error("Http failed to get a response at all: {err:?}")] ResponseError { err: Arc }, diff --git a/src/console/clients/mod.rs b/console/tracker-client/src/console/clients/mod.rs similarity index 100% rename from src/console/clients/mod.rs rename to console/tracker-client/src/console/clients/mod.rs diff --git a/src/console/clients/udp/app.rs b/console/tracker-client/src/console/clients/udp/app.rs similarity index 95% rename from src/console/clients/udp/app.rs rename to console/tracker-client/src/console/clients/udp/app.rs index c2ba647b8..527f46e78 100644 --- a/src/console/clients/udp/app.rs +++ b/console/tracker-client/src/console/clients/udp/app.rs @@ -61,9 +61,9 @@ use std::str::FromStr; use anyhow::Context; use aquatic_udp_protocol::{Response, TransactionId}; +use bittorrent_primitives::info_hash::InfoHash as TorrustInfoHash; use clap::{Parser, Subcommand}; use torrust_tracker_configuration::DEFAULT_TIMEOUT; -use torrust_tracker_primitives::info_hash::InfoHash as TorrustInfoHash; use tracing::level_filters::LevelFilter; use url::Url; @@ -176,8 +176,7 @@ fn parse_socket_addr(tracker_socket_addr_str: &str) -> anyhow::Result anyhow::Result = resolved_addr.to_socket_addrs()?.collect(); if socket_addrs.is_empty() { - Err(anyhow::anyhow!("DNS resolution failed for `{}`", tracker_socket_addr_str)) + Err(anyhow::anyhow!("DNS resolution failed for `{tracker_socket_addr_str}`")) } else { Ok(socket_addrs[0]) } diff --git a/src/console/clients/udp/checker.rs b/console/tracker-client/src/console/clients/udp/checker.rs similarity index 96% rename from src/console/clients/udp/checker.rs rename to console/tracker-client/src/console/clients/udp/checker.rs index 437af33e0..ded5c107e 100644 --- a/src/console/clients/udp/checker.rs +++ b/console/tracker-client/src/console/clients/udp/checker.rs @@ -7,10 +7,10 @@ use aquatic_udp_protocol::{ AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, Response, ScrapeRequest, TransactionId, }; -use torrust_tracker_primitives::info_hash::InfoHash as TorrustInfoHash; +use bittorrent_primitives::info_hash::InfoHash as TorrustInfoHash; +use bittorrent_tracker_client::udp::client::UdpTrackerClient; use super::Error; -use crate::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; /// A UDP Tracker client to make test requests (checks). #[derive(Debug)] @@ -116,7 +116,7 @@ impl Client { bytes_uploaded: NumberOfBytes(0i64.into()), bytes_left: NumberOfBytes(0i64.into()), event: AnnounceEvent::Started.into(), - ip_address: Ipv4Addr::new(0, 0, 0, 0).into(), + ip_address: Ipv4Addr::UNSPECIFIED.into(), key: PeerKey::new(0i32), peers_wanted: NumberOfPeers(1i32.into()), port: Port::new(port), diff --git a/src/console/clients/udp/mod.rs b/console/tracker-client/src/console/clients/udp/mod.rs similarity index 97% rename from src/console/clients/udp/mod.rs rename to console/tracker-client/src/console/clients/udp/mod.rs index b92bed096..fbfd53770 100644 --- a/src/console/clients/udp/mod.rs +++ b/console/tracker-client/src/console/clients/udp/mod.rs @@ -1,11 +1,10 @@ use std::net::SocketAddr; use aquatic_udp_protocol::Response; +use bittorrent_tracker_client::udp; use serde::Serialize; use thiserror::Error; -use crate::shared::bit_torrent::tracker::udp; - pub mod app; pub mod checker; pub mod responses; diff --git a/src/console/clients/udp/responses/dto.rs b/console/tracker-client/src/console/clients/udp/responses/dto.rs similarity index 100% rename from src/console/clients/udp/responses/dto.rs rename to console/tracker-client/src/console/clients/udp/responses/dto.rs diff --git a/src/console/clients/udp/responses/json.rs b/console/tracker-client/src/console/clients/udp/responses/json.rs similarity index 100% rename from src/console/clients/udp/responses/json.rs rename to console/tracker-client/src/console/clients/udp/responses/json.rs diff --git a/src/console/clients/udp/responses/mod.rs b/console/tracker-client/src/console/clients/udp/responses/mod.rs similarity index 100% rename from src/console/clients/udp/responses/mod.rs rename to console/tracker-client/src/console/clients/udp/responses/mod.rs diff --git a/console/tracker-client/src/console/mod.rs b/console/tracker-client/src/console/mod.rs new file mode 100644 index 000000000..4b4cb9de4 --- /dev/null +++ b/console/tracker-client/src/console/mod.rs @@ -0,0 +1,2 @@ +//! Console apps. +pub mod clients; diff --git a/console/tracker-client/src/lib.rs b/console/tracker-client/src/lib.rs new file mode 100644 index 000000000..5b9849fdc --- /dev/null +++ b/console/tracker-client/src/lib.rs @@ -0,0 +1 @@ +pub mod console; diff --git a/contrib/bencode/Cargo.toml b/contrib/bencode/Cargo.toml index e25a9b64f..f6355b6fc 100644 --- a/contrib/bencode/Cargo.toml +++ b/contrib/bencode/Cargo.toml @@ -16,7 +16,7 @@ rust-version.workspace = true version.workspace = true [dependencies] -thiserror = "1" +thiserror = "2" [dev-dependencies] criterion = "0" diff --git a/contrib/bencode/benches/bencode_benchmark.rs b/contrib/bencode/benches/bencode_benchmark.rs index b79bb0999..b22b286a5 100644 --- a/contrib/bencode/benches/bencode_benchmark.rs +++ b/contrib/bencode/benches/bencode_benchmark.rs @@ -1,4 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; use torrust_tracker_contrib_bencode::{BDecodeOpt, BencodeRef}; const B_NESTED_LISTS: &[u8; 100] = diff --git a/contrib/bencode/src/access/bencode.rs b/contrib/bencode/src/access/bencode.rs index ee90296e2..728535a98 100644 --- a/contrib/bencode/src/access/bencode.rs +++ b/contrib/bencode/src/access/bencode.rs @@ -50,7 +50,7 @@ pub trait BRefAccessExt<'a>: BRefAccess { fn bytes_ext(&self) -> Option<&'a [u8]>; } -impl<'a, T> BRefAccess for &'a T +impl BRefAccess for &T where T: BRefAccess, { diff --git a/contrib/bencode/src/lib.rs b/contrib/bencode/src/lib.rs index 09aaa6867..c44ec07b2 100644 --- a/contrib/bencode/src/lib.rs +++ b/contrib/bencode/src/lib.rs @@ -5,9 +5,9 @@ //! Decoding bencoded data: //! //! ```rust -//! extern crate bencode; +//! extern crate torrust_tracker_contrib_bencode; //! -//! use bencode::{BencodeRef, BRefAccess, BDecodeOpt}; +//! use torrust_tracker_contrib_bencode::{BencodeRef, BRefAccess, BDecodeOpt}; //! //! fn main() { //! let data = b"d12:lucky_numberi7ee"; // cspell:disable-line @@ -22,7 +22,7 @@ //! //! ```rust //! #[macro_use] -//! extern crate bencode; +//! extern crate torrust_tracker_contrib_bencode; //! //! fn main() { //! let message = (ben_map!{ diff --git a/contrib/bencode/src/mutable/bencode_mut.rs b/contrib/bencode/src/mutable/bencode_mut.rs index a3f95dbbf..21e00f7b0 100644 --- a/contrib/bencode/src/mutable/bencode_mut.rs +++ b/contrib/bencode/src/mutable/bencode_mut.rs @@ -82,10 +82,7 @@ impl<'a> BRefAccess for BencodeMut<'a> { fn str(&self) -> Option<&str> { let bytes = self.bytes()?; - match str::from_utf8(bytes) { - Ok(n) => Some(n), - Err(_) => None, - } + str::from_utf8(bytes).ok() } fn int(&self) -> Option { diff --git a/contrib/bencode/src/reference/bencode_ref.rs b/contrib/bencode/src/reference/bencode_ref.rs index 73aaad039..20d102cb4 100644 --- a/contrib/bencode/src/reference/bencode_ref.rs +++ b/contrib/bencode/src/reference/bencode_ref.rs @@ -107,10 +107,7 @@ impl<'a> BRefAccessExt<'a> for BencodeRef<'a> { fn str_ext(&self) -> Option<&'a str> { let bytes = self.bytes_ext()?; - match str::from_utf8(bytes) { - Ok(n) => Some(n), - Err(_) => None, - } + str::from_utf8(bytes).ok() } fn bytes_ext(&self) -> Option<&'a [u8]> { diff --git a/contrib/bencode/src/reference/decode.rs b/contrib/bencode/src/reference/decode.rs index 97c5cf1ff..37ca22549 100644 --- a/contrib/bencode/src/reference/decode.rs +++ b/contrib/bencode/src/reference/decode.rs @@ -129,7 +129,8 @@ fn decode_dict( }) } _ => (), - }; + } + curr_pos = next_pos; let (value, next_pos) = decode(bytes, curr_pos, opts, depth + 1)?; diff --git a/contrib/dev-tools/benches/run-benches.sh b/contrib/dev-tools/benches/run-benches.sh new file mode 100755 index 000000000..0de356492 --- /dev/null +++ b/contrib/dev-tools/benches/run-benches.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# This script is only intended to be used for local development or testing environments. + +cargo bench --package torrust-tracker-torrent-repository + +cargo bench --package bittorrent-http-tracker-core + +cargo bench --package bittorrent-udp-tracker-core diff --git a/contrib/dev-tools/git/hooks/pre-commit.sh b/contrib/dev-tools/git/hooks/pre-commit.sh new file mode 100755 index 000000000..c1b183fde --- /dev/null +++ b/contrib/dev-tools/git/hooks/pre-commit.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +cargo +nightly fmt --check && + cargo +nightly check --tests --benches --examples --workspace --all-targets --all-features && + cargo +nightly doc --no-deps --bins --examples --workspace --all-features && + cargo +nightly machete && + cargo +stable build && + CARGO_INCREMENTAL=0 cargo +stable clippy --no-deps --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic && + cargo +stable test --doc --workspace && + cargo +stable test --tests --benches --examples --workspace --all-targets --all-features diff --git a/contrib/dev-tools/git/hooks/pre-push.sh b/contrib/dev-tools/git/hooks/pre-push.sh new file mode 100755 index 000000000..593068cee --- /dev/null +++ b/contrib/dev-tools/git/hooks/pre-push.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +cargo +nightly fmt --check && + cargo +nightly check --tests --benches --examples --workspace --all-targets --all-features && + cargo +nightly doc --no-deps --bins --examples --workspace --all-features && + cargo +nightly machete && + cargo +stable build && + CARGO_INCREMENTAL=0 cargo +stable clippy --no-deps --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic && + cargo +stable test --doc --workspace && + cargo +stable test --tests --benches --examples --workspace --all-targets --all-features && + cargo +stable run --bin e2e_tests_runner -- --config-toml-path "./share/default/config/tracker.e2e.container.sqlite3.toml" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..873f3758b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,11 @@ +# Torrust Tracker Documentation + +For more detailed instructions, please view our [crate documentation][docs]. + +- [Benchmarking](benchmarking.md) +- [Containers](containers.md) +- [Packages](packages.md) +- [Profiling](profiling.md) +- [Releases process](release_process.md) + +[docs]: https://docs.rs/torrust-tracker/latest/torrust_tracker/ diff --git a/docs/media/demo/torrust-tracker-grafana-dashboard.png b/docs/media/demo/torrust-tracker-grafana-dashboard.png new file mode 100644 index 000000000..090932a8c Binary files /dev/null and b/docs/media/demo/torrust-tracker-grafana-dashboard.png differ diff --git a/docs/media/packages/packages-dependencies-http-tracker.png b/docs/media/packages/packages-dependencies-http-tracker.png new file mode 100644 index 000000000..45dbd9839 Binary files /dev/null and b/docs/media/packages/packages-dependencies-http-tracker.png differ diff --git a/docs/media/packages/packages-dependencies-udp-tracker.png b/docs/media/packages/packages-dependencies-udp-tracker.png new file mode 100644 index 000000000..57987a31e Binary files /dev/null and b/docs/media/packages/packages-dependencies-udp-tracker.png differ diff --git a/docs/media/packages/torrust-tracker-layers-with-packages.png b/docs/media/packages/torrust-tracker-layers-with-packages.png new file mode 100644 index 000000000..9da465806 Binary files /dev/null and b/docs/media/packages/torrust-tracker-layers-with-packages.png differ diff --git a/docs/media/torrust-tracker-components.png b/docs/media/torrust-tracker-components.png deleted file mode 100644 index 19fe3c0b8..000000000 Binary files a/docs/media/torrust-tracker-components.png and /dev/null differ diff --git a/docs/packages.md b/docs/packages.md new file mode 100644 index 000000000..118046a87 --- /dev/null +++ b/docs/packages.md @@ -0,0 +1,115 @@ +# Torrust Tracker Package Architecture + +- [Package Conventions](#package-conventions) +- [Package Catalog](#package-catalog) +- [Architectural Philosophy](#architectural-philosophy) +- [Protocol Implementation Details](#protocol-implementation-details) +- [Architectural Philosophy](#architectural-philosophy) + +```output +packages/ +├── axum-health-check-api-server +├── axum-http-tracker-server +├── axum-rest-tracker-api-server +├── axum-server +├── clock +├── configuration +├── http-protocol +├── http-tracker-core +├── located-error +├── primitives +├── rest-tracker-api-client +├── rest-tracker-api-core +├── server-lib +├── test-helpers +├── torrent-repository +├── tracker-client +├── tracker-core +├── udp-protocol +├── udp-tracker-core +└── udp-tracker-server +``` + +```output +console/ +└── tracker-client # Client for interacting with trackers +``` + +```output +contrib/ +└── bencode # Community-contributed Bencode utilities +``` + +## Package Conventions + +| Prefix | Responsibility | Dependencies | +|-----------------|-----------------------------------------|---------------------------| +| `axum-*` | HTTP server components using Axum | Axum framework | +| `*-server` | Server implementations | Corresponding *-core | +| `*-core` | Domain logic & business rules | Protocol implementations | +| `*-protocol` | BitTorrent protocol implementations | BitTorrent protocol | +| `udp-*` | UDP Protocol-specific implementations | Tracker core | +| `http-*` | HTTP Protocol-specific implementations | Tracker core | + +Key Architectural Principles: + +1. **Separation of Concerns**: Servers contain only network I/O logic. +2. **Protocol Compliance**: `*-protocol` packages strictly implement BEP specifications. +3. **Extensibility**: Core logic is framework-agnostic for easy protocol additions. + +## Package Catalog + +| Package | Description | Key Responsibilities | +|---------|-------------|----------------------| +| **axum-*** | | | +| `axum-server` | Base Axum HTTP server infrastructure | HTTP server lifecycle management | +| `axum-http-tracker-server` | BitTorrent HTTP tracker (BEP 3/23) | Handle announce/scrape requests | +| `axum-rest-tracker-api-server` | Management REST API | Tracker configuration & monitoring | +| `axum-health-check-api-server` | Health monitoring endpoint | System health reporting | +| **Core Components** | | | +| `http-tracker-core` | HTTP-specific implementation | Request validation, Response formatting | +| `udp-tracker-core` | UDP-specific implementation | Connectionless request handling | +| `tracker-core` | Central tracker logic | Peer management | +| **Protocols** | | | +| `http-protocol` | HTTP tracker protocol (BEP 3/23) | Announce/scrape request parsing | +| `udp-protocol` | UDP tracker protocol (BEP 15) | UDP message framing/parsing | +| **Domain** | | | +| `torrent-repository` | Torrent metadata storage | InfoHash management, Peer coordination | +| `configuration` | Runtime configuration | Config file parsing, Environment variables | +| `primitives` | Domain-specific types | InfoHash, PeerId, Byte handling | +| **Utilities** | | | +| `clock` | Time abstraction | Mockable time source for testing | +| `located-error` | Diagnostic errors | Error tracing with source locations | +| `test-helpers` | Testing utilities | Mock servers, Test data generation | +| **Client Tools** | | | +| `tracker-client` | CLI client | Tracker interaction/testing | +| `rest-tracker-api-client` | API client library | REST API integration | + +## Protocol Implementation Details + +### HTTP Tracker (BEP 3/23) + +- `http-protocol` implements: + - URL parameter parsing + - Response bencoding + - Error code mapping + - Compact peer formatting + +### UDP Tracker (BEP 15) + +- `udp-protocol` handles: + - Connection ID management + - Message framing (32-bit big-endian) + - Transaction ID tracking + - Error response codes + +## Architectural Philosophy + +1. **Testability**: Core packages have minimal dependencies for easy unit testing +2. **Observability**: Health checks and metrics built into server packages +3. **Modularity**: Protocol implementations decoupled from transport layers +4. **Extensibility**: New protocols can be added without modifying core logic + +![Torrust Tracker Architecture Diagram](./media/packages/torrust-tracker-layers-with-packages.png) + +> Diagram shows clean separation between network I/O (servers), protocol handling, and core tracker logic diff --git a/docs/release_process.md b/docs/release_process.md index 73b0a8827..f9d1cce71 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -1,11 +1,12 @@ -# Torrust Tracker Release Process (v2.2.2) +# Torrust Tracker Release Process (v2.2.2) + +## Version -## Version: > **The `[semantic version]` is bumped according to releases, new features, and breaking changes.** > > *The `develop` branch uses the (semantic version) suffix `-develop`.* -## Process: +## Process **Note**: this guide assumes that the your git `torrust` remote is like this: @@ -20,18 +21,18 @@ git remote show torrust ... ``` +### 1. The `develop` branch is ready for a release -### 1. The `develop` branch is ready for a release. The `develop` branch should have the version `[semantic version]-develop` that is ready to be released. -### 2. Stage `develop` HEAD for merging into the `main` branch: +### 2. Stage `develop` HEAD for merging into the `main` branch ```sh git fetch --all git push --force torrust develop:staging/main ``` -### 3. Create Release Commit: +### 3. Create Release Commit ```sh git stash @@ -43,13 +44,13 @@ git commit -m "release: version [semantic version]" git push torrust ``` -### 4. Create and Merge Pull Request from `staging/main` into `main` branch. +### 4. Create and Merge Pull Request from `staging/main` into `main` branch Pull request title format: "Release Version `[semantic version]`". This pull request merges the new version into the `main` branch. -### 5. Push new version from `main` HEAD to `releases/v[semantic version]` branch: +### 5. Push new version from `main` HEAD to `releases/v[semantic version]` branch ```sh git fetch --all @@ -58,7 +59,7 @@ git push torrust main:releases/v[semantic version] > **Check that the deployment is successful!** -### 6. Create Release Tag: +### 6. Create Release Tag ```sh git switch releases/v[semantic version] @@ -66,17 +67,31 @@ git tag --sign v[semantic version] git push --tags torrust ``` -### 7. Create Release on Github from Tag. +Make sure the [deployment](https://github.com/torrust/torrust-tracker/actions/workflows/deployment.yaml) workflow was successfully executed and the new version for the following crates were published: + +- [torrust-tracker-contrib-bencode](https://crates.io/crates/torrust-tracker-contrib-bencode) +- [torrust-tracker-located-error](https://crates.io/crates/torrust-tracker-located-error) +- [torrust-tracker-primitives](https://crates.io/crates/torrust-tracker-primitives) +- [torrust-tracker-clock](https://crates.io/crates/torrust-tracker-clock) +- [torrust-tracker-configuration](https://crates.io/crates/torrust-tracker-configuration) +- [torrust-tracker-torrent-repository](https://crates.io/crates/torrust-tracker-torrent-repository) +- [torrust-tracker-test-helpers](https://crates.io/crates/torrust-tracker-test-helpers) +- [torrust-tracker](https://crates.io/crates/torrust-tracker) + +### 7. Create Release on Github from Tag + This is for those who wish to download the source code. -### 8. Stage `main` HEAD for merging into the `develop` branch: +### 8. Stage `main` HEAD for merging into the `develop` branch + Merge release back into the develop branch. ```sh git fetch --all git push --force torrust main:staging/develop ``` -### 9. Create Comment that bumps next development version: + +### 9. Create Comment that bumps next development version ```sh git stash @@ -88,7 +103,7 @@ git commit -m "develop: bump to version (next)[semantic version]-develop" git push torrust ``` -### 10. Create and Merge Pull Request from `staging/develop` into `develop` branch. +### 10. Create and Merge Pull Request from `staging/develop` into `develop` branch Pull request title format: "Version `[semantic version]` was Released". diff --git a/packages/axum-health-check-api-server/Cargo.toml b/packages/axum-health-check-api-server/Cargo.toml new file mode 100644 index 000000000..e0504f7df --- /dev/null +++ b/packages/axum-health-check-api-server/Cargo.toml @@ -0,0 +1,40 @@ +[package] +authors.workspace = true +description = "The Torrust Bittorrent HTTP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["axum", "bittorrent", "healthcheck", "http", "server", "torrust", "tracker"] +license.workspace = true +name = "torrust-axum-health-check-api-server" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +axum = { version = "0", features = ["macros"] } +axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +futures = "0" +hyper = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } +torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tracing = "0" +url = "2.5.4" + +[dev-dependencies] +reqwest = { version = "0", features = ["json"] } +torrust-axum-health-check-api-server = { version = "3.0.0-develop", path = "../axum-health-check-api-server" } +torrust-axum-http-tracker-server = { version = "3.0.0-develop", path = "../axum-http-tracker-server" } +torrust-axum-rest-tracker-api-server = { version = "3.0.0-develop", path = "../axum-rest-tracker-api-server" } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } +torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } +tracing-subscriber = { version = "0", features = ["json"] } diff --git a/packages/axum-health-check-api-server/LICENSE b/packages/axum-health-check-api-server/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/axum-health-check-api-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/axum-health-check-api-server/README.md b/packages/axum-health-check-api-server/README.md new file mode 100644 index 000000000..d4c6b4f0b --- /dev/null +++ b/packages/axum-health-check-api-server/README.md @@ -0,0 +1,49 @@ +# Torrust Axum HTTP Tracker + +The Torrust Tracker Health Check API. + +The Torrust tracker container starts a local HTTP server on port 1313 to check all services. + +It's used for the container health check. + +URL: + +Example response: + +```json +{ + "status": "Ok", + "message": "", + "details": [ + { + "binding": "0.0.0.0:6969", + "info": "checking the udp tracker health check at: 0.0.0.0:6969", + "result": { + "Ok": "Connected" + } + }, + { + "binding": "0.0.0.0:1212", + "info": "checking api health check at: http://0.0.0.0:1212/api/health_check", + "result": { + "Ok": "200 OK" + } + }, + { + "binding": "0.0.0.0:7070", + "info": "checking http tracker health check at: http://0.0.0.0:7070/health_check", + "result": { + "Ok": "200 OK" + } + } + ] +} +``` + +## Documentation + +[Crate documentation](https://docs.rs/torrust-axum-health-check-api-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/tests/servers/health_check_api/environment.rs b/packages/axum-health-check-api-server/src/environment.rs similarity index 80% rename from tests/servers/health_check_api/environment.rs rename to packages/axum-health-check-api-server/src/environment.rs index b101a54e7..c1fb0547a 100644 --- a/tests/servers/health_check_api/environment.rs +++ b/packages/axum-health-check-api-server/src/environment.rs @@ -3,12 +3,14 @@ use std::sync::Arc; use tokio::sync::oneshot::{self, Sender}; use tokio::task::JoinHandle; -use torrust_tracker::bootstrap::jobs::Started; -use torrust_tracker::servers::health_check_api::{server, HEALTH_CHECK_API_LOG_TARGET}; -use torrust_tracker::servers::registar::Registar; -use torrust_tracker::servers::signals::{self, Halted}; +use torrust_server_lib::registar::Registar; +use torrust_server_lib::signals::{self, Halted as SignalHalted, Started as SignalStarted}; use torrust_tracker_configuration::HealthCheckApi; +use crate::{server, HEALTH_CHECK_API_LOG_TARGET}; + +pub type Started = Environment; + #[derive(Debug)] pub enum Error { #[allow(dead_code)] @@ -31,6 +33,7 @@ pub struct Environment { } impl Environment { + #[must_use] pub fn new(config: &Arc, registar: Registar) -> Self { let bind_to = config.bind_address; @@ -42,9 +45,13 @@ impl Environment { /// Start the test environment for the Health Check API. /// It runs the API server. + /// + /// # Panics + /// + /// Will panic if it cannot start the service in a spawned task. pub async fn start(self) -> Environment { - let (tx_start, rx_start) = oneshot::channel::(); - let (tx_halt, rx_halt) = tokio::sync::oneshot::channel::(); + let (tx_start, rx_start) = oneshot::channel::(); + let (tx_halt, rx_halt) = tokio::sync::oneshot::channel::(); let register = self.registar.entries(); @@ -82,10 +89,17 @@ impl Environment { Environment::::new(config, registar).start().await } + /// # Errors + /// + /// Will return an error if it cannot send the halt signal. + /// + /// # Panics + /// + /// Will panic if it cannot shutdown the service. pub async fn stop(self) -> Result, Error> { self.state .halt_task - .send(Halted::Normal) + .send(SignalHalted::Normal) .map_err(|e| Error::Error(e.to_string()))?; let bind_to = self.state.task.await.expect("it should shutdown the service"); diff --git a/src/servers/health_check_api/handlers.rs b/packages/axum-health-check-api-server/src/handlers.rs similarity index 85% rename from src/servers/health_check_api/handlers.rs rename to packages/axum-health-check-api-server/src/handlers.rs index fe65e996b..a26c901d7 100644 --- a/src/servers/health_check_api/handlers.rs +++ b/packages/axum-health-check-api-server/src/handlers.rs @@ -2,11 +2,11 @@ use std::collections::VecDeque; use axum::extract::State; use axum::Json; +use torrust_server_lib::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistry}; use tracing::{instrument, Level}; use super::resources::{CheckReport, Report}; use super::responses; -use crate::servers::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistry}; /// Endpoint for container health check. /// @@ -31,8 +31,10 @@ pub(crate) async fn health_check_handler(State(register): State let jobs = checks.drain(..).map(|c| { tokio::spawn(async move { CheckReport { - binding: c.binding, + service_binding: c.service_binding.url(), + binding: c.service_binding.bind_address(), info: c.info.clone(), + service_type: c.service_type, result: c.job.await.expect("it should be able to join into the checking function"), } }) diff --git a/src/servers/health_check_api/mod.rs b/packages/axum-health-check-api-server/src/lib.rs similarity index 86% rename from src/servers/health_check_api/mod.rs rename to packages/axum-health-check-api-server/src/lib.rs index 24c5232c8..6a3b4b34d 100644 --- a/src/servers/health_check_api/mod.rs +++ b/packages/axum-health-check-api-server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod environment; pub mod handlers; pub mod resources; pub mod responses; diff --git a/src/servers/health_check_api/resources.rs b/packages/axum-health-check-api-server/src/resources.rs similarity index 94% rename from src/servers/health_check_api/resources.rs rename to packages/axum-health-check-api-server/src/resources.rs index 3302fb966..44e64b24c 100644 --- a/src/servers/health_check_api/resources.rs +++ b/packages/axum-health-check-api-server/src/resources.rs @@ -1,6 +1,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use url::Url; #[derive(Copy, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub enum Status { @@ -11,7 +12,9 @@ pub enum Status { #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct CheckReport { + pub service_binding: Url, pub binding: SocketAddr, + pub service_type: String, pub info: String, pub result: Result, } diff --git a/src/servers/health_check_api/responses.rs b/packages/axum-health-check-api-server/src/responses.rs similarity index 100% rename from src/servers/health_check_api/responses.rs rename to packages/axum-health-check-api-server/src/responses.rs diff --git a/src/servers/health_check_api/server.rs b/packages/axum-health-check-api-server/src/server.rs similarity index 58% rename from src/servers/health_check_api/server.rs rename to packages/axum-health-check-api-server/src/server.rs index df4b1cf69..a371f146e 100644 --- a/src/servers/health_check_api/server.rs +++ b/packages/axum-health-check-api-server/src/server.rs @@ -14,17 +14,21 @@ use futures::Future; use hyper::Request; use serde_json::json; use tokio::sync::oneshot::{Receiver, Sender}; +use torrust_axum_server::signals::graceful_shutdown; +use torrust_server_lib::logging::Latency; +use torrust_server_lib::registar::ServiceRegistry; +use torrust_server_lib::signals::{Halted, Started}; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; +use tower_http::classify::ServerErrorsFailureClass; use tower_http::compression::CompressionLayer; use tower_http::propagate_header::PropagateHeaderLayer; use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer}; use tower_http::trace::{DefaultMakeSpan, TraceLayer}; +use tower_http::LatencyUnit; use tracing::{instrument, Level, Span}; -use crate::bootstrap::jobs::Started; -use crate::servers::health_check_api::handlers::health_check_handler; -use crate::servers::health_check_api::HEALTH_CHECK_API_LOG_TARGET; -use crate::servers::registar::ServiceRegistry; -use crate::servers::signals::{graceful_shutdown, Halted}; +use crate::handlers::health_check_handler; +use crate::HEALTH_CHECK_API_LOG_TARGET; /// Starts Health Check API server. /// @@ -48,7 +52,7 @@ pub fn start( .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) - .on_request(|request: &Request, _span: &Span| { + .on_request(|request: &Request, span: &Span| { let method = request.method().to_string(); let uri = request.uri().to_string(); let request_id = request @@ -57,28 +61,52 @@ pub fn start( .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - tracing::span!( + span.record("request_id", request_id); + + tracing::event!( target: HEALTH_CHECK_API_LOG_TARGET, - tracing::Level::INFO, "request", method = %method, uri = %uri, request_id = %request_id); + tracing::Level::INFO, %method, %uri, %request_id, "request"); }) - .on_response(|response: &Response, latency: Duration, _span: &Span| { + .on_response(|response: &Response, latency: Duration, span: &Span| { + let latency_ms = latency.as_millis(); let status_code = response.status(); let request_id = response .headers() .get("x-request-id") .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - let latency_ms = latency.as_millis(); - tracing::span!( - target: HEALTH_CHECK_API_LOG_TARGET, - tracing::Level::INFO, "response", latency = %latency_ms, status = %status_code, request_id = %request_id); - }), + span.record("request_id", request_id); + + if status_code.is_server_error() { + tracing::event!( + target: HEALTH_CHECK_API_LOG_TARGET, + tracing::Level::ERROR, %latency_ms, %status_code, %request_id, "response"); + } else { + tracing::event!( + target: HEALTH_CHECK_API_LOG_TARGET, + tracing::Level::INFO, %latency_ms, %status_code, %request_id, "response"); + } + }) + .on_failure( + |failure_classification: ServerErrorsFailureClass, latency: Duration, _span: &Span| { + let latency = Latency::new(LatencyUnit::Millis, latency); + + tracing::event!( + target: HEALTH_CHECK_API_LOG_TARGET, + tracing::Level::ERROR, %failure_classification, %latency, "response failed"); + }, + ), ) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)); let socket = std::net::TcpListener::bind(bind_to).expect("Could not bind tcp_listener to address."); + socket + .set_nonblocking(true) + .expect("Failed to set socket to non-blocking mode"); let address = socket.local_addr().expect("Could not get local_addr from tcp_listener."); + let protocol = Protocol::HTTP; // The health check API only supports HTTP directly now. Use a reverse proxy for HTTPS. + let service_binding = ServiceBinding::new(protocol.clone(), address).expect("Service binding creation failed"); let handle = Handle::new(); @@ -88,14 +116,19 @@ pub fn start( handle.clone(), rx_halt, format!("Shutting down http server on socket address: {address}"), + address, )); let running = axum_server::from_tcp(socket) + .expect("Failed to create server from TCP socket") .handle(handle) .serve(router.into_make_service_with_connect_info::()); - tx.send(Started { address }) - .expect("the Health Check API server should not be dropped"); + tx.send(Started { + service_binding, + address, + }) + .expect("the Health Check API server should not be dropped"); running } diff --git a/packages/axum-health-check-api-server/tests/integration.rs b/packages/axum-health-check-api-server/tests/integration.rs new file mode 100644 index 000000000..13ca963a3 --- /dev/null +++ b/packages/axum-health-check-api-server/tests/integration.rs @@ -0,0 +1,19 @@ +//! Integration tests. +//! +//! ```text +//! cargo test --test integration +//! ``` +mod server; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/tests/servers/health_check_api/client.rs b/packages/axum-health-check-api-server/tests/server/client.rs similarity index 100% rename from tests/servers/health_check_api/client.rs rename to packages/axum-health-check-api-server/tests/server/client.rs diff --git a/tests/servers/health_check_api/contract.rs b/packages/axum-health-check-api-server/tests/server/contract.rs similarity index 73% rename from tests/servers/health_check_api/contract.rs rename to packages/axum-health-check-api-server/tests/server/contract.rs index d40899f98..af1c0cff9 100644 --- a/tests/servers/health_check_api/contract.rs +++ b/packages/axum-health-check-api-server/tests/server/contract.rs @@ -1,23 +1,19 @@ -use torrust_tracker::servers::health_check_api::resources::{Report, Status}; -use torrust_tracker::servers::registar::Registar; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; +use torrust_axum_health_check_api_server::environment::Started; +use torrust_axum_health_check_api_server::resources::{Report, Status}; +use torrust_server_lib::registar::Registar; +use torrust_tracker_test_helpers::{configuration, logging}; -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::health_check_api::client::get; -use crate::servers::health_check_api::Started; +use crate::server::client::get; #[tokio::test] async fn health_check_endpoint_should_return_status_ok_when_there_is_no_services_registered() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = configuration::ephemeral_with_no_services(); let env = Started::new(&configuration.health_check_api.into(), Registar::default()).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -35,24 +31,19 @@ async fn health_check_endpoint_should_return_status_ok_when_there_is_no_services mod api { use std::sync::Arc; - use torrust_tracker::servers::health_check_api::resources::{Report, Status}; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_axum_health_check_api_server::environment::Started; + use torrust_axum_health_check_api_server::resources::{Report, Status}; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::api; - use crate::servers::health_check_api::client::get; - use crate::servers::health_check_api::Started; + use crate::server::client::get; #[tokio::test] pub(crate) async fn it_should_return_good_health_for_api_service() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = api::Started::new(&configuration).await; + let service = torrust_axum_rest_tracker_api_server::environment::Started::new(&configuration).await; let registar = service.registar.clone(); @@ -60,7 +51,7 @@ mod api { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -82,7 +73,7 @@ mod api { assert_eq!( details.info, format!( - "checking api health check at: http://{}/api/health_check", + "checking api health check at: http://{}/api/health_check", // DevSkim: ignore DS137138 service.bind_address() ) ); @@ -95,13 +86,11 @@ mod api { #[tokio::test] pub(crate) async fn it_should_return_error_when_api_service_was_stopped_after_registration() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = api::Started::new(&configuration).await; + let service = torrust_axum_rest_tracker_api_server::environment::Started::new(&configuration).await; let binding = service.bind_address(); @@ -113,7 +102,7 @@ mod api { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -130,16 +119,13 @@ mod api { assert_eq!(details.binding, binding); assert!( - details - .result - .as_ref() - .is_err_and(|e| e.contains("error sending request for url")), - "Expected to contain, \"error sending request for url\", but have message \"{:?}\".", + details.result.as_ref().is_err_and(|e| e.contains("error sending request")), + "Expected to contain, \"error sending request\", but have message \"{:?}\".", details.result ); assert_eq!( details.info, - format!("checking api health check at: http://{binding}/api/health_check") + format!("checking api health check at: http://{binding}/api/health_check") // DevSkim: ignore DS137138 ); env.stop().await.expect("it should stop the service"); @@ -150,24 +136,19 @@ mod api { mod http { use std::sync::Arc; - use torrust_tracker::servers::health_check_api::resources::{Report, Status}; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_axum_health_check_api_server::environment::Started; + use torrust_axum_health_check_api_server::resources::{Report, Status}; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::health_check_api::client::get; - use crate::servers::health_check_api::Started; - use crate::servers::http; + use crate::server::client::get; #[tokio::test] pub(crate) async fn it_should_return_good_health_for_http_service() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = http::Started::new(&configuration).await; + let service = torrust_axum_http_tracker_server::environment::Started::new(&configuration).await; let registar = service.registar.clone(); @@ -175,7 +156,7 @@ mod http { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -196,7 +177,7 @@ mod http { assert_eq!( details.info, format!( - "checking http tracker health check at: http://{}/health_check", + "checking http tracker health check at: http://{}/health_check", // DevSkim: ignore DS137138 service.bind_address() ) ); @@ -209,13 +190,11 @@ mod http { #[tokio::test] pub(crate) async fn it_should_return_error_when_http_service_was_stopped_after_registration() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = http::Started::new(&configuration).await; + let service = torrust_axum_http_tracker_server::environment::Started::new(&configuration).await; let binding = *service.bind_address(); @@ -223,11 +202,14 @@ mod http { service.server.stop().await.expect("it should stop udp server"); + // Give the OS a moment to fully release the TCP port after the server stops. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -244,16 +226,13 @@ mod http { assert_eq!(details.binding, binding); assert!( - details - .result - .as_ref() - .is_err_and(|e| e.contains("error sending request for url")), - "Expected to contain, \"error sending request for url\", but have message \"{:?}\".", + details.result.as_ref().is_err_and(|e| e.contains("error sending request")), + "Expected to contain, \"error sending request\", but have message \"{:?}\".", details.result ); assert_eq!( details.info, - format!("checking http tracker health check at: http://{binding}/health_check") + format!("checking http tracker health check at: http://{binding}/health_check") // DevSkim: ignore DS137138 ); env.stop().await.expect("it should stop the service"); @@ -264,24 +243,19 @@ mod http { mod udp { use std::sync::Arc; - use torrust_tracker::servers::health_check_api::resources::{Report, Status}; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_axum_health_check_api_server::environment::Started; + use torrust_axum_health_check_api_server::resources::{Report, Status}; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::health_check_api::client::get; - use crate::servers::health_check_api::Started; - use crate::servers::udp; + use crate::server::client::get; #[tokio::test] pub(crate) async fn it_should_return_good_health_for_udp_service() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = udp::Started::new(&configuration).await; + let service = torrust_udp_tracker_server::environment::Started::new(&configuration).await; let registar = service.registar.clone(); @@ -289,7 +263,7 @@ mod udp { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); @@ -320,13 +294,11 @@ mod udp { #[tokio::test] pub(crate) async fn it_should_return_error_when_udp_service_was_stopped_after_registration() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let configuration = Arc::new(configuration::ephemeral()); - let service = udp::Started::new(&configuration).await; + let service = torrust_udp_tracker_server::environment::Started::new(&configuration).await; let binding = service.bind_address(); @@ -338,7 +310,7 @@ mod udp { let config = configuration.health_check_api.clone(); let env = Started::new(&config.into(), registar).await; - let response = get(&format!("http://{}/health_check", env.state.binding)).await; + let response = get(&format!("http://{}/health_check", env.state.binding)).await; // DevSkim: ignore DS137138 assert_eq!(response.status(), 200); assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); diff --git a/packages/axum-health-check-api-server/tests/server/mod.rs b/packages/axum-health-check-api-server/tests/server/mod.rs new file mode 100644 index 000000000..2676be6f9 --- /dev/null +++ b/packages/axum-health-check-api-server/tests/server/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod contract; diff --git a/packages/axum-http-tracker-server/Cargo.toml b/packages/axum-http-tracker-server/Cargo.toml new file mode 100644 index 000000000..eb2c2cad3 --- /dev/null +++ b/packages/axum-http-tracker-server/Cargo.toml @@ -0,0 +1,53 @@ +[package] +authors.workspace = true +description = "The Torrust Bittorrent HTTP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["axum", "bittorrent", "http", "server", "torrust", "tracker"] +license.workspace = true +name = "torrust-axum-http-tracker-server" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +axum = { version = "0", features = ["macros"] } +axum-client-ip = "0" +axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } +bittorrent-http-tracker-protocol = { version = "3.0.0-develop", path = "../http-protocol" } +bittorrent-primitives = "0.1.0" +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +futures = "0" +hyper = "1" +reqwest = { version = "0", features = ["json"] } +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } +torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +tower = { version = "0", features = ["timeout"] } +tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tracing = "0" + +[dev-dependencies] +local-ip-address = "0" +percent-encoding = "2" +rand = "0" +serde_bencode = "0" +serde_bytes = "0" +serde_repr = "0" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } +uuid = { version = "1", features = ["v4"] } +zerocopy = "0.7" diff --git a/packages/axum-http-tracker-server/LICENSE b/packages/axum-http-tracker-server/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/axum-http-tracker-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/axum-http-tracker-server/README.md b/packages/axum-http-tracker-server/README.md new file mode 100644 index 000000000..b109a08c1 --- /dev/null +++ b/packages/axum-http-tracker-server/README.md @@ -0,0 +1,11 @@ +# Torrust Axum HTTP Tracker + +The Torrust Bittorrent HTTP tracker. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-axum-http-tracker-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/axum-http-tracker-server/src/environment.rs b/packages/axum-http-tracker-server/src/environment.rs new file mode 100644 index 000000000..616973a0f --- /dev/null +++ b/packages/axum-http-tracker-server/src/environment.rs @@ -0,0 +1,179 @@ +use std::sync::Arc; + +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; +use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::container::TrackerCoreContainer; +use futures::executor::block_on; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_axum_server::tsl::make_rust_tls; +use torrust_server_lib::registar::Registar; +use torrust_tracker_configuration::{logging, Configuration}; +use torrust_tracker_primitives::peer; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +use crate::server::{HttpServer, Launcher, Running, Stopped}; + +pub type Started = Environment; + +pub struct Environment { + pub container: Arc, + pub registar: Registar, + pub server: HttpServer, + pub event_listener_job: Option>, + pub cancellation_token: CancellationToken, +} + +impl Environment { + /// Add a torrent to the tracker + pub async fn add_torrent_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + self.container + .tracker_core_container + .in_memory_torrent_repository + .handle_announcement(info_hash, peer, None) + .await; + } +} + +impl Environment { + /// # Panics + /// + /// Will panic if it fails to make the TSL config from the configuration. + #[allow(dead_code)] + #[must_use] + pub fn new(configuration: &Arc) -> Self { + initialize_global_services(configuration); + + let container = Arc::new(EnvContainer::initialize(configuration)); + + let bind_to = container.http_tracker_core_container.http_tracker_config.bind_address; + + let tls = block_on(make_rust_tls( + &container.http_tracker_core_container.http_tracker_config.tsl_config, + )) + .map(|tls| tls.expect("tls config failed")); + + let server = HttpServer::new(Launcher::new(bind_to, tls)); + + Self { + container, + registar: Registar::default(), + server, + event_listener_job: None, + cancellation_token: CancellationToken::new(), + } + } + + /// Starts the test environment and return a running environment. + /// + /// # Panics + /// + /// Will panic if the server fails to start. + #[allow(dead_code)] + pub async fn start(self) -> Environment { + // Start the event listener + let event_listener_job = run_event_listener( + self.container.http_tracker_core_container.event_bus.receiver(), + self.cancellation_token.clone(), + &self.container.http_tracker_core_container.stats_repository, + ); + + // Start the server + let server = self + .server + .start(self.container.http_tracker_core_container.clone(), self.registar.give_form()) + .await + .expect("Failed to start the HTTP tracker server"); + + Environment { + container: self.container.clone(), + registar: self.registar.clone(), + server, + event_listener_job: Some(event_listener_job), + cancellation_token: self.cancellation_token, + } + } +} + +impl Environment { + pub async fn new(configuration: &Arc) -> Self { + Environment::::new(configuration).start().await + } + + /// Stops the test environment and return a stopped environment. + /// + /// # Panics + /// + /// Will panic if the server fails to stop. + pub async fn stop(self) -> Environment { + // Stop the event listener + if let Some(event_listener_job) = self.event_listener_job { + // todo: send a message to the event listener to stop and wait for + // it to finish + event_listener_job.abort(); + } + + // Stop the server + let server = self.server.stop().await.expect("Failed to stop the HTTP tracker server"); + + Environment { + container: self.container, + registar: Registar::default(), + server, + event_listener_job: None, + cancellation_token: self.cancellation_token, + } + } + + #[must_use] + pub fn bind_address(&self) -> &std::net::SocketAddr { + &self.server.state.binding + } +} + +pub struct EnvContainer { + pub tracker_core_container: Arc, + pub http_tracker_core_container: Arc, +} + +impl EnvContainer { + /// # Panics + /// + /// Will panic if the configuration is missing the HTTP tracker configuration. + #[must_use] + pub fn initialize(configuration: &Configuration) -> Self { + let core_config = Arc::new(configuration.core.clone()); + let http_tracker_config = configuration + .http_trackers + .clone() + .expect("missing HTTP tracker configuration"); + let http_tracker_config = Arc::new(http_tracker_config[0].clone()); + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + configuration.core.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + let http_tracker_container = + HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &http_tracker_config); + + Self { + tracker_core_container, + http_tracker_core_container: http_tracker_container, + } + } +} + +fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); +} + +fn initialize_static() { + torrust_tracker_clock::initialize_static(); +} diff --git a/src/servers/http/mod.rs b/packages/axum-http-tracker-server/src/lib.rs similarity index 82% rename from src/servers/http/mod.rs rename to packages/axum-http-tracker-server/src/lib.rs index 4ef5ca7ea..2bb6978b7 100644 --- a/src/servers/http/mod.rs +++ b/packages/axum-http-tracker-server/src/lib.rs @@ -43,18 +43,18 @@ //! //! Parameter | Type | Description | Required | Default | Example //! ---|---|---|---|---|--- -//! [`info_hash`](crate::servers::http::v1::requests::announce::Announce::info_hash) | percent encoded of 20-byte array | The `Info Hash` of the torrent. | Yes | No | `%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00` +//! [`info_hash`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::info_hash) | percent encoded of 20-byte array | The `Info Hash` of the torrent. | Yes | No | `%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00` //! `peer_addr` | string |The IP address of the peer. | No | No | `2.137.87.41` -//! [`downloaded`](crate::servers::http::v1::requests::announce::Announce::downloaded) | positive integer |The number of bytes downloaded by the peer. | No | `0` | `0` -//! [`uploaded`](crate::servers::http::v1::requests::announce::Announce::uploaded) | positive integer | The number of bytes uploaded by the peer. | No | `0` | `0` -//! [`peer_id`](crate::servers::http::v1::requests::announce::Announce::peer_id) | percent encoded of 20-byte array | The ID of the peer. | Yes | No | `-qB00000000000000001` -//! [`port`](crate::servers::http::v1::requests::announce::Announce::port) | positive integer | The port used by the peer. | Yes | No | `17548` -//! [`left`](crate::servers::http::v1::requests::announce::Announce::left) | positive integer | The number of bytes pending to download. | No | `0` | `0` -//! [`event`](crate::servers::http::v1::requests::announce::Announce::event) | positive integer | The event that triggered the `Announce` request: `started`, `completed`, `stopped` | No | `None` | `completed` -//! [`compact`](crate::servers::http::v1::requests::announce::Announce::compact) | `0` or `1` | Whether the tracker should return a compact peer list. | No | `None` | `0` +//! [`downloaded`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::downloaded) | positive integer |The number of bytes downloaded by the peer. | No | `0` | `0` +//! [`uploaded`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::uploaded) | positive integer | The number of bytes uploaded by the peer. | No | `0` | `0` +//! [`peer_id`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::peer_id) | percent encoded of 20-byte array | The ID of the peer. | Yes | No | `-qB00000000000000001` +//! [`port`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::port) | positive integer | The port used by the peer. | Yes | No | `17548` +//! [`left`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::left) | positive integer | The number of bytes pending to download. | No | `0` | `0` +//! [`event`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::event) | positive integer | The event that triggered the `Announce` request: `started`, `completed`, `stopped` | No | `None` | `completed` +//! [`compact`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce::compact) | `0` or `1` | Whether the tracker should return a compact peer list. | No | `None` | `0` //! `numwant` | positive integer | **Not implemented**. The maximum number of peers you want in the reply. | No | `50` | `50` //! -//! Refer to the [`Announce`](crate::servers::http::v1::requests::announce::Announce) +//! Refer to the [`Announce`](bittorrent_http_tracker_protocol::v1::requests::announce::Announce) //! request for more information about the parameters. //! //! > **NOTICE**: the [BEP 03](https://www.bittorrent.org/beps/bep_0003.html) @@ -152,7 +152,7 @@ //! 000000f0: 65 e //! ``` //! -//! Refer to the [`Normal`](crate::servers::http::v1::responses::announce::Normal), i.e. `Non-Compact` +//! Refer to the [`Normal`](bittorrent_http_tracker_protocol::v1::responses::announce::Normal), i.e. `Non-Compact` //! response for more information about the response. //! //! **Sample compact response** @@ -190,7 +190,7 @@ //! 0000070: 7065 pe //! ``` //! -//! Refer to the [`Compact`](crate::servers::http::v1::responses::announce::Compact) +//! Refer to the [`Compact`](bittorrent_http_tracker_protocol::v1::responses::announce::Compact) //! response for more information about the response. //! //! **Protocol** @@ -220,12 +220,12 @@ //! //! Parameter | Type | Description | Required | Default | Example //! ---|---|---|---|---|--- -//! [`info_hash`](crate::servers::http::v1::requests::scrape::Scrape::info_hashes) | percent encoded of 20-byte array | The `Info Hash` of the torrent. | Yes | No | `%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00` +//! [`info_hash`](bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape::info_hashes) | percent encoded of 20-byte array | The `Info Hash` of the torrent. | Yes | No | `%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00` //! //! > **NOTICE**: you can scrape multiple torrents at the same time by passing //! > multiple `info_hash` parameters. //! -//! Refer to the [`Scrape`](crate::servers::http::v1::requests::scrape::Scrape) +//! Refer to the [`Scrape`](bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape) //! request for more information about the parameters. //! //! **Sample scrape URL** @@ -238,7 +238,7 @@ //! `info_hash` parameters: `info_hash=%81%00%0...00%00%00&info_hash=%82%00%0...00%00%00` //! //! > **NOTICE**: the maximum number of torrents you can scrape at the same time -//! > is `74`. Defined with a hardcoded const [`MAX_SCRAPE_TORRENTS`](crate::shared::bit_torrent::common::MAX_SCRAPE_TORRENTS). +//! > is `74`. Defined with a hardcoded const [`MAX_SCRAPE_TORRENTS`](torrust_udp_tracker_server::MAX_SCRAPE_TORRENTS). //! //! **Sample response** //! @@ -303,12 +303,12 @@ //! //! - [Bencode](https://en.wikipedia.org/wiki/Bencode). //! - [Bencode to Json Online converter](https://chocobo1.github.io/bencode_online). -use serde::{Deserialize, Serialize}; - -pub mod percent_encoding; +pub mod environment; pub mod server; pub mod v1; +use serde::{Deserialize, Serialize}; + pub const HTTP_TRACKER_LOG_TARGET: &str = "HTTP TRACKER"; /// The version of the HTTP tracker. @@ -317,3 +317,21 @@ pub enum Version { /// The `v1` version of the HTTP tracker. V1, } + +#[cfg(test)] +pub(crate) mod tests { + + pub(crate) mod helpers { + use bittorrent_primitives::info_hash::InfoHash; + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + } +} diff --git a/src/servers/http/server.rs b/packages/axum-http-tracker-server/src/server.rs similarity index 52% rename from src/servers/http/server.rs rename to packages/axum-http-tracker-server/src/server.rs index 560d91681..69f9cb72e 100644 --- a/src/servers/http/server.rs +++ b/packages/axum-http-tracker-server/src/server.rs @@ -4,20 +4,22 @@ use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; use axum_server::Handle; +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; use derive_more::Constructor; use futures::future::BoxFuture; use tokio::sync::oneshot::{Receiver, Sender}; +use torrust_axum_server::custom_axum_server::{self, TimeoutAcceptor}; +use torrust_axum_server::signals::graceful_shutdown; +use torrust_server_lib::logging::STARTED_ON; +use torrust_server_lib::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistrationForm}; +use torrust_server_lib::signals::{Halted, Started}; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use tracing::instrument; use super::v1::routes::router; -use crate::bootstrap::jobs::Started; -use crate::core::Tracker; -use crate::servers::custom_axum_server::{self, TimeoutAcceptor}; -use crate::servers::http::HTTP_TRACKER_LOG_TARGET; -use crate::servers::logging::STARTED_ON; -use crate::servers::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistrationForm}; -use crate::servers::signals::{graceful_shutdown, Halted}; +use crate::HTTP_TRACKER_LOG_TARGET; +const TYPE_STRING: &str = "http_tracker"; /// Error that can occur when starting or stopping the HTTP server. /// /// Some errors triggered while starting the server are: @@ -42,9 +44,17 @@ pub struct Launcher { } impl Launcher { - #[instrument(skip(self, tracker, tx_start, rx_halt))] - fn start(&self, tracker: Arc, tx_start: Sender, rx_halt: Receiver) -> BoxFuture<'static, ()> { + #[instrument(skip(self, http_tracker_container, tx_start, rx_halt))] + fn start( + &self, + http_tracker_container: &Arc, + tx_start: Sender, + rx_halt: Receiver, + ) -> BoxFuture<'static, ()> { let socket = std::net::TcpListener::bind(self.bind_to).expect("Could not bind tcp_listener to address."); + socket + .set_nonblocking(true) + .expect("Failed to set socket to non-blocking mode"); let address = socket.local_addr().expect("Could not get local_addr from tcp_listener."); let handle = Handle::new(); @@ -53,18 +63,21 @@ impl Launcher { handle.clone(), rx_halt, format!("Shutting down HTTP server on socket address: {address}"), + address, )); let tls = self.tls.clone(); - let protocol = if tls.is_some() { "https" } else { "http" }; + let protocol = if tls.is_some() { Protocol::HTTPS } else { Protocol::HTTP }; + let service_binding = ServiceBinding::new(protocol.clone(), address).expect("Service binding creation failed"); - tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "Starting on: {protocol}://{}", address); + tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "Starting on: {protocol}://{address}"); - let app = router(tracker, address); + let app = router(http_tracker_container, &service_binding); let running = Box::pin(async { match tls { Some(tls) => custom_axum_server::from_tcp_rustls_with_timeouts(socket, tls) + .expect("Failed to create server from TCP socket with TLS") .handle(handle) // The TimeoutAcceptor is commented because TSL does not work with it. // See: https://github.com/torrust/torrust-index/issues/204#issuecomment-2115529214 @@ -73,6 +86,7 @@ impl Launcher { .await .expect("Axum server crashed."), None => custom_axum_server::from_tcp_with_timeouts(socket) + .expect("Failed to create server from TCP socket") .handle(handle) .acceptor(TimeoutAcceptor) .serve(app.into_make_service_with_connect_info::()) @@ -84,7 +98,10 @@ impl Launcher { tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "{STARTED_ON}: {protocol}://{}", address); tx_start - .send(Started { address }) + .send(Started { + service_binding, + address, + }) .expect("the HTTP(s) Tracker service should not be dropped"); running @@ -153,23 +170,30 @@ impl HttpServer { /// /// It would panic spawned HTTP server launcher cannot send the bound `SocketAddr` /// back to the main thread. - pub async fn start(self, tracker: Arc, form: ServiceRegistrationForm) -> Result, Error> { + pub async fn start( + self, + http_tracker_container: Arc, + form: ServiceRegistrationForm, + ) -> Result, Error> { let (tx_start, rx_start) = tokio::sync::oneshot::channel::(); let (tx_halt, rx_halt) = tokio::sync::oneshot::channel::(); let launcher = self.state.launcher; let task = tokio::spawn(async move { - let server = launcher.start(tracker, tx_start, rx_halt); + let server = launcher.start(&http_tracker_container, tx_start, rx_halt); server.await; launcher }); - let binding = rx_start.await.expect("it should be able to start the service").address; + let started = rx_start.await.expect("it should be able to start the service"); - form.send(ServiceRegistration::new(binding, check_fn)) + let listen_url = started.service_binding; + let binding = started.address; + + form.send(ServiceRegistration::new(listen_url, check_fn)) .expect("it should be able to send service registration"); Ok(HttpServer { @@ -210,8 +234,8 @@ impl HttpServer { /// This function will return an error if unable to connect. /// Or if the request returns an error. #[must_use] -pub fn check_fn(binding: &SocketAddr) -> ServiceHealthCheckJob { - let url = format!("http://{binding}/health_check"); // DevSkim: ignore DS137138 +pub fn check_fn(service_binding: &ServiceBinding) -> ServiceHealthCheckJob { + let url = format!("http://{}/health_check", service_binding.bind_address()); // DevSkim: ignore DS137138 let info = format!("checking http tracker health check at: {url}"); @@ -222,38 +246,128 @@ pub fn check_fn(binding: &SocketAddr) -> ServiceHealthCheckJob { } }); - ServiceHealthCheckJob::new(*binding, info, job) + ServiceHealthCheckJob::new(service_binding.clone(), info, TYPE_STRING.to_string(), job) } #[cfg(test)] mod tests { use std::sync::Arc; + use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; + use bittorrent_http_tracker_core::event::bus::EventBus; + use bittorrent_http_tracker_core::event::sender::Broadcaster; + use bittorrent_http_tracker_core::services::announce::AnnounceService; + use bittorrent_http_tracker_core::services::scrape::ScrapeService; + use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; + use bittorrent_http_tracker_core::statistics::repository::Repository; + use bittorrent_tracker_core::container::TrackerCoreContainer; + use tokio_util::sync::CancellationToken; + use torrust_axum_server::tsl::make_rust_tls; + use torrust_server_lib::registar::Registar; + use torrust_tracker_configuration::{logging, Configuration}; + use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; use torrust_tracker_test_helpers::configuration::ephemeral_public; - use crate::bootstrap::app::initialize_with_configuration; - use crate::bootstrap::jobs::make_rust_tls; - use crate::servers::http::server::{HttpServer, Launcher}; - use crate::servers::registar::Registar; + use crate::server::{HttpServer, Launcher}; + + pub fn initialize_container(configuration: &Configuration) -> HttpTrackerCoreContainer { + let cancellation_token = CancellationToken::new(); + + let core_config = Arc::new(configuration.core.clone()); + + let http_trackers = configuration + .http_trackers + .clone() + .expect("missing HTTP trackers configuration"); + + let http_tracker_config = &http_trackers[0]; + + let http_tracker_config = Arc::new(http_tracker_config.clone()); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + configuration.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + if configuration.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + configuration.core.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + let announce_service = Arc::new(AnnounceService::new( + tracker_core_container.core_config.clone(), + tracker_core_container.announce_handler.clone(), + tracker_core_container.authentication_service.clone(), + tracker_core_container.whitelist_authorization.clone(), + http_stats_event_sender.clone(), + )); + + let scrape_service = Arc::new(ScrapeService::new( + tracker_core_container.core_config.clone(), + tracker_core_container.scrape_handler.clone(), + tracker_core_container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + HttpTrackerCoreContainer { + tracker_core_container, + http_tracker_config, + event_bus: http_stats_event_bus, + stats_event_sender: http_stats_event_sender, + stats_repository: http_stats_repository, + announce_service, + scrape_service, + } + } + + fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); + } + + fn initialize_static() { + torrust_tracker_clock::initialize_static(); + } #[tokio::test] async fn it_should_be_able_to_start_and_stop() { - let cfg = Arc::new(ephemeral_public()); - let tracker = initialize_with_configuration(&cfg); - let http_trackers = cfg.http_trackers.clone().expect("missing HTTP trackers configuration"); - let config = &http_trackers[0]; + let configuration = Arc::new(ephemeral_public()); + + let http_trackers = configuration + .http_trackers + .clone() + .expect("missing HTTP trackers configuration"); + + let http_tracker_config = &http_trackers[0]; - let bind_to = config.bind_address; + initialize_global_services(&configuration); - let tls = make_rust_tls(&config.tsl_config) + let http_tracker_container = Arc::new(initialize_container(&configuration)); + + let bind_to = http_tracker_config.bind_address; + + let tls = make_rust_tls(&http_tracker_config.tsl_config) .await .map(|tls| tls.expect("tls config failed")); let register = &Registar::default(); - let stopped = HttpServer::new(Launcher::new(bind_to, tls)); + let started = stopped - .start(tracker, register.give_form()) + .start(http_tracker_container, register.give_form()) .await .expect("it should start the server"); let stopped = started.stop().await.expect("it should stop the server"); diff --git a/src/servers/http/v1/extractors/announce_request.rs b/packages/axum-http-tracker-server/src/v1/extractors/announce_request.rs similarity index 70% rename from src/servers/http/v1/extractors/announce_request.rs rename to packages/axum-http-tracker-server/src/v1/extractors/announce_request.rs index 324e91bf2..57001a47e 100644 --- a/src/servers/http/v1/extractors/announce_request.rs +++ b/packages/axum-http-tracker-server/src/v1/extractors/announce_request.rs @@ -4,10 +4,10 @@ //! It parses the query parameters returning an [`Announce`] //! request. //! -//! Refer to [`Announce`](crate::servers::http::v1::requests::announce) for more +//! Refer to [`Announce`](bittorrent_http_tracker_protocol::v1::requests::announce) for more //! information about the returned structure. //! -//! It returns a bencoded [`Error`](crate::servers::http::v1::responses::error) +//! It returns a bencoded [`Error`](bittorrent_http_tracker_protocol::v1::responses::error) //! response (`500`) if the query parameters are missing or invalid. //! //! **Sample announce request** @@ -19,25 +19,25 @@ //! Missing query params for `announce` request: //! //! ```text -//! d14:failure reason149:Cannot parse query params for announce request: missing query params for announce request in src/servers/http/v1/extractors/announce_request.rs:54:23e +//! d14:failure reason149:Bad request. Cannot parse query params for announce request: missing query params for announce request in src/servers/http/v1/extractors/announce_request.rs:54:23e //! ``` //! //! Invalid query param (`info_hash`): //! //! ```text -//! d14:failure reason240:Cannot parse query params for announce request: invalid param value invalid for info_hash in not enough bytes for infohash: got 7 bytes, expected 20 src/shared/bit_torrent/info_hash.rs:240:27, src/servers/http/v1/requests/announce.rs:182:42e +//! d14:failure reason240:Bad request. Cannot parse query params for announce request: invalid param value invalid for info_hash in not enough bytes for infohash: got 7 bytes, expected 20 src/shared/bit_torrent/info_hash.rs:240:27, src/servers/http/v1/requests/announce.rs:182:42e //! ``` +use std::future::Future; use std::panic::Location; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; -use futures::future::BoxFuture; +use bittorrent_http_tracker_protocol::v1::query::Query; +use bittorrent_http_tracker_protocol::v1::requests::announce::{Announce, ParseAnnounceQueryError}; +use bittorrent_http_tracker_protocol::v1::responses; use futures::FutureExt; - -use crate::servers::http::v1::query::Query; -use crate::servers::http::v1::requests::announce::{Announce, ParseAnnounceQueryError}; -use crate::servers::http::v1::responses; +use hyper::StatusCode; /// Extractor for the [`Announce`] /// request. @@ -49,20 +49,11 @@ where { type Rejection = Response; - #[must_use] - fn from_request_parts<'life0, 'life1, 'async_trait>( - parts: &'life0 mut Parts, - _state: &'life1 S, - ) -> BoxFuture<'async_trait, Result> - where - 'life0: 'async_trait, - 'life1: 'async_trait, - Self: 'async_trait, - { + fn from_request_parts(parts: &mut Parts, _state: &S) -> impl Future> + Send { async { match extract_announce_from(parts.uri.query()) { Ok(announce_request) => Ok(ExtractRequest(announce_request)), - Err(error) => Err(error.into_response()), + Err(error) => Err((StatusCode::OK, error.write()).into_response()), } } .boxed() @@ -96,11 +87,11 @@ mod tests { use std::str::FromStr; use aquatic_udp_protocol::{NumberOfBytes, PeerId}; - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_http_tracker_protocol::v1::requests::announce::{Announce, Compact, Event}; + use bittorrent_http_tracker_protocol::v1::responses::error::Error; + use bittorrent_primitives::info_hash::InfoHash; use super::extract_announce_from; - use crate::servers::http::v1::requests::announce::{Announce, Compact, Event}; - use crate::servers::http::v1::responses::error::Error; fn assert_error_response(error: &Error, error_message: &str) { assert!( @@ -118,7 +109,7 @@ mod tests { assert_eq!( announce, Announce { - info_hash: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), + info_hash: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, downloaded: Some(NumberOfBytes::new(0)), @@ -137,7 +128,7 @@ mod tests { assert_error_response( &response, - "Cannot parse query params for announce request: missing query params for announce request", + "Bad request. Cannot parse query params for announce request: missing query params for announce request", ); } @@ -146,13 +137,13 @@ mod tests { let invalid_query = "param1=value1=value2"; let response = extract_announce_from(Some(invalid_query)).unwrap_err(); - assert_error_response(&response, "Cannot parse query params"); + assert_error_response(&response, "Bad request. Cannot parse query params"); } #[test] fn it_should_reject_a_request_with_a_query_that_cannot_be_parsed_into_an_announce_request() { let response = extract_announce_from(Some("param1=value1")).unwrap_err(); - assert_error_response(&response, "Cannot parse query params for announce request"); + assert_error_response(&response, "Bad request. Cannot parse query params for announce request"); } } diff --git a/src/servers/http/v1/extractors/authentication_key.rs b/packages/axum-http-tracker-server/src/v1/extractors/authentication_key.rs similarity index 79% rename from src/servers/http/v1/extractors/authentication_key.rs rename to packages/axum-http-tracker-server/src/v1/extractors/authentication_key.rs index e86241edf..7dca7f42e 100644 --- a/src/servers/http/v1/extractors/authentication_key.rs +++ b/packages/axum-http-tracker-server/src/v1/extractors/authentication_key.rs @@ -9,7 +9,7 @@ //! It's a wrapper for Axum `Path` extractor in order to return custom //! authentication errors. //! -//! It returns a bencoded [`Error`](crate::servers::http::v1::responses::error) +//! It returns a bencoded [`Error`](bittorrent_http_tracker_protocol::v1::responses::error) //! response (`500`) if the `key` parameter are missing or invalid. //! //! **Sample authentication error responses** @@ -42,20 +42,18 @@ //! > Neither [The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) //! > nor [The Private Torrents](https://www.bittorrent.org/beps/bep_0027.html) //! > specifications specify any HTTP status code for authentication errors. +use std::future::Future; use std::panic::Location; use axum::extract::rejection::PathRejection; use axum::extract::{FromRequestParts, Path}; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; -use futures::future::BoxFuture; -use futures::FutureExt; +use bittorrent_http_tracker_protocol::v1::{auth, responses}; +use bittorrent_tracker_core::authentication::Key; +use hyper::StatusCode; use serde::Deserialize; -use crate::core::auth::Key; -use crate::servers::http::v1::handlers::common::auth; -use crate::servers::http::v1::responses; - /// Extractor for the [`Key`] struct. pub struct Extract(pub Key); @@ -71,30 +69,21 @@ impl KeyParam { impl FromRequestParts for Extract where - S: Send + Sync, + S: Send + Sync + 'static, { type Rejection = Response; - #[must_use] - fn from_request_parts<'life0, 'life1, 'async_trait>( - parts: &'life0 mut Parts, - state: &'life1 S, - ) -> BoxFuture<'async_trait, Result> - where - 'life0: 'async_trait, - 'life1: 'async_trait, - Self: 'async_trait, - { - async { + #[allow(clippy::manual_async_fn)] + fn from_request_parts(parts: &mut Parts, state: &S) -> impl Future> + Send { + async move { // Extract `key` from URL path with Axum `Path` extractor let maybe_path_with_key = Path::::from_request_parts(parts, state).await; match extract_key(maybe_path_with_key) { Ok(key) => Ok(Extract(key)), - Err(error) => Err(error.into_response()), + Err(error) => Err((StatusCode::OK, error.write()).into_response()), } } - .boxed() } } @@ -126,11 +115,6 @@ fn custom_error(rejection: &PathRejection) -> responses::error::Error { location: Location::caller(), }) } - axum::extract::rejection::PathRejection::MissingPathParams(_) => { - responses::error::Error::from(auth::Error::MissingAuthKey { - location: Location::caller(), - }) - } _ => responses::error::Error::from(auth::Error::CannotExtractKeyParam { location: Location::caller(), }), @@ -140,8 +124,9 @@ fn custom_error(rejection: &PathRejection) -> responses::error::Error { #[cfg(test)] mod tests { + use bittorrent_http_tracker_protocol::v1::responses::error::Error; + use super::parse_key; - use crate::servers::http::v1::responses::error::Error; fn assert_error_response(error: &Error, error_message: &str) { assert!( @@ -156,6 +141,9 @@ mod tests { let response = parse_key(invalid_key).unwrap_err(); - assert_error_response(&response, "Authentication error: Invalid format for authentication key param"); + assert_error_response( + &response, + "Tracker authentication error: Invalid format for authentication key param", + ); } } diff --git a/src/servers/http/v1/extractors/client_ip_sources.rs b/packages/axum-http-tracker-server/src/v1/extractors/client_ip_sources.rs similarity index 82% rename from src/servers/http/v1/extractors/client_ip_sources.rs rename to packages/axum-http-tracker-server/src/v1/extractors/client_ip_sources.rs index 5b235fbe0..ed568e0b9 100644 --- a/src/servers/http/v1/extractors/client_ip_sources.rs +++ b/packages/axum-http-tracker-server/src/v1/extractors/client_ip_sources.rs @@ -35,16 +35,14 @@ //! `right_most_x_forwarded_for` = 126.0.0.2 //! `connection_info_ip` = 126.0.0.3 //! ``` +use std::future::Future; use std::net::SocketAddr; use axum::extract::{ConnectInfo, FromRequestParts}; use axum::http::request::Parts; use axum::response::Response; use axum_client_ip::RightmostXForwardedFor; -use futures::future::BoxFuture; -use futures::FutureExt; - -use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; /// Extractor for the [`ClientIpSources`] /// struct. @@ -56,32 +54,23 @@ where { type Rejection = Response; - #[must_use] - fn from_request_parts<'life0, 'life1, 'async_trait>( - parts: &'life0 mut Parts, - state: &'life1 S, - ) -> BoxFuture<'async_trait, Result> - where - 'life0: 'async_trait, - 'life1: 'async_trait, - Self: 'async_trait, - { - async { + #[allow(clippy::manual_async_fn)] + fn from_request_parts(parts: &mut Parts, state: &S) -> impl Future> + Send { + async move { let right_most_x_forwarded_for = match RightmostXForwardedFor::from_request_parts(parts, state).await { Ok(right_most_x_forwarded_for) => Some(right_most_x_forwarded_for.0), Err(_) => None, }; let connection_info_ip = match ConnectInfo::::from_request_parts(parts, state).await { - Ok(connection_info_socket_addr) => Some(connection_info_socket_addr.0.ip()), + Ok(connection_info_socket_addr) => Some(connection_info_socket_addr.0), Err(_) => None, }; Ok(Extract(ClientIpSources { right_most_x_forwarded_for, - connection_info_ip, + connection_info_socket_address: connection_info_ip, })) } - .boxed() } } diff --git a/src/servers/http/v1/extractors/mod.rs b/packages/axum-http-tracker-server/src/v1/extractors/mod.rs similarity index 100% rename from src/servers/http/v1/extractors/mod.rs rename to packages/axum-http-tracker-server/src/v1/extractors/mod.rs diff --git a/src/servers/http/v1/extractors/scrape_request.rs b/packages/axum-http-tracker-server/src/v1/extractors/scrape_request.rs similarity index 71% rename from src/servers/http/v1/extractors/scrape_request.rs rename to packages/axum-http-tracker-server/src/v1/extractors/scrape_request.rs index 07fa4ccb9..33a998ff2 100644 --- a/src/servers/http/v1/extractors/scrape_request.rs +++ b/packages/axum-http-tracker-server/src/v1/extractors/scrape_request.rs @@ -4,10 +4,10 @@ //! It parses the query parameters returning an [`Scrape`] //! request. //! -//! Refer to [`Scrape`](crate::servers::http::v1::requests::scrape) for more +//! Refer to [`Scrape`](bittorrent_http_tracker_protocol::v1::requests::scrape) for more //! information about the returned structure. //! -//! It returns a bencoded [`Error`](crate::servers::http::v1::responses::error) +//! It returns a bencoded [`Error`](bittorrent_http_tracker_protocol::v1::responses::error) //! response (`500`) if the query parameters are missing or invalid. //! //! **Sample scrape request** @@ -19,25 +19,25 @@ //! Missing query params for scrape request: //! //! ```text -//! d14:failure reason143:Cannot parse query params for scrape request: missing query params for scrape request in src/servers/http/v1/extractors/scrape_request.rs:52:23e +//! d14:failure reason143:Bad request. Cannot parse query params for scrape request: missing query params for scrape request in src/servers/http/v1/extractors/scrape_request.rs:52:23e //! ``` //! //! Invalid query params for scrape request: //! //! ```text -//! d14:failure reason235:Cannot parse query params for scrape request: invalid param value invalid for info_hash in not enough bytes for infohash: got 7 bytes, expected 20 src/shared/bit_torrent/info_hash.rs:240:27, src/servers/http/v1/requests/scrape.rs:66:46e +//! d14:failure reason235:Bad request. Cannot parse query params for scrape request: invalid param value invalid for info_hash in not enough bytes for infohash: got 7 bytes, expected 20 src/shared/bit_torrent/info_hash.rs:240:27, src/servers/http/v1/requests/scrape.rs:66:46e //! ``` +use std::future::Future; use std::panic::Location; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; -use futures::future::BoxFuture; +use bittorrent_http_tracker_protocol::v1::query::Query; +use bittorrent_http_tracker_protocol::v1::requests::scrape::{ParseScrapeQueryError, Scrape}; +use bittorrent_http_tracker_protocol::v1::responses; use futures::FutureExt; - -use crate::servers::http::v1::query::Query; -use crate::servers::http::v1::requests::scrape::{ParseScrapeQueryError, Scrape}; -use crate::servers::http::v1::responses; +use hyper::StatusCode; /// Extractor for the [`Scrape`] /// request. @@ -49,20 +49,11 @@ where { type Rejection = Response; - #[must_use] - fn from_request_parts<'life0, 'life1, 'async_trait>( - parts: &'life0 mut Parts, - _state: &'life1 S, - ) -> BoxFuture<'async_trait, Result> - where - 'life0: 'async_trait, - 'life1: 'async_trait, - Self: 'async_trait, - { + fn from_request_parts(parts: &mut Parts, _state: &S) -> impl Future> + Send { async { match extract_scrape_from(parts.uri.query()) { Ok(scrape_request) => Ok(ExtractRequest(scrape_request)), - Err(error) => Err(error.into_response()), + Err(error) => Err((StatusCode::OK, error.write()).into_response()), } } .boxed() @@ -95,11 +86,11 @@ fn extract_scrape_from(maybe_raw_query: Option<&str>) -> Result TestInfoHash { TestInfoHash { bencoded: "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0".to_owned(), - value: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), + value: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), // DevSkim: ignore DS173237 } } @@ -158,7 +149,7 @@ mod tests { assert_error_response( &response, - "Cannot parse query params for scrape request: missing query params for scrape request", + "Bad request. Cannot parse query params for scrape request: missing query params for scrape request", ); } @@ -167,13 +158,13 @@ mod tests { let invalid_query = "param1=value1=value2"; let response = extract_scrape_from(Some(invalid_query)).unwrap_err(); - assert_error_response(&response, "Cannot parse query params"); + assert_error_response(&response, "Bad request. Cannot parse query params"); } #[test] fn it_should_reject_a_request_with_a_query_that_cannot_be_parsed_into_a_scrape_request() { let response = extract_scrape_from(Some("param1=value1")).unwrap_err(); - assert_error_response(&response, "Cannot parse query params for scrape request"); + assert_error_response(&response, "Bad request. Cannot parse query params for scrape request"); } } diff --git a/packages/axum-http-tracker-server/src/v1/handlers/announce.rs b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs new file mode 100644 index 000000000..ce718cd30 --- /dev/null +++ b/packages/axum-http-tracker-server/src/v1/handlers/announce.rs @@ -0,0 +1,431 @@ +//! Axum [`handlers`](axum#handlers) for the `announce` requests. +//! +//! The handlers perform the authentication and authorization of the request, +//! and resolve the client IP address. +use std::sync::Arc; + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use bittorrent_http_tracker_core::services::announce::{AnnounceService, HttpAnnounceError}; +use bittorrent_http_tracker_protocol::v1::requests::announce::{Announce, Compact}; +use bittorrent_http_tracker_protocol::v1::responses::{self}; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; +use bittorrent_tracker_core::authentication::Key; +use hyper::StatusCode; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::v1::extractors::announce_request::ExtractRequest; +use crate::v1::extractors::authentication_key::Extract as ExtractKey; +use crate::v1::extractors::client_ip_sources::Extract as ExtractClientIpSources; + +/// It handles the `announce` request when the HTTP tracker does not require +/// authentication (no PATH `key` parameter required). +#[allow(clippy::unused_async)] +pub async fn handle_without_key( + State(state): State<(Arc, ServiceBinding)>, + ExtractRequest(announce_request): ExtractRequest, + ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, +) -> Response { + tracing::debug!("http announce request: {:#?}", announce_request); + + handle(&state.0, &announce_request, &client_ip_sources, &state.1, None).await +} + +/// It handles the `announce` request when the HTTP tracker requires +/// authentication (PATH `key` parameter required). +#[allow(clippy::unused_async)] +pub async fn handle_with_key( + State(state): State<(Arc, ServiceBinding)>, + ExtractRequest(announce_request): ExtractRequest, + ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, + ExtractKey(key): ExtractKey, +) -> Response { + tracing::debug!("http announce request: {:#?}", announce_request); + + handle(&state.0, &announce_request, &client_ip_sources, &state.1, Some(key)).await +} + +/// It handles the `announce` request. +/// +/// Internal implementation that handles both the `authenticated` and +/// `unauthenticated` modes. +async fn handle( + announce_service: &Arc, + announce_request: &Announce, + client_ip_sources: &ClientIpSources, + server_service_binding: &ServiceBinding, + maybe_key: Option, +) -> Response { + let announce_data = match handle_announce( + announce_service, + announce_request, + client_ip_sources, + server_service_binding, + maybe_key, + ) + .await + { + Ok(announce_data) => announce_data, + Err(error) => { + let error_response = responses::error::Error { + failure_reason: error.to_string(), + }; + return (StatusCode::OK, error_response.write()).into_response(); + } + }; + build_response(announce_request, announce_data) +} + +async fn handle_announce( + announce_service: &Arc, + announce_request: &Announce, + client_ip_sources: &ClientIpSources, + server_service_binding: &ServiceBinding, + maybe_key: Option, +) -> Result { + announce_service + .handle_announce(announce_request, client_ip_sources, server_service_binding, maybe_key) + .await +} + +fn build_response(announce_request: &Announce, announce_data: AnnounceData) -> Response { + if announce_request.compact.as_ref().is_some_and(|f| *f == Compact::Accepted) { + let response: responses::Announce = announce_data.into(); + let bytes: Vec = response.data.into(); + (StatusCode::OK, bytes).into_response() + } else { + let response: responses::Announce = announce_data.into(); + let bytes: Vec = response.data.into(); + (StatusCode::OK, bytes).into_response() + } +} + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use aquatic_udp_protocol::PeerId; + use bittorrent_http_tracker_core::event::bus::EventBus; + use bittorrent_http_tracker_core::event::sender::Broadcaster; + use bittorrent_http_tracker_core::services::announce::AnnounceService; + use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; + use bittorrent_http_tracker_core::statistics::repository::Repository; + use bittorrent_http_tracker_protocol::v1::requests::announce::Announce; + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::authentication::key::repository::in_memory::InMemoryKeyRepository; + use bittorrent_tracker_core::authentication::service::AuthenticationService; + use bittorrent_tracker_core::databases::setup::initialize_database; + use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use tokio_util::sync::CancellationToken; + use torrust_tracker_configuration::Configuration; + use torrust_tracker_test_helpers::configuration; + + use crate::tests::helpers::sample_info_hash; + + struct CoreHttpTrackerServices { + pub announce_service: Arc, + } + + fn initialize_private_tracker() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_private()) + } + + fn initialize_listed_tracker() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_listed()) + } + + fn initialize_tracker_on_reverse_proxy() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_with_reverse_proxy()) + } + + fn initialize_tracker_not_on_reverse_proxy() -> CoreHttpTrackerServices { + initialize_core_tracker_services(&configuration::ephemeral_without_reverse_proxy()) + } + + fn initialize_core_tracker_services(config: &Configuration) -> CoreHttpTrackerServices { + let cancellation_token = CancellationToken::new(); + + // Initialize the core tracker services with the provided configuration. + let core_config = Arc::new(config.core.clone()); + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + config.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + if config.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + let announce_service = Arc::new(AnnounceService::new( + core_config.clone(), + announce_handler.clone(), + authentication_service.clone(), + whitelist_authorization.clone(), + http_stats_event_sender.clone(), + )); + + CoreHttpTrackerServices { announce_service } + } + + fn sample_announce_request() -> Announce { + Announce { + info_hash: sample_info_hash(), + peer_id: PeerId(*b"-qB00000000000000001"), + port: 17548, + downloaded: None, + uploaded: None, + left: None, + event: None, + compact: None, + numwant: None, + } + } + + fn sample_client_ip_sources() -> ClientIpSources { + ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + } + } + + fn assert_error_response(error: &responses::error::Error, error_message: &str) { + assert!( + error.failure_reason.contains(error_message), + "Error response does not contain message: '{error_message}'. Error: {error:?}" + ); + } + + mod with_tracker_in_private_mode { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_tracker_core::authentication; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_private_tracker, sample_announce_request, sample_client_ip_sources}; + use crate::v1::handlers::announce::handle_announce; + use crate::v1::handlers::announce::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_authentication_key_is_missing() { + let http_core_tracker_services = initialize_private_tracker(); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let maybe_key = None; + + let response = handle_announce( + &http_core_tracker_services.announce_service, + &sample_announce_request(), + &sample_client_ip_sources(), + &server_service_binding, + maybe_key, + ) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Tracker core error: Tracker core authentication error: Missing authentication key", + ); + } + + #[tokio::test] + async fn it_should_fail_when_the_authentication_key_is_invalid() { + let http_core_tracker_services = initialize_private_tracker(); + + let unregistered_key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let maybe_key = Some(unregistered_key); + + let response = handle_announce( + &http_core_tracker_services.announce_service, + &sample_announce_request(), + &sample_client_ip_sources(), + &server_service_binding, + maybe_key, + ) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Tracker core error: Tracker core authentication error: Failed to read key: YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ", + ); + } + } + + mod with_tracker_in_listed_mode { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_protocol::v1::responses; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_listed_tracker, sample_announce_request, sample_client_ip_sources}; + use crate::v1::handlers::announce::handle_announce; + use crate::v1::handlers::announce::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_announced_torrent_is_not_whitelisted() { + let http_core_tracker_services = initialize_listed_tracker(); + + let announce_request = sample_announce_request(); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let response = handle_announce( + &http_core_tracker_services.announce_service, + &announce_request, + &sample_client_ip_sources(), + &server_service_binding, + None, + ) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + &format!( + "Tracker core error: Tracker core whitelist error: The torrent: {}, is not whitelisted", + announce_request.info_hash + ), + ); + } + } + + mod with_tracker_on_reverse_proxy { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_tracker_on_reverse_proxy, sample_announce_request}; + use crate::v1::handlers::announce::handle_announce; + use crate::v1::handlers::announce::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_right_most_x_forwarded_for_header_ip_is_not_available() { + let http_core_tracker_services = initialize_tracker_on_reverse_proxy(); + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let response = handle_announce( + &http_core_tracker_services.announce_service, + &sample_announce_request(), + &client_ip_sources, + &server_service_binding, + None, + ) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Error resolving peer IP: missing or invalid the right most X-Forwarded-For IP", + ); + } + } + + mod with_tracker_not_on_reverse_proxy { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_tracker_not_on_reverse_proxy, sample_announce_request}; + use crate::v1::handlers::announce::handle_announce; + use crate::v1::handlers::announce::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_client_ip_from_the_connection_info_is_not_available() { + let http_core_tracker_services = initialize_tracker_not_on_reverse_proxy(); + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let response = handle_announce( + &http_core_tracker_services.announce_service, + &sample_announce_request(), + &client_ip_sources, + &server_service_binding, + None, + ) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Error resolving peer IP: cannot get the client IP from the connection info", + ); + } + } +} diff --git a/src/servers/http/v1/handlers/health_check.rs b/packages/axum-http-tracker-server/src/v1/handlers/health_check.rs similarity index 100% rename from src/servers/http/v1/handlers/health_check.rs rename to packages/axum-http-tracker-server/src/v1/handlers/health_check.rs diff --git a/packages/axum-http-tracker-server/src/v1/handlers/mod.rs b/packages/axum-http-tracker-server/src/v1/handlers/mod.rs new file mode 100644 index 000000000..785213696 --- /dev/null +++ b/packages/axum-http-tracker-server/src/v1/handlers/mod.rs @@ -0,0 +1,4 @@ +//! Axum [`handlers`](axum#handlers) for the HTTP server. +pub mod announce; +pub mod health_check; +pub mod scrape; diff --git a/packages/axum-http-tracker-server/src/v1/handlers/scrape.rs b/packages/axum-http-tracker-server/src/v1/handlers/scrape.rs new file mode 100644 index 000000000..bdd4378f3 --- /dev/null +++ b/packages/axum-http-tracker-server/src/v1/handlers/scrape.rs @@ -0,0 +1,392 @@ +//! Axum [`handlers`](axum#handlers) for the `announce` requests. +//! +//! The handlers perform the authentication and authorization of the request, +//! and resolve the client IP address. +use std::sync::Arc; + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use bittorrent_http_tracker_core::services::scrape::ScrapeService; +use bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape; +use bittorrent_http_tracker_protocol::v1::responses; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; +use bittorrent_tracker_core::authentication::Key; +use hyper::StatusCode; +use torrust_tracker_primitives::core::ScrapeData; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::v1::extractors::authentication_key::Extract as ExtractKey; +use crate::v1::extractors::client_ip_sources::Extract as ExtractClientIpSources; +use crate::v1::extractors::scrape_request::ExtractRequest; + +/// It handles the `scrape` request when the HTTP tracker is configured +/// to run in `public` mode. +#[allow(clippy::unused_async)] +pub async fn handle_without_key( + State(state): State<(Arc, ServiceBinding)>, + ExtractRequest(scrape_request): ExtractRequest, + ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, +) -> Response { + tracing::debug!("http scrape request: {:#?}", &scrape_request); + + handle(&state.0, &scrape_request, &client_ip_sources, &state.1, None).await +} + +/// It handles the `scrape` request when the HTTP tracker is configured +/// to run in `private` or `private_listed` mode. +/// +/// In this case, the authentication `key` parameter is required. +#[allow(clippy::unused_async)] +pub async fn handle_with_key( + State(state): State<(Arc, ServiceBinding)>, + ExtractRequest(scrape_request): ExtractRequest, + ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, + ExtractKey(key): ExtractKey, +) -> Response { + tracing::debug!("http scrape request: {:#?}", &scrape_request); + + handle(&state.0, &scrape_request, &client_ip_sources, &state.1, Some(key)).await +} + +async fn handle( + scrape_service: &Arc, + scrape_request: &Scrape, + client_ip_sources: &ClientIpSources, + server_service_binding: &ServiceBinding, + maybe_key: Option, +) -> Response { + let scrape_data = match scrape_service + .handle_scrape(scrape_request, client_ip_sources, server_service_binding, maybe_key) + .await + { + Ok(scrape_data) => scrape_data, + Err(error) => { + let error_response = responses::error::Error { + failure_reason: error.to_string(), + }; + return (StatusCode::OK, error_response.write()).into_response(); + } + }; + + build_response(scrape_data) +} + +fn build_response(scrape_data: ScrapeData) -> Response { + let response = responses::scrape::Bencoded::from(scrape_data); + + (StatusCode::OK, response.body()).into_response() +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + use std::sync::Arc; + + use bittorrent_http_tracker_core::event::bus::EventBus; + use bittorrent_http_tracker_core::event::sender::Broadcaster; + use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; + use bittorrent_http_tracker_core::statistics::repository::Repository; + use bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape; + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use bittorrent_primitives::info_hash::InfoHash; + use bittorrent_tracker_core::authentication::key::repository::in_memory::InMemoryKeyRepository; + use bittorrent_tracker_core::authentication::service::AuthenticationService; + use bittorrent_tracker_core::scrape_handler::ScrapeHandler; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use tokio_util::sync::CancellationToken; + use torrust_tracker_configuration::{Configuration, Core}; + use torrust_tracker_test_helpers::configuration; + + struct CoreTrackerServices { + pub core_config: Arc, + pub scrape_handler: Arc, + pub authentication_service: Arc, + } + + struct CoreHttpTrackerServices { + pub http_stats_event_sender: bittorrent_http_tracker_core::event::sender::Sender, + } + + fn initialize_private_tracker() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_private()) + } + + fn initialize_listed_tracker() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_listed()) + } + + fn initialize_tracker_on_reverse_proxy() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_with_reverse_proxy()) + } + + fn initialize_tracker_not_on_reverse_proxy() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_without_reverse_proxy()) + } + + fn initialize_core_tracker_services(config: &Configuration) -> (CoreTrackerServices, CoreHttpTrackerServices) { + let cancellation_token = CancellationToken::new(); + + let core_config = Arc::new(config.core.clone()); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let scrape_handler = Arc::new(ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository)); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + config.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + if config.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + ( + CoreTrackerServices { + core_config, + scrape_handler, + authentication_service, + }, + CoreHttpTrackerServices { http_stats_event_sender }, + ) + } + + fn sample_scrape_request() -> Scrape { + Scrape { + info_hashes: vec!["3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap()], // DevSkim: ignore DS173237 + } + } + + fn sample_client_ip_sources() -> ClientIpSources { + ClientIpSources { + right_most_x_forwarded_for: Some(IpAddr::from_str("203.0.113.195").unwrap()), + connection_info_socket_address: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 8080)), + } + } + + fn assert_error_response(error: &responses::error::Error, error_message: &str) { + assert!( + error.failure_reason.contains(error_message), + "Error response does not contain message: '{error_message}'. Error: {error:?}" + ); + } + + mod with_tracker_in_private_mode { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + use bittorrent_http_tracker_core::services::scrape::ScrapeService; + use bittorrent_tracker_core::authentication; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_private_tracker, sample_client_ip_sources, sample_scrape_request}; + + #[tokio::test] + async fn it_should_return_zeroed_swarm_metadata_when_the_authentication_key_is_missing() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let (core_tracker_services, core_http_tracker_services) = initialize_private_tracker(); + + let scrape_request = sample_scrape_request(); + let maybe_key = None; + + let scrape_service = ScrapeService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.scrape_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let scrape_data = scrape_service + .handle_scrape( + &scrape_request, + &sample_client_ip_sources(), + &server_service_binding, + maybe_key, + ) + .await + .unwrap(); + + let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); + + assert_eq!(scrape_data, expected_scrape_data); + } + + #[tokio::test] + async fn it_should_return_zeroed_swarm_metadata_when_the_authentication_key_is_invalid() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let (core_tracker_services, core_http_tracker_services) = initialize_private_tracker(); + + let scrape_request = sample_scrape_request(); + let unregistered_key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + let maybe_key = Some(unregistered_key); + + let scrape_service = ScrapeService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.scrape_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let scrape_data = scrape_service + .handle_scrape( + &scrape_request, + &sample_client_ip_sources(), + &server_service_binding, + maybe_key, + ) + .await + .unwrap(); + + let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); + + assert_eq!(scrape_data, expected_scrape_data); + } + } + + mod with_tracker_in_listed_mode { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_core::services::scrape::ScrapeService; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_listed_tracker, sample_client_ip_sources, sample_scrape_request}; + + #[tokio::test] + async fn it_should_return_zeroed_swarm_metadata_when_the_torrent_is_not_whitelisted() { + let (core_tracker_services, core_http_tracker_services) = initialize_listed_tracker(); + + let scrape_request = sample_scrape_request(); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = ScrapeService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.scrape_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let scrape_data = scrape_service + .handle_scrape(&scrape_request, &sample_client_ip_sources(), &server_service_binding, None) + .await + .unwrap(); + + let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); + + assert_eq!(scrape_data, expected_scrape_data); + } + } + + mod with_tracker_on_reverse_proxy { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_core::services::scrape::ScrapeService; + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_tracker_on_reverse_proxy, sample_scrape_request}; + use crate::v1::handlers::scrape::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_right_most_x_forwarded_for_header_ip_is_not_available() { + let (core_tracker_services, core_http_tracker_services) = initialize_tracker_on_reverse_proxy(); + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = ScrapeService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.scrape_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let response = scrape_service + .handle_scrape(&sample_scrape_request(), &client_ip_sources, &server_service_binding, None) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Error resolving peer IP: missing or invalid the right most X-Forwarded-For IP", + ); + } + } + + mod with_tracker_not_on_reverse_proxy { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use bittorrent_http_tracker_core::services::scrape::ScrapeService; + use bittorrent_http_tracker_protocol::v1::responses; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::{initialize_tracker_not_on_reverse_proxy, sample_scrape_request}; + use crate::v1::handlers::scrape::tests::assert_error_response; + + #[tokio::test] + async fn it_should_fail_when_the_client_ip_from_the_connection_info_is_not_available() { + let (core_tracker_services, core_http_tracker_services) = initialize_tracker_not_on_reverse_proxy(); + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = ScrapeService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.scrape_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let response = scrape_service + .handle_scrape(&sample_scrape_request(), &client_ip_sources, &server_service_binding, None) + .await + .unwrap_err(); + + let error_response = responses::error::Error { + failure_reason: response.to_string(), + }; + + assert_error_response( + &error_response, + "Error resolving peer IP: cannot get the client IP from the connection info", + ); + } + } +} diff --git a/packages/axum-http-tracker-server/src/v1/mod.rs b/packages/axum-http-tracker-server/src/v1/mod.rs new file mode 100644 index 000000000..7b1b15138 --- /dev/null +++ b/packages/axum-http-tracker-server/src/v1/mod.rs @@ -0,0 +1,4 @@ +//! HTTP server implementation for the `v1` API. +pub mod extractors; +pub mod handlers; +pub mod routes; diff --git a/src/servers/http/v1/routes.rs b/packages/axum-http-tracker-server/src/v1/routes.rs similarity index 50% rename from src/servers/http/v1/routes.rs rename to packages/axum-http-tracker-server/src/v1/routes.rs index 16e39b61b..df395cd9a 100644 --- a/src/servers/http/v1/routes.rs +++ b/packages/axum-http-tracker-server/src/v1/routes.rs @@ -1,5 +1,4 @@ //! HTTP server routes for version `v1`. -use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -9,36 +8,61 @@ use axum::response::Response; use axum::routing::get; use axum::{BoxError, Router}; use axum_client_ip::SecureClientIpSource; +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; use hyper::{Request, StatusCode}; +use torrust_server_lib::logging::Latency; use torrust_tracker_configuration::DEFAULT_TIMEOUT; +use torrust_tracker_primitives::service_binding::ServiceBinding; use tower::timeout::TimeoutLayer; use tower::ServiceBuilder; +use tower_http::classify::ServerErrorsFailureClass; use tower_http::compression::CompressionLayer; use tower_http::propagate_header::PropagateHeaderLayer; use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer}; use tower_http::trace::{DefaultMakeSpan, TraceLayer}; +use tower_http::LatencyUnit; use tracing::{instrument, Level, Span}; use super::handlers::{announce, health_check, scrape}; -use crate::core::Tracker; -use crate::servers::http::HTTP_TRACKER_LOG_TARGET; +use crate::HTTP_TRACKER_LOG_TARGET; /// It adds the routes to the router. /// /// > **NOTICE**: it's added a layer to get the client IP from the connection /// > info. The tracker could use the connection info to get the client IP. -#[allow(clippy::needless_pass_by_value)] -#[instrument(skip(tracker, server_socket_addr))] -pub fn router(tracker: Arc, server_socket_addr: SocketAddr) -> Router { +#[instrument(skip(http_tracker_container, server_service_binding))] +pub fn router(http_tracker_container: &Arc, server_service_binding: &ServiceBinding) -> Router { + let server_socket_addr = server_service_binding.bind_address(); + Router::new() // Health check .route("/health_check", get(health_check::handler)) // Announce request - .route("/announce", get(announce::handle_without_key).with_state(tracker.clone())) - .route("/announce/:key", get(announce::handle_with_key).with_state(tracker.clone())) + .route( + "/announce", + get(announce::handle_without_key).with_state(( + http_tracker_container.announce_service.clone(), + server_service_binding.clone(), + )), + ) + .route( + "/announce/{key}", + get(announce::handle_with_key).with_state(( + http_tracker_container.announce_service.clone(), + server_service_binding.clone(), + )), + ) // Scrape request - .route("/scrape", get(scrape::handle_without_key).with_state(tracker.clone())) - .route("/scrape/:key", get(scrape::handle_with_key).with_state(tracker)) + .route( + "/scrape", + get(scrape::handle_without_key) + .with_state((http_tracker_container.scrape_service.clone(), server_service_binding.clone())), + ) + .route( + "/scrape/{key}", + get(scrape::handle_with_key) + .with_state((http_tracker_container.scrape_service.clone(), server_service_binding.clone())), + ) // Add extension to get the client IP from the connection info .layer(SecureClientIpSource::ConnectInfo.into_extension()) .layer(CompressionLayer::new()) @@ -47,7 +71,7 @@ pub fn router(tracker: Arc, server_socket_addr: SocketAddr) -> Router { .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) - .on_request(move |request: &Request, _span: &Span| { + .on_request(move |request: &Request, span: &Span| { let method = request.method().to_string(); let uri = request.uri().to_string(); let request_id = request @@ -56,26 +80,45 @@ pub fn router(tracker: Arc, server_socket_addr: SocketAddr) -> Router { .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - tracing::span!( + span.record("request_id", request_id); + + tracing::event!( target: HTTP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "request", server_socket_addr= %server_socket_addr, method = %method, uri = %uri, request_id = %request_id); + tracing::Level::INFO, %server_socket_addr, %method, %uri, %request_id, "request"); }) - .on_response(move |response: &Response, latency: Duration, _span: &Span| { + .on_response(move |response: &Response, latency: Duration, span: &Span| { + let latency_ms = latency.as_millis(); let status_code = response.status(); let request_id = response .headers() .get("x-request-id") .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - let latency_ms = latency.as_millis(); - tracing::span!( - target: HTTP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "response", server_socket_addr= %server_socket_addr, latency = %latency_ms, status = %status_code, request_id = %request_id); - }), + span.record("request_id", request_id); + + if status_code.is_server_error() { + tracing::event!( + target: HTTP_TRACKER_LOG_TARGET, + tracing::Level::ERROR, %server_socket_addr, %latency_ms, %status_code, %request_id, "response"); + } else { + tracing::event!( + target: HTTP_TRACKER_LOG_TARGET, + tracing::Level::INFO, %server_socket_addr, %latency_ms, %status_code, %request_id, "response"); + } + }) + .on_failure( + |failure_classification: ServerErrorsFailureClass, latency: Duration, _span: &Span| { + let latency = Latency::new(LatencyUnit::Millis, latency); + + tracing::event!( + target: HTTP_TRACKER_LOG_TARGET, + tracing::Level::ERROR, %failure_classification, %latency, "response failed"); + }, + ), ) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) - .layer( + .layer( ServiceBuilder::new() // this middleware goes above `TimeoutLayer` because it will receive // errors returned by `TimeoutLayer` diff --git a/packages/axum-http-tracker-server/tests/common/fixtures.rs b/packages/axum-http-tracker-server/tests/common/fixtures.rs new file mode 100644 index 000000000..2b4a42b58 --- /dev/null +++ b/packages/axum-http-tracker-server/tests/common/fixtures.rs @@ -0,0 +1,22 @@ +use bittorrent_primitives::info_hash::InfoHash; +use rand::prelude::*; + +pub fn invalid_info_hashes() -> Vec { + [ + "0".to_string(), + "-1".to_string(), + "1.1".to_string(), + "INVALID INFOHASH".to_string(), + "9c38422213e30bff212b30c360d26f9a0213642".to_string(), // 39-char length instead of 40. DevSkim: ignore DS173237 + "9c38422213e30bff212b30c360d26f9a0213642&".to_string(), // Invalid char + ] + .to_vec() +} + +/// Returns a random info hash. +pub fn random_info_hash() -> InfoHash { + let mut rng = rand::rng(); + let random_bytes: [u8; 20] = rng.random(); + + InfoHash::from_bytes(&random_bytes) +} diff --git a/tests/common/http.rs b/packages/axum-http-tracker-server/tests/common/http.rs similarity index 100% rename from tests/common/http.rs rename to packages/axum-http-tracker-server/tests/common/http.rs diff --git a/packages/axum-http-tracker-server/tests/common/mod.rs b/packages/axum-http-tracker-server/tests/common/mod.rs new file mode 100644 index 000000000..810620359 --- /dev/null +++ b/packages/axum-http-tracker-server/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod fixtures; +pub mod http; diff --git a/packages/axum-http-tracker-server/tests/integration.rs b/packages/axum-http-tracker-server/tests/integration.rs new file mode 100644 index 000000000..70b3aeb89 --- /dev/null +++ b/packages/axum-http-tracker-server/tests/integration.rs @@ -0,0 +1,20 @@ +//! Integration tests. +//! +//! ```text +//! cargo test --test integration +//! ``` +mod common; +mod server; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/tests/servers/http/asserts.rs b/packages/axum-http-tracker-server/tests/server/asserts.rs similarity index 89% rename from tests/servers/http/asserts.rs rename to packages/axum-http-tracker-server/tests/server/asserts.rs index 3a2e67bf0..a82014e16 100644 --- a/tests/servers/http/asserts.rs +++ b/packages/axum-http-tracker-server/tests/server/asserts.rs @@ -4,7 +4,7 @@ use reqwest::Response; use super::responses::announce::{Announce, Compact, DeserializedCompact}; use super::responses::scrape; -use crate::servers::http::responses::error::Error; +use crate::server::responses::error::Error; pub fn assert_bencoded_error(response_text: &String, expected_failure_reason: &str, location: &'static Location<'static>) { let error_failure_reason = serde_bencode::from_str::(response_text) @@ -22,6 +22,7 @@ pub fn assert_bencoded_error(response_text: &String, expected_failure_reason: &s ); } +#[allow(dead_code)] pub async fn assert_empty_announce_response(response: Response) { assert_eq!(response.status(), 200); let announce_response: Announce = serde_bencode::from_str(&response.text().await.unwrap()).unwrap(); @@ -133,7 +134,7 @@ pub async fn assert_cannot_parse_query_params_error_response(response: Response, assert_bencoded_error( &response.text().await.unwrap(), - &format!("Cannot parse query params{failure}"), + &format!("Bad request. Cannot parse query params{failure}"), Location::caller(), ); } @@ -141,5 +142,19 @@ pub async fn assert_cannot_parse_query_params_error_response(response: Response, pub async fn assert_authentication_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_bencoded_error(&response.text().await.unwrap(), "Authentication error", Location::caller()); + assert_bencoded_error( + &response.text().await.unwrap(), + "Tracker authentication error", + Location::caller(), + ); +} + +pub async fn assert_tracker_core_authentication_error_response(response: Response) { + assert_eq!(response.status(), 200); + + assert_bencoded_error( + &response.text().await.unwrap(), + "Tracker core error: Tracker core authentication error", + Location::caller(), + ); } diff --git a/tests/servers/http/client.rs b/packages/axum-http-tracker-server/tests/server/client.rs similarity index 98% rename from tests/servers/http/client.rs rename to packages/axum-http-tracker-server/tests/server/client.rs index 288987c55..ca9703858 100644 --- a/tests/servers/http/client.rs +++ b/packages/axum-http-tracker-server/tests/server/client.rs @@ -1,7 +1,7 @@ use std::net::IpAddr; +use bittorrent_tracker_core::authentication::Key; use reqwest::{Client as ReqwestClient, Response}; -use torrust_tracker::core::auth::Key; use super::requests::announce::{self, Query}; use super::requests::scrape; diff --git a/src/shared/bit_torrent/tracker/http/mod.rs b/packages/axum-http-tracker-server/tests/server/mod.rs similarity index 88% rename from src/shared/bit_torrent/tracker/http/mod.rs rename to packages/axum-http-tracker-server/tests/server/mod.rs index 15723c1b7..31b48b2f0 100644 --- a/src/shared/bit_torrent/tracker/http/mod.rs +++ b/packages/axum-http-tracker-server/tests/server/mod.rs @@ -1,10 +1,13 @@ +pub mod asserts; pub mod client; +pub mod requests; +pub mod responses; +pub mod v1; use percent_encoding::NON_ALPHANUMERIC; pub type ByteArray20 = [u8; 20]; -#[must_use] pub fn percent_encode_byte_array(bytes: &ByteArray20) -> String { percent_encoding::percent_encode(bytes, NON_ALPHANUMERIC).to_string() } @@ -12,14 +15,12 @@ pub fn percent_encode_byte_array(bytes: &ByteArray20) -> String { pub struct InfoHash(ByteArray20); impl InfoHash { - #[must_use] pub fn new(vec: &[u8]) -> Self { let mut byte_array_20: ByteArray20 = Default::default(); byte_array_20.clone_from_slice(vec); Self(byte_array_20) } - #[must_use] pub fn bytes(&self) -> ByteArray20 { self.0 } diff --git a/tests/servers/http/requests/announce.rs b/packages/axum-http-tracker-server/tests/server/requests/announce.rs similarity index 97% rename from tests/servers/http/requests/announce.rs rename to packages/axum-http-tracker-server/tests/server/requests/announce.rs index fa20553d0..5a670b618 100644 --- a/tests/servers/http/requests/announce.rs +++ b/packages/axum-http-tracker-server/tests/server/requests/announce.rs @@ -3,10 +3,10 @@ use std::net::{IpAddr, Ipv4Addr}; use std::str::FromStr; use aquatic_udp_protocol::PeerId; +use bittorrent_primitives::info_hash::InfoHash; use serde_repr::Serialize_repr; -use torrust_tracker_primitives::info_hash::InfoHash; -use crate::servers::http::{percent_encode_byte_array, ByteArray20}; +use crate::server::{percent_encode_byte_array, ByteArray20}; pub struct Query { pub info_hash: ByteArray20, @@ -126,6 +126,11 @@ impl QueryBuilder { self } + pub fn with_port(mut self, port: u16) -> Self { + self.announce_query.port = port; + self + } + pub fn without_compact(mut self) -> Self { self.announce_query.compact = None; self diff --git a/src/shared/bit_torrent/tracker/http/client/requests/mod.rs b/packages/axum-http-tracker-server/tests/server/requests/mod.rs similarity index 100% rename from src/shared/bit_torrent/tracker/http/client/requests/mod.rs rename to packages/axum-http-tracker-server/tests/server/requests/mod.rs diff --git a/tests/servers/http/requests/scrape.rs b/packages/axum-http-tracker-server/tests/server/requests/scrape.rs similarity index 96% rename from tests/servers/http/requests/scrape.rs rename to packages/axum-http-tracker-server/tests/server/requests/scrape.rs index f66605855..afd8cfbe3 100644 --- a/tests/servers/http/requests/scrape.rs +++ b/packages/axum-http-tracker-server/tests/server/requests/scrape.rs @@ -1,9 +1,9 @@ use std::fmt; use std::str::FromStr; -use torrust_tracker_primitives::info_hash::InfoHash; +use bittorrent_primitives::info_hash::InfoHash; -use crate::servers::http::{percent_encode_byte_array, ByteArray20}; +use crate::server::{percent_encode_byte_array, ByteArray20}; pub struct Query { pub info_hash: Vec, diff --git a/tests/servers/http/responses/announce.rs b/packages/axum-http-tracker-server/tests/server/responses/announce.rs similarity index 100% rename from tests/servers/http/responses/announce.rs rename to packages/axum-http-tracker-server/tests/server/responses/announce.rs diff --git a/src/shared/bit_torrent/tracker/http/client/responses/error.rs b/packages/axum-http-tracker-server/tests/server/responses/error.rs similarity index 100% rename from src/shared/bit_torrent/tracker/http/client/responses/error.rs rename to packages/axum-http-tracker-server/tests/server/responses/error.rs diff --git a/src/shared/bit_torrent/tracker/http/client/responses/mod.rs b/packages/axum-http-tracker-server/tests/server/responses/mod.rs similarity index 100% rename from src/shared/bit_torrent/tracker/http/client/responses/mod.rs rename to packages/axum-http-tracker-server/tests/server/responses/mod.rs diff --git a/tests/servers/http/responses/scrape.rs b/packages/axum-http-tracker-server/tests/server/responses/scrape.rs similarity index 99% rename from tests/servers/http/responses/scrape.rs rename to packages/axum-http-tracker-server/tests/server/responses/scrape.rs index fc741cbf4..5de15c731 100644 --- a/tests/servers/http/responses/scrape.rs +++ b/packages/axum-http-tracker-server/tests/server/responses/scrape.rs @@ -4,7 +4,7 @@ use std::str; use serde::{Deserialize, Serialize}; use serde_bencode::value::Value; -use crate::servers::http::{ByteArray20, InfoHash}; +use crate::server::{ByteArray20, InfoHash}; #[derive(Debug, PartialEq, Default)] pub struct Response { diff --git a/tests/servers/http/v1/contract.rs b/packages/axum-http-tracker-server/tests/server/v1/contract.rs similarity index 74% rename from tests/servers/http/v1/contract.rs rename to packages/axum-http-tracker-server/tests/server/v1/contract.rs index 405a35dc5..85792f922 100644 --- a/tests/servers/http/v1/contract.rs +++ b/packages/axum-http-tracker-server/tests/server/v1/contract.rs @@ -1,9 +1,10 @@ -use torrust_tracker_test_helpers::configuration; - -use crate::servers::http::Started; +use torrust_axum_http_tracker_server::environment::Started; +use torrust_tracker_test_helpers::{configuration, logging}; #[tokio::test] async fn environment_should_be_started_and_stopped() { + logging::setup(); + let env = Started::new(&configuration::ephemeral().into()).await; env.stop().await; @@ -11,19 +12,15 @@ async fn environment_should_be_started_and_stopped() { mod for_all_config_modes { - use torrust_tracker::servers::http::v1::handlers::health_check::{Report, Status}; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_axum_http_tracker_server::environment::Started; + use torrust_axum_http_tracker_server::v1::handlers::health_check::{Report, Status}; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::client::Client; - use crate::servers::http::Started; + use crate::server::client::Client; #[tokio::test] async fn health_check_endpoint_should_return_ok_if_the_http_tracker_is_running() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_with_reverse_proxy().into()).await; @@ -37,20 +34,16 @@ mod for_all_config_modes { } mod and_running_on_reverse_proxy { - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_axum_http_tracker_server::environment::Started; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::assert_could_not_find_remote_address_on_x_forwarded_for_header_error_response; - use crate::servers::http::client::Client; - use crate::servers::http::requests::announce::QueryBuilder; - use crate::servers::http::Started; + use crate::server::asserts::assert_could_not_find_remote_address_on_x_forwarded_for_header_error_response; + use crate::server::client::Client; + use crate::server::requests::announce::QueryBuilder; #[tokio::test] async fn should_fail_when_the_http_request_does_not_include_the_xff_http_request_header() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // If the tracker is running behind a reverse proxy, the peer IP is the // right most IP in the `X-Forwarded-For` HTTP header, which is the IP of the proxy's client. @@ -68,9 +61,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_xff_http_request_header_contains_an_invalid_ip() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_with_reverse_proxy().into()).await; @@ -103,31 +94,28 @@ mod for_all_config_modes { use std::str::FromStr; use aquatic_udp_protocol::PeerId; + use bittorrent_primitives::info_hash::InfoHash; use local_ip_address::local_ip; use reqwest::{Response, StatusCode}; use tokio::net::TcpListener; - use torrust_tracker_primitives::info_hash::InfoHash; + use torrust_axum_http_tracker_server::environment::Started; use torrust_tracker_primitives::peer::fixture::PeerBuilder; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_tracker_test_helpers::{configuration, logging}; use crate::common::fixtures::invalid_info_hashes; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::{ + use crate::server::asserts::{ assert_announce_response, assert_bad_announce_request_error_response, assert_cannot_parse_query_param_error_response, - assert_cannot_parse_query_params_error_response, assert_compact_announce_response, assert_empty_announce_response, - assert_is_announce_response, assert_missing_query_params_for_announce_request_error_response, + assert_cannot_parse_query_params_error_response, assert_compact_announce_response, assert_is_announce_response, + assert_missing_query_params_for_announce_request_error_response, }; - use crate::servers::http::client::Client; - use crate::servers::http::requests::announce::{Compact, QueryBuilder}; - use crate::servers::http::responses::announce::{Announce, CompactPeer, CompactPeerList, DictionaryPeer}; - use crate::servers::http::{responses, Started}; + use crate::server::client::Client; + use crate::server::requests::announce::{Compact, QueryBuilder}; + use crate::server::responses; + use crate::server::responses::announce::{Announce, CompactPeer, CompactPeerList, DictionaryPeer}; #[tokio::test] async fn it_should_start_and_stop() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; env.stop().await; @@ -135,9 +123,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_respond_if_only_the_mandatory_fields_are_provided() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -154,9 +140,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_url_query_component_is_empty() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -169,9 +153,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_url_query_parameters_are_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -188,9 +170,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_a_mandatory_field_is_missing() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -229,9 +209,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_info_hash_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -250,9 +228,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_not_fail_when_the_peer_address_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // AnnounceQuery does not even contain the `peer_addr` // The peer IP is obtained in two ways: @@ -274,9 +250,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_downloaded_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -297,9 +271,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_uploaded_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -320,9 +292,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_peer_id_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -350,9 +320,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_port_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -373,9 +341,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_left_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -396,9 +362,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_event_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -427,9 +391,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_compact_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -450,9 +412,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_numwant_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -473,21 +433,19 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_no_peers_if_the_announced_peer_is_the_first_one() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; let response = Client::new(*env.bind_address()) .announce( &QueryBuilder::default() - .with_info_hash(&InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap()) + .with_info_hash(&InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap()) // DevSkim: ignore DS173237 .query(), ) .await; - let announce_policy = env.tracker.get_announce_policy(); + let announce_policy = env.container.tracker_core_container.core_config.announce_policy; assert_announce_response( response, @@ -506,19 +464,17 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_the_list_of_previously_announced_peers() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 // Peer 1 let previously_announced_peer = PeerBuilder::default().with_peer_id(&PeerId(*b"-qB00000000000000001")).build(); // Add the Peer 1 - env.add_torrent_peer(&info_hash, &previously_announced_peer); + env.add_torrent_peer(&info_hash, &previously_announced_peer).await; // Announce the new Peer 2. This new peer is non included on the response peer list let response = Client::new(*env.bind_address()) @@ -530,7 +486,7 @@ mod for_all_config_modes { ) .await; - let announce_policy = env.tracker.get_announce_policy(); + let announce_policy = env.container.tracker_core_container.core_config.announce_policy; // It should only contain the previously announced peer assert_announce_response( @@ -550,20 +506,18 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_the_list_of_previously_announced_peers_including_peers_using_ipv4_and_ipv6() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 // Announce a peer using IPV4 let peer_using_ipv4 = PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0x69, 0x69, 0x69, 0x69)), 8080)) .build(); - env.add_torrent_peer(&info_hash, &peer_using_ipv4); + env.add_torrent_peer(&info_hash, &peer_using_ipv4).await; // Announce a peer using IPV6 let peer_using_ipv6 = PeerBuilder::default() @@ -573,7 +527,7 @@ mod for_all_config_modes { 8080, )) .build(); - env.add_torrent_peer(&info_hash, &peer_using_ipv6); + env.add_torrent_peer(&info_hash, &peer_using_ipv6).await; // Announce the new Peer. let response = Client::new(*env.bind_address()) @@ -585,7 +539,7 @@ mod for_all_config_modes { ) .await; - let announce_policy = env.tracker.get_announce_policy(); + let announce_policy = env.container.tracker_core_container.core_config.announce_policy; // The newly announced peer is not included on the response peer list, // but all the previously announced peers should be included regardless the IP version they are using. @@ -605,51 +559,73 @@ mod for_all_config_modes { } #[tokio::test] - async fn should_consider_two_peers_to_be_the_same_when_they_have_the_same_peer_id_even_if_the_ip_is_different() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + async fn should_consider_two_peers_to_be_the_same_when_they_have_the_same_socket_address_even_if_the_peer_id_is_different( + ) { + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let peer = PeerBuilder::default().build(); - // Add a peer - env.add_torrent_peer(&info_hash, &peer); - - let announce_query = QueryBuilder::default() + let announce_query_1 = QueryBuilder::default() .with_info_hash(&info_hash) .with_peer_id(&peer.peer_id) + .with_peer_addr(&peer.peer_addr.ip()) + .with_port(peer.peer_addr.port()) + .query(); + + let announce_query_2 = QueryBuilder::default() + .with_info_hash(&info_hash) + .with_peer_id(&PeerId(*b"-qB00000000000000002")) // Different peer ID + .with_peer_addr(&peer.peer_addr.ip()) + .with_port(peer.peer_addr.port()) .query(); - assert_ne!(peer.peer_addr.ip(), announce_query.peer_addr); + // Same peer socket address + assert_eq!(announce_query_1.peer_addr, announce_query_2.peer_addr); + assert_eq!(announce_query_1.port, announce_query_2.port); - let response = Client::new(*env.bind_address()).announce(&announce_query).await; + // Different peer ID + assert_ne!(announce_query_1.peer_id, announce_query_2.peer_id); - assert_empty_announce_response(response).await; + let _response = Client::new(*env.bind_address()).announce(&announce_query_1).await; + let response = Client::new(*env.bind_address()).announce(&announce_query_2).await; + + let announce_policy = env.container.tracker_core_container.core_config.announce_policy; + + // The response should contain only the first peer. + assert_announce_response( + response, + &Announce { + complete: 1, + incomplete: 0, + interval: announce_policy.interval, + min_interval: announce_policy.interval_min, + peers: vec![], + }, + ) + .await; env.stop().await; } #[tokio::test] async fn should_return_the_compact_response() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // Tracker Returns Compact Peer Lists // https://www.bittorrent.org/beps/bep_0023.html let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 // Peer 1 let previously_announced_peer = PeerBuilder::default().with_peer_id(&PeerId(*b"-qB00000000000000001")).build(); // Add the Peer 1 - env.add_torrent_peer(&info_hash, &previously_announced_peer); + env.add_torrent_peer(&info_hash, &previously_announced_peer).await; // Announce the new Peer 2 accepting compact responses let response = Client::new(*env.bind_address()) @@ -677,22 +653,20 @@ mod for_all_config_modes { #[tokio::test] async fn should_not_return_the_compact_response_by_default() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // code-review: the HTTP tracker does not return the compact response by default if the "compact" // param is not provided in the announce URL. The BEP 23 suggest to do so. let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 // Peer 1 let previously_announced_peer = PeerBuilder::default().with_peer_id(&PeerId(*b"-qB00000000000000001")).build(); // Add the Peer 1 - env.add_torrent_peer(&info_hash, &previously_announced_peer); + env.add_torrent_peer(&info_hash, &previously_announced_peer).await; // Announce the new Peer 2 without passing the "compact" param // By default it should respond with the compact peer list @@ -718,87 +692,9 @@ mod for_all_config_modes { compact_announce.is_ok() } - #[tokio::test] - async fn should_increase_the_number_of_tcp4_connections_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral_public().into()).await; - - Client::new(*env.bind_address()) - .announce(&QueryBuilder::default().query()) - .await; - - let stats = env.tracker.get_stats().await; - - assert_eq!(stats.tcp4_connections_handled, 1); - - drop(stats); - - env.stop().await; - } - - #[tokio::test] - async fn should_increase_the_number_of_tcp6_connections_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - if TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)) - .await - .is_err() - { - return; // we cannot bind to a ipv6 socket, so we will skip this test - } - - let env = Started::new(&configuration::ephemeral_ipv6().into()).await; - - Client::bind(*env.bind_address(), IpAddr::from_str("::1").unwrap()) - .announce(&QueryBuilder::default().query()) - .await; - - let stats = env.tracker.get_stats().await; - - assert_eq!(stats.tcp6_connections_handled, 1); - - drop(stats); - - env.stop().await; - } - - #[tokio::test] - async fn should_not_increase_the_number_of_tcp6_connections_handled_if_the_client_is_not_using_an_ipv6_ip() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - // The tracker ignores the peer address in the request param. It uses the client remote ip address. - - let env = Started::new(&configuration::ephemeral_public().into()).await; - - Client::new(*env.bind_address()) - .announce( - &QueryBuilder::default() - .with_peer_addr(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))) - .query(), - ) - .await; - - let stats = env.tracker.get_stats().await; - - assert_eq!(stats.tcp6_connections_handled, 0); - - drop(stats); - - env.stop().await; - } - #[tokio::test] async fn should_increase_the_number_of_tcp4_announce_requests_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; @@ -806,9 +702,9 @@ mod for_all_config_modes { .announce(&QueryBuilder::default().query()) .await; - let stats = env.tracker.get_stats().await; + let stats = env.container.http_tracker_core_container.stats_repository.get_stats().await; - assert_eq!(stats.tcp4_announces_handled, 1); + assert_eq!(stats.tcp4_announces_handled(), 1); drop(stats); @@ -817,9 +713,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_increase_the_number_of_tcp6_announce_requests_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); if TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)) .await @@ -834,9 +728,9 @@ mod for_all_config_modes { .announce(&QueryBuilder::default().query()) .await; - let stats = env.tracker.get_stats().await; + let stats = env.container.http_tracker_core_container.stats_repository.get_stats().await; - assert_eq!(stats.tcp6_announces_handled, 1); + assert_eq!(stats.tcp6_announces_handled(), 1); drop(stats); @@ -845,9 +739,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_not_increase_the_number_of_tcp6_announce_requests_handled_if_the_client_is_not_using_an_ipv6_ip() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // The tracker ignores the peer address in the request param. It uses the client remote ip address. @@ -856,14 +748,14 @@ mod for_all_config_modes { Client::new(*env.bind_address()) .announce( &QueryBuilder::default() - .with_peer_addr(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))) + .with_peer_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST)) .query(), ) .await; - let stats = env.tracker.get_stats().await; + let stats = env.container.http_tracker_core_container.stats_repository.get_stats().await; - assert_eq!(stats.tcp6_announces_handled, 0); + assert_eq!(stats.tcp6_announces_handled(), 0); drop(stats); @@ -872,13 +764,11 @@ mod for_all_config_modes { #[tokio::test] async fn should_assign_to_the_peer_ip_the_remote_client_ip_instead_of_the_peer_address_in_the_request_param() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let client_ip = local_ip().unwrap(); let announce_query = QueryBuilder::default() @@ -893,7 +783,12 @@ mod for_all_config_modes { assert_eq!(status, StatusCode::OK); } - let peers = env.tracker.get_torrent_peers(&info_hash); + let peers = env + .container + .tracker_core_container + .in_memory_torrent_repository + .get_torrent_peers(&info_hash) + .await; let peer_addr = peers[0].peer_addr; assert_eq!(peer_addr.ip(), client_ip); @@ -905,9 +800,7 @@ mod for_all_config_modes { #[tokio::test] async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_to_the_peer_ip_the_external_ip_in_the_tracker_configuration( ) { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); /* We assume that both the client and tracker share the same public IP. @@ -917,7 +810,7 @@ mod for_all_config_modes { let env = Started::new(&configuration::ephemeral_with_external_ip(IpAddr::from_str("2.137.87.41").unwrap()).into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let loopback_ip = IpAddr::from_str("127.0.0.1").unwrap(); let client_ip = loopback_ip; @@ -933,10 +826,18 @@ mod for_all_config_modes { assert_eq!(status, StatusCode::OK); } - let peers = env.tracker.get_torrent_peers(&info_hash); + let peers = env + .container + .tracker_core_container + .in_memory_torrent_repository + .get_torrent_peers(&info_hash) + .await; let peer_addr = peers[0].peer_addr; - assert_eq!(peer_addr.ip(), env.tracker.get_maybe_external_ip().unwrap()); + assert_eq!( + peer_addr.ip(), + env.container.tracker_core_container.core_config.net.external_ip.unwrap() + ); assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); env.stop().await; @@ -945,9 +846,7 @@ mod for_all_config_modes { #[tokio::test] async fn when_the_client_ip_is_a_loopback_ipv6_it_should_assign_to_the_peer_ip_the_external_ip_in_the_tracker_configuration( ) { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); /* We assume that both the client and tracker share the same public IP. @@ -961,7 +860,7 @@ mod for_all_config_modes { ) .await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let loopback_ip = IpAddr::from_str("127.0.0.1").unwrap(); let client_ip = loopback_ip; @@ -977,10 +876,18 @@ mod for_all_config_modes { assert_eq!(status, StatusCode::OK); } - let peers = env.tracker.get_torrent_peers(&info_hash); + let peers = env + .container + .tracker_core_container + .in_memory_torrent_repository + .get_torrent_peers(&info_hash) + .await; let peer_addr = peers[0].peer_addr; - assert_eq!(peer_addr.ip(), env.tracker.get_maybe_external_ip().unwrap()); + assert_eq!( + peer_addr.ip(), + env.container.tracker_core_container.core_config.net.external_ip.unwrap() + ); assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); env.stop().await; @@ -989,9 +896,7 @@ mod for_all_config_modes { #[tokio::test] async fn when_the_tracker_is_behind_a_reverse_proxy_it_should_assign_to_the_peer_ip_the_ip_in_the_x_forwarded_for_http_header( ) { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); /* client <-> http proxy <-> tracker <-> Internet @@ -1001,7 +906,7 @@ mod for_all_config_modes { let env = Started::new(&configuration::ephemeral_with_reverse_proxy().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let announce_query = QueryBuilder::default().with_info_hash(&info_hash).query(); @@ -1019,7 +924,12 @@ mod for_all_config_modes { assert_eq!(status, StatusCode::OK); } - let peers = env.tracker.get_torrent_peers(&info_hash); + let peers = env + .container + .tracker_core_container + .in_memory_torrent_repository + .get_torrent_peers(&info_hash) + .await; let peer_addr = peers[0].peer_addr; assert_eq!(peer_addr.ip(), IpAddr::from_str("150.172.238.178").unwrap()); @@ -1042,29 +952,26 @@ mod for_all_config_modes { use std::str::FromStr; use aquatic_udp_protocol::PeerId; + use bittorrent_primitives::info_hash::InfoHash; use tokio::net::TcpListener; - use torrust_tracker_primitives::info_hash::InfoHash; + use torrust_axum_http_tracker_server::environment::Started; use torrust_tracker_primitives::peer::fixture::PeerBuilder; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_tracker_test_helpers::{configuration, logging}; use crate::common::fixtures::invalid_info_hashes; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::{ + use crate::server::asserts::{ assert_cannot_parse_query_params_error_response, assert_missing_query_params_for_scrape_request_error_response, assert_scrape_response, }; - use crate::servers::http::client::Client; - use crate::servers::http::requests::scrape::QueryBuilder; - use crate::servers::http::responses::scrape::{self, File, ResponseBuilder}; - use crate::servers::http::{requests, Started}; + use crate::server::client::Client; + use crate::server::requests; + use crate::server::requests::scrape::QueryBuilder; + use crate::server::responses::scrape::{self, File, ResponseBuilder}; #[tokio::test] #[allow(dead_code)] async fn should_fail_when_the_request_is_empty() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; let response = Client::new(*env.bind_address()).get("scrape").await; @@ -1076,9 +983,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_fail_when_the_info_hash_param_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; @@ -1097,21 +1002,20 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_the_file_with_the_incomplete_peer_when_there_is_one_peer_with_bytes_pending_to_download() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; let response = Client::new(*env.bind_address()) .scrape( @@ -1139,21 +1043,20 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_the_file_with_the_complete_peer_when_there_is_one_peer_with_no_bytes_pending_to_download() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_no_bytes_pending_to_download() + .with_no_bytes_left_to_download() .build(), - ); + ) + .await; let response = Client::new(*env.bind_address()) .scrape( @@ -1181,13 +1084,11 @@ mod for_all_config_modes { #[tokio::test] async fn should_return_a_file_with_zeroed_values_when_there_are_no_peers() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let response = Client::new(*env.bind_address()) .scrape( @@ -1204,14 +1105,12 @@ mod for_all_config_modes { #[tokio::test] async fn should_accept_multiple_infohashes() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash1 = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); - let info_hash2 = InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(); + let info_hash1 = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 + let info_hash2 = InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(); // DevSkim: ignore DS173237 let response = Client::new(*env.bind_address()) .scrape( @@ -1234,13 +1133,11 @@ mod for_all_config_modes { #[tokio::test] async fn should_increase_the_number_ot_tcp4_scrape_requests_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_public().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 Client::new(*env.bind_address()) .scrape( @@ -1250,9 +1147,9 @@ mod for_all_config_modes { ) .await; - let stats = env.tracker.get_stats().await; + let stats = env.container.http_tracker_core_container.stats_repository.get_stats().await; - assert_eq!(stats.tcp4_scrapes_handled, 1); + assert_eq!(stats.tcp4_scrapes_handled(), 1); drop(stats); @@ -1261,9 +1158,7 @@ mod for_all_config_modes { #[tokio::test] async fn should_increase_the_number_ot_tcp6_scrape_requests_handled_in_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); if TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)) .await @@ -1274,7 +1169,7 @@ mod for_all_config_modes { let env = Started::new(&configuration::ephemeral_ipv6().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 Client::bind(*env.bind_address(), IpAddr::from_str("::1").unwrap()) .scrape( @@ -1284,9 +1179,9 @@ mod for_all_config_modes { ) .await; - let stats = env.tracker.get_stats().await; + let stats = env.container.http_tracker_core_container.stats_repository.get_stats().await; - assert_eq!(stats.tcp6_scrapes_handled, 1); + assert_eq!(stats.tcp6_scrapes_handled(), 1); drop(stats); @@ -1300,46 +1195,55 @@ mod configured_as_whitelisted { mod and_receiving_an_announce_request { use std::str::FromStr; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use bittorrent_primitives::info_hash::InfoHash; + use torrust_axum_http_tracker_server::environment::Started; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + use uuid::Uuid; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::{assert_is_announce_response, assert_torrent_not_in_whitelist_error_response}; - use crate::servers::http::client::Client; - use crate::servers::http::requests::announce::QueryBuilder; - use crate::servers::http::Started; + use crate::common::fixtures::random_info_hash; + use crate::server::asserts::{assert_is_announce_response, assert_torrent_not_in_whitelist_error_response}; + use crate::server::client::Client; + use crate::server::requests::announce::QueryBuilder; #[tokio::test] async fn should_fail_if_the_torrent_is_not_in_the_whitelist() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_listed().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let request_id = Uuid::new_v4(); + let info_hash = random_info_hash(); let response = Client::new(*env.bind_address()) - .announce(&QueryBuilder::default().with_info_hash(&info_hash).query()) + .announce_with_header( + &QueryBuilder::default().with_info_hash(&info_hash).query(), + "x-request-id", + &request_id.to_string(), + ) .await; assert_torrent_not_in_whitelist_error_response(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", &format!("{info_hash}"), "is not whitelisted"]), + "Expected logs to contain: ERROR ... {info_hash} is not whitelisted" + ); + env.stop().await; } #[tokio::test] async fn should_allow_announcing_a_whitelisted_torrent() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_listed().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 - env.tracker + env.container + .tracker_core_container + .whitelist_manager .add_torrent_to_whitelist(&info_hash) .await .expect("should add the torrent to the whitelist"); @@ -1358,34 +1262,34 @@ mod configured_as_whitelisted { use std::str::FromStr; use aquatic_udp_protocol::PeerId; - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; + use torrust_axum_http_tracker_server::environment::Started; use torrust_tracker_primitives::peer::fixture::PeerBuilder; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::assert_scrape_response; - use crate::servers::http::client::Client; - use crate::servers::http::responses::scrape::{File, ResponseBuilder}; - use crate::servers::http::{requests, Started}; + use crate::common::fixtures::random_info_hash; + use crate::server::asserts::assert_scrape_response; + use crate::server::client::Client; + use crate::server::requests; + use crate::server::responses::scrape::{File, ResponseBuilder}; #[tokio::test] async fn should_return_the_zeroed_file_when_the_requested_file_is_not_whitelisted() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_listed().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = random_info_hash(); env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; let response = Client::new(*env.bind_address()) .scrape( @@ -1399,28 +1303,34 @@ mod configured_as_whitelisted { assert_scrape_response(response, &expected_scrape_response).await; + assert!( + logs_contains_a_line_with(&["ERROR", &format!("{info_hash}"), "is not whitelisted"]), + "Expected logs to contain: ERROR ... {info_hash} is not whitelisted" + ); + env.stop().await; } #[tokio::test] async fn should_return_the_file_stats_when_the_requested_file_is_whitelisted() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_listed().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; - env.tracker + env.container + .tracker_core_container + .whitelist_manager .add_torrent_to_whitelist(&info_hash) .await .expect("should add the torrent to the whitelist"); @@ -1457,26 +1367,30 @@ mod configured_as_private { use std::str::FromStr; use std::time::Duration; - use torrust_tracker::core::auth::Key; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use bittorrent_primitives::info_hash::InfoHash; + use bittorrent_tracker_core::authentication::Key; + use torrust_axum_http_tracker_server::environment::Started; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::{assert_authentication_error_response, assert_is_announce_response}; - use crate::servers::http::client::Client; - use crate::servers::http::requests::announce::QueryBuilder; - use crate::servers::http::Started; + use crate::server::asserts::{ + assert_authentication_error_response, assert_is_announce_response, assert_tracker_core_authentication_error_response, + }; + use crate::server::client::Client; + use crate::server::requests::announce::QueryBuilder; #[tokio::test] async fn should_respond_to_authenticated_peers() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; - let expiring_key = env.tracker.generate_auth_key(Some(Duration::from_secs(60))).await.unwrap(); + let expiring_key = env + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(60))) + .await + .unwrap(); let response = Client::authenticated(*env.bind_address(), expiring_key.key()) .announce(&QueryBuilder::default().query()) @@ -1489,28 +1403,24 @@ mod configured_as_private { #[tokio::test] async fn should_fail_if_the_peer_has_not_provided_the_authentication_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 let response = Client::new(*env.bind_address()) .announce(&QueryBuilder::default().with_info_hash(&info_hash).query()) .await; - assert_authentication_error_response(response).await; + assert_tracker_core_authentication_error_response(response).await; env.stop().await; } #[tokio::test] async fn should_fail_if_the_key_query_param_cannot_be_parsed() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; @@ -1527,9 +1437,7 @@ mod configured_as_private { #[tokio::test] async fn should_fail_if_the_peer_cannot_be_authenticated_with_the_provided_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; @@ -1540,7 +1448,7 @@ mod configured_as_private { .announce(&QueryBuilder::default().query()) .await; - assert_authentication_error_response(response).await; + assert_tracker_core_authentication_error_response(response).await; env.stop().await; } @@ -1552,23 +1460,20 @@ mod configured_as_private { use std::time::Duration; use aquatic_udp_protocol::PeerId; - use torrust_tracker::core::auth::Key; - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; + use bittorrent_tracker_core::authentication::Key; + use torrust_axum_http_tracker_server::environment::Started; use torrust_tracker_primitives::peer::fixture::PeerBuilder; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use torrust_tracker_test_helpers::{configuration, logging}; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::http::asserts::{assert_authentication_error_response, assert_scrape_response}; - use crate::servers::http::client::Client; - use crate::servers::http::responses::scrape::{File, ResponseBuilder}; - use crate::servers::http::{requests, Started}; + use crate::server::asserts::{assert_authentication_error_response, assert_scrape_response}; + use crate::server::client::Client; + use crate::server::requests; + use crate::server::responses::scrape::{File, ResponseBuilder}; #[tokio::test] async fn should_fail_if_the_key_query_param_cannot_be_parsed() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; @@ -1585,21 +1490,20 @@ mod configured_as_private { #[tokio::test] async fn should_return_the_zeroed_file_when_the_client_is_not_authenticated() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; let response = Client::new(*env.bind_address()) .scrape( @@ -1618,23 +1522,28 @@ mod configured_as_private { #[tokio::test] async fn should_return_the_real_file_stats_when_the_client_is_authenticated() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral_private().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; - let expiring_key = env.tracker.generate_auth_key(Some(Duration::from_secs(60))).await.unwrap(); + let expiring_key = env + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(60))) + .await + .unwrap(); let response = Client::authenticated(*env.bind_address(), expiring_key.key()) .scrape( @@ -1662,24 +1571,23 @@ mod configured_as_private { #[tokio::test] async fn should_return_the_zeroed_file_when_the_authentication_key_provided_by_the_client_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); // There is not authentication error // code-review: should this really be this way? let env = Started::new(&configuration::ephemeral_private().into()).await; - let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 env.add_torrent_peer( &info_hash, &PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_bytes_pending_to_download(1) + .with_bytes_left_to_download(1) .build(), - ); + ) + .await; let false_key: Key = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ".parse().unwrap(); diff --git a/tests/servers/http/v1/mod.rs b/packages/axum-http-tracker-server/tests/server/v1/mod.rs similarity index 100% rename from tests/servers/http/v1/mod.rs rename to packages/axum-http-tracker-server/tests/server/v1/mod.rs diff --git a/packages/axum-rest-tracker-api-server/Cargo.toml b/packages/axum-rest-tracker-api-server/Cargo.toml new file mode 100644 index 000000000..9493b8693 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/Cargo.toml @@ -0,0 +1,55 @@ +[package] +authors.workspace = true +description = "The Torrust Tracker API." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["axum", "bittorrent", "http", "server", "torrust", "tracker"] +license.workspace = true +name = "torrust-axum-rest-tracker-api-server" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +axum = { version = "0", features = ["macros"] } +axum-extra = { version = "0", features = ["query"] } +axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } +bittorrent-primitives = "0.1.0" +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +futures = "0" +hyper = "1" +reqwest = { version = "0", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +serde_with = { version = "3", features = ["json"] } +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-axum-server = { version = "3.0.0-develop", path = "../axum-server" } +torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } +torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "../rest-tracker-api-core" } +torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } +tower = { version = "0", features = ["timeout"] } +tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tracing = "0" +url = "2" + +[dev-dependencies] +local-ip-address = "0" +mockall = "0" +torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } +url = { version = "2", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } diff --git a/packages/axum-rest-tracker-api-server/LICENSE b/packages/axum-rest-tracker-api-server/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/axum-rest-tracker-api-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/axum-rest-tracker-api-server/README.md b/packages/axum-rest-tracker-api-server/README.md new file mode 100644 index 000000000..6a0415828 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/README.md @@ -0,0 +1,11 @@ +# Torrust Tracker API + +The Torrust Tracker Rest API. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-axum-tracker-api-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/axum-rest-tracker-api-server/src/environment.rs b/packages/axum-rest-tracker-api-server/src/environment.rs new file mode 100644 index 000000000..cddb45277 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/environment.rs @@ -0,0 +1,217 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::container::TrackerCoreContainer; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use futures::executor::block_on; +use torrust_axum_server::tsl::make_rust_tls; +use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin}; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; +use torrust_server_lib::registar::Registar; +use torrust_tracker_configuration::{logging, Configuration}; +use torrust_tracker_primitives::peer; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; +use torrust_udp_tracker_server::container::UdpTrackerServerContainer; + +use crate::server::{ApiServer, Launcher, Running, Stopped}; + +pub type Started = Environment; + +pub struct Environment +where + S: std::fmt::Debug + std::fmt::Display, +{ + pub container: Arc, + pub registar: Registar, + pub server: ApiServer, +} + +impl Environment +where + S: std::fmt::Debug + std::fmt::Display, +{ + /// Add a torrent to the tracker + pub async fn add_torrent_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + self.container + .tracker_core_container + .in_memory_torrent_repository + .handle_announcement(info_hash, peer, None) + .await; + } +} + +impl Environment { + /// # Panics + /// + /// Will panic if it cannot make the TSL configuration from the provided + /// configuration. + #[must_use] + pub fn new(configuration: &Arc) -> Self { + initialize_global_services(configuration); + + let container = Arc::new(EnvContainer::initialize(configuration)); + + let bind_to = container.tracker_http_api_core_container.http_api_config.bind_address; + + let tls = block_on(make_rust_tls( + &container.tracker_http_api_core_container.http_api_config.tsl_config, + )) + .map(|tls| tls.expect("tls config failed")); + + let server = ApiServer::new(Launcher::new(bind_to, tls)); + + Self { + container, + registar: Registar::default(), + server, + } + } + + /// # Panics + /// + /// Will panic if the server cannot be started. + pub async fn start(self) -> Environment { + let access_tokens = Arc::new( + self.container + .tracker_http_api_core_container + .http_api_config + .access_tokens + .clone(), + ); + + Environment { + container: self.container.clone(), + registar: self.registar.clone(), + server: self + .server + .start( + self.container.tracker_http_api_core_container.clone(), + self.registar.give_form(), + access_tokens, + ) + .await + .unwrap(), + } + } +} + +impl Environment { + pub async fn new(configuration: &Arc) -> Self { + Environment::::new(configuration).start().await + } + + /// # Panics + /// + /// Will panic if the server cannot be stopped. + pub async fn stop(self) -> Environment { + Environment { + container: self.container, + registar: Registar::default(), + server: self.server.stop().await.unwrap(), + } + } + + /// # Panics + /// + /// Will panic if it cannot build the origin for the connection info from the + /// server local socket address. + #[must_use] + pub fn get_connection_info(&self) -> ConnectionInfo { + let origin = Origin::new(&format!("http://{}/", self.server.state.local_addr)).unwrap(); // DevSkim: ignore DS137138 + + ConnectionInfo { + origin, + api_token: self + .container + .tracker_http_api_core_container + .http_api_config + .access_tokens + .get("admin") + .cloned(), + } + } + + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.server.state.local_addr + } +} + +pub struct EnvContainer { + pub tracker_core_container: Arc, + pub tracker_http_api_core_container: Arc, +} + +impl EnvContainer { + /// # Panics + /// + /// Will panic if: + /// + /// - The configuration does not contain a HTTP tracker configuration. + /// - The configuration does not contain a UDP tracker configuration. + /// - The configuration does not contain a HTTP API configuration. + #[must_use] + pub fn initialize(configuration: &Configuration) -> Self { + let core_config = Arc::new(configuration.core.clone()); + + let http_tracker_config = configuration + .http_trackers + .clone() + .expect("missing HTTP tracker configuration"); + let http_tracker_config = Arc::new(http_tracker_config[0].clone()); + + let udp_tracker_configurations = configuration.udp_trackers.clone().expect("missing UDP tracker configuration"); + let udp_tracker_config = Arc::new(udp_tracker_configurations[0].clone()); + + let http_api_config = Arc::new( + configuration + .http_api + .clone() + .expect("missing HTTP API configuration") + .clone(), + ); + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + let http_tracker_core_container = + HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &http_tracker_config); + + let udp_tracker_core_container = + UdpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &udp_tracker_config); + + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); + + let tracker_http_api_core_container = TrackerHttpApiCoreContainer::initialize_from( + &swarm_coordination_registry_container, + &tracker_core_container, + &http_tracker_core_container, + &udp_tracker_core_container, + &udp_tracker_server_container, + &http_api_config, + ); + + Self { + tracker_core_container, + tracker_http_api_core_container, + } + } +} + +fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); +} + +fn initialize_static() { + torrust_tracker_clock::initialize_static(); + bittorrent_udp_tracker_core::initialize_static(); +} diff --git a/src/servers/apis/mod.rs b/packages/axum-rest-tracker-api-server/src/lib.rs similarity index 92% rename from src/servers/apis/mod.rs rename to packages/axum-rest-tracker-api-server/src/lib.rs index 0451b46c0..0ed026654 100644 --- a/src/servers/apis/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/lib.rs @@ -60,7 +60,7 @@ //! ``` //! //! The response will be a JSON object. For example, the [tracker statistics -//! endpoint](crate::servers::apis::v1::context::stats#get-tracker-statistics): +//! endpoint](crate::v1::context::stats#get-tracker-statistics): //! //! ```json //! { @@ -101,7 +101,7 @@ //! //! Refer to [`torrust-tracker-configuration`](torrust_tracker_configuration) //! for more information about the API configuration and to the -//! [`auth`](crate::servers::apis::v1::middlewares::auth) middleware for more +//! [`auth`](crate::v1::middlewares::auth) middleware for more //! information about the authentication process. //! //! # Setup SSL (optional) @@ -153,11 +153,24 @@ //! > **NOTICE**: we are using [curl](https://curl.se/) in the API examples. //! > And you have to use quotes around the URL in order to avoid unexpected //! > errors. For example: `curl "http://127.0.0.1:1212/api/v1/stats?token=MyAccessToken"`. +pub mod environment; pub mod routes; pub mod server; pub mod v1; use serde::{Deserialize, Serialize}; +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; pub const API_LOG_TARGET: &str = "API"; diff --git a/src/servers/apis/routes.rs b/packages/axum-rest-tracker-api-server/src/routes.rs similarity index 60% rename from src/servers/apis/routes.rs rename to packages/axum-rest-tracker-api-server/src/routes.rs index 327cab0c5..78b7818d9 100644 --- a/src/servers/apis/routes.rs +++ b/packages/axum-rest-tracker-api-server/src/routes.rs @@ -5,6 +5,7 @@ //! //! All the API routes have the `/api` prefix and the version number as the //! first path segment. For example: `/api/v1/torrents`. +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -14,30 +15,36 @@ use axum::response::Response; use axum::routing::get; use axum::{middleware, BoxError, Router}; use hyper::{Request, StatusCode}; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; +use torrust_server_lib::logging::Latency; use torrust_tracker_configuration::{AccessTokens, DEFAULT_TIMEOUT}; use tower::timeout::TimeoutLayer; use tower::ServiceBuilder; +use tower_http::classify::ServerErrorsFailureClass; use tower_http::compression::CompressionLayer; use tower_http::propagate_header::PropagateHeaderLayer; use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer}; use tower_http::trace::{DefaultMakeSpan, TraceLayer}; +use tower_http::LatencyUnit; use tracing::{instrument, Level, Span}; use super::v1; use super::v1::context::health_check::handlers::health_check_handler; use super::v1::middlewares::auth::State; -use crate::core::Tracker; -use crate::servers::apis::API_LOG_TARGET; +use crate::API_LOG_TARGET; /// Add all API routes to the router. -#[allow(clippy::needless_pass_by_value)] -#[instrument(skip(tracker, access_tokens))] -pub fn router(tracker: Arc, access_tokens: Arc) -> Router { +#[instrument(skip(http_api_container, access_tokens))] +pub fn router( + http_api_container: &Arc, + access_tokens: Arc, + server_socket_addr: SocketAddr, +) -> Router { let router = Router::new(); let api_url_prefix = "/api"; - let router = v1::routes::add(api_url_prefix, router, tracker.clone()); + let router = v1::routes::add(api_url_prefix, router, http_api_container); let state = State { access_tokens }; @@ -50,7 +57,7 @@ pub fn router(tracker: Arc, access_tokens: Arc) -> Router .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) - .on_request(|request: &Request, _span: &Span| { + .on_request(|request: &Request, span: &Span| { let method = request.method().to_string(); let uri = request.uri().to_string(); let request_id = request @@ -59,23 +66,42 @@ pub fn router(tracker: Arc, access_tokens: Arc) -> Router .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - tracing::span!( + span.record("request_id", request_id); + + tracing::event!( target: API_LOG_TARGET, - tracing::Level::INFO, "request", method = %method, uri = %uri, request_id = %request_id); + tracing::Level::INFO, %method, %uri, %request_id, "request"); }) - .on_response(|response: &Response, latency: Duration, _span: &Span| { + .on_response(move |response: &Response, latency: Duration, span: &Span| { + let latency_ms = latency.as_millis(); let status_code = response.status(); let request_id = response .headers() .get("x-request-id") .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default(); - let latency_ms = latency.as_millis(); - tracing::span!( - target: API_LOG_TARGET, - tracing::Level::INFO, "response", latency = %latency_ms, status = %status_code, request_id = %request_id); - }), + span.record("request_id", request_id); + + if status_code.is_server_error() { + tracing::event!( + target: API_LOG_TARGET, + tracing::Level::ERROR, %latency_ms, %status_code, %server_socket_addr, %request_id, "response"); + } else { + tracing::event!( + target: API_LOG_TARGET, + tracing::Level::INFO, %latency_ms, %status_code, %server_socket_addr, %request_id, "response"); + } + }) + .on_failure( + move |failure_classification: ServerErrorsFailureClass, latency: Duration, _span: &Span| { + let latency = Latency::new(LatencyUnit::Millis, latency); + + tracing::event!( + target: API_LOG_TARGET, + tracing::Level::ERROR, %failure_classification, %latency, %server_socket_addr, "response failed"); + }, + ), ) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) .layer( diff --git a/src/servers/apis/server.rs b/packages/axum-rest-tracker-api-server/src/server.rs similarity index 71% rename from src/servers/apis/server.rs rename to packages/axum-rest-tracker-api-server/src/server.rs index 31220f497..05adeae8a 100644 --- a/src/servers/apis/server.rs +++ b/packages/axum-rest-tracker-api-server/src/server.rs @@ -33,17 +33,20 @@ use derive_more::Constructor; use futures::future::BoxFuture; use thiserror::Error; use tokio::sync::oneshot::{Receiver, Sender}; +use torrust_axum_server::custom_axum_server::{self, TimeoutAcceptor}; +use torrust_axum_server::signals::graceful_shutdown; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; +use torrust_server_lib::logging::STARTED_ON; +use torrust_server_lib::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistrationForm}; +use torrust_server_lib::signals::{Halted, Started}; use torrust_tracker_configuration::AccessTokens; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use tracing::{instrument, Level}; use super::routes::router; -use crate::bootstrap::jobs::Started; -use crate::core::Tracker; -use crate::servers::apis::API_LOG_TARGET; -use crate::servers::custom_axum_server::{self, TimeoutAcceptor}; -use crate::servers::logging::STARTED_ON; -use crate::servers::registar::{ServiceHealthCheckJob, ServiceRegistration, ServiceRegistrationForm}; -use crate::servers::signals::{graceful_shutdown, Halted}; +use crate::API_LOG_TARGET; + +const TYPE_STRING: &str = "tracker_rest_api"; /// Errors that can occur when starting or stopping the API server. #[derive(Debug, Error)] @@ -122,10 +125,10 @@ impl ApiServer { /// # Panics /// /// It would panic if the bound socket address cannot be sent back to this starter. - #[instrument(skip(self, tracker, form, access_tokens), err, ret(Display, level = Level::INFO))] + #[instrument(skip(self, http_api_container, form, access_tokens), err, ret(Display, level = Level::INFO))] pub async fn start( self, - tracker: Arc, + http_api_container: Arc, form: ServiceRegistrationForm, access_tokens: Arc, ) -> Result, Error> { @@ -137,7 +140,7 @@ impl ApiServer { let task = tokio::spawn(async move { tracing::debug!(target: API_LOG_TARGET, "Starting with launcher in spawned task ..."); - let _task = launcher.start(tracker, access_tokens, tx_start, rx_halt).await; + let _task = launcher.start(&http_api_container, access_tokens, tx_start, rx_halt).await; tracing::debug!(target: API_LOG_TARGET, "Started with launcher in spawned task"); @@ -146,7 +149,7 @@ impl ApiServer { let api_server = match rx_start.await { Ok(started) => { - form.send(ServiceRegistration::new(started.address, check_fn)) + form.send(ServiceRegistration::new(started.service_binding, check_fn)) .expect("it should be able to send service registration"); ApiServer { @@ -193,8 +196,8 @@ impl ApiServer { /// Or if there request returns an error code. #[must_use] #[instrument(skip())] -pub fn check_fn(binding: &SocketAddr) -> ServiceHealthCheckJob { - let url = format!("http://{binding}/api/health_check"); // DevSkim: ignore DS137138 +pub fn check_fn(service_binding: &ServiceBinding) -> ServiceHealthCheckJob { + let url = format!("http://{}/api/health_check", service_binding.bind_address()); // DevSkim: ignore DS137138 let info = format!("checking api health check at: {url}"); @@ -204,7 +207,7 @@ pub fn check_fn(binding: &SocketAddr) -> ServiceHealthCheckJob { Err(err) => Err(err.to_string()), } }); - ServiceHealthCheckJob::new(*binding, info, job) + ServiceHealthCheckJob::new(service_binding.clone(), info, TYPE_STRING.to_string(), job) } /// A struct responsible for starting the API server. @@ -235,34 +238,41 @@ impl Launcher { /// /// Will panic if unable to bind to the socket, or unable to get the address of the bound socket. /// Will also panic if unable to send message regarding the bound socket address. - #[instrument(skip(self, tracker, access_tokens, tx_start, rx_halt))] + #[instrument(skip(self, http_api_container, access_tokens, tx_start, rx_halt))] pub fn start( &self, - tracker: Arc, + http_api_container: &Arc, access_tokens: Arc, tx_start: Sender, rx_halt: Receiver, ) -> BoxFuture<'static, ()> { - let router = router(tracker, access_tokens); let socket = std::net::TcpListener::bind(self.bind_to).expect("Could not bind tcp_listener to address."); + socket + .set_nonblocking(true) + .expect("Failed to set socket to non-blocking mode"); let address = socket.local_addr().expect("Could not get local_addr from tcp_listener."); + let router = router(http_api_container, access_tokens, address); + let handle = Handle::new(); tokio::task::spawn(graceful_shutdown( handle.clone(), rx_halt, format!("Shutting down tracker API server on socket address: {address}"), + address, )); let tls = self.tls.clone(); - let protocol = if tls.is_some() { "https" } else { "http" }; + let protocol = if tls.is_some() { Protocol::HTTPS } else { Protocol::HTTP }; + let service_binding = ServiceBinding::new(protocol.clone(), address).expect("Service binding creation failed"); - tracing::info!(target: API_LOG_TARGET, "Starting on {protocol}://{}", address); + tracing::info!(target: API_LOG_TARGET, "Starting on: {protocol}://{address}"); let running = Box::pin(async { match tls { Some(tls) => custom_axum_server::from_tcp_rustls_with_timeouts(socket, tls) + .expect("Failed to create server from TCP socket with TLS") .handle(handle) // The TimeoutAcceptor is commented because TSL does not work with it. // See: https://github.com/torrust/torrust-index/issues/204#issuecomment-2115529214 @@ -271,6 +281,7 @@ impl Launcher { .await .expect("Axum server for tracker API crashed."), None => custom_axum_server::from_tcp_with_timeouts(socket) + .expect("Failed to create server from TCP socket") .handle(handle) .acceptor(TimeoutAcceptor) .serve(router.into_make_service_with_connect_info::()) @@ -279,10 +290,13 @@ impl Launcher { } }); - tracing::info!(target: API_LOG_TARGET, "{STARTED_ON} {protocol}://{}", address); + tracing::info!(target: API_LOG_TARGET, "{STARTED_ON}: {protocol}://{}", address); tx_start - .send(Started { address }) + .send(Started { + service_binding, + address, + }) .expect("the HTTP(s) Tracker API service should not be dropped"); running @@ -293,34 +307,53 @@ impl Launcher { mod tests { use std::sync::Arc; + use torrust_axum_server::tsl::make_rust_tls; + use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; + use torrust_server_lib::registar::Registar; + use torrust_tracker_configuration::{logging, Configuration}; use torrust_tracker_test_helpers::configuration::ephemeral_public; - use crate::bootstrap::app::initialize_with_configuration; - use crate::bootstrap::jobs::make_rust_tls; - use crate::servers::apis::server::{ApiServer, Launcher}; - use crate::servers::registar::Registar; + use crate::server::{ApiServer, Launcher}; + + fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); + } + + fn initialize_static() { + torrust_tracker_clock::initialize_static(); + bittorrent_udp_tracker_core::initialize_static(); + } #[tokio::test] async fn it_should_be_able_to_start_and_stop() { let cfg = Arc::new(ephemeral_public()); - let config = &cfg.http_api.clone().unwrap(); + let core_config = Arc::new(cfg.core.clone()); + let http_tracker_config = cfg.http_trackers.clone().expect("missing HTTP tracker configuration"); + let http_tracker_config = Arc::new(http_tracker_config[0].clone()); + let udp_tracker_configurations = cfg.udp_trackers.clone().expect("missing UDP tracker configuration"); + let udp_tracker_config = Arc::new(udp_tracker_configurations[0].clone()); + let http_api_config = Arc::new(cfg.http_api.clone().expect("missing HTTP API configuration").clone()); - let tracker = initialize_with_configuration(&cfg); + initialize_global_services(&cfg); - let bind_to = config.bind_address; + let bind_to = http_api_config.bind_address; - let tls = make_rust_tls(&config.tsl_config) + let tls = make_rust_tls(&http_api_config.tsl_config) .await .map(|tls| tls.expect("tls config failed")); - let access_tokens = Arc::new(config.access_tokens.clone()); + let access_tokens = Arc::new(http_api_config.access_tokens.clone()); let stopped = ApiServer::new(Launcher::new(bind_to, tls)); let register = &Registar::default(); + let http_api_container = + TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config); + let started = stopped - .start(tracker, register.give_form(), access_tokens) + .start(http_api_container, register.give_form(), access_tokens) .await .expect("it should start the server"); let stopped = started.stop().await.expect("it should stop the server"); diff --git a/src/servers/apis/v1/context/auth_key/forms.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/forms.rs similarity index 100% rename from src/servers/apis/v1/context/auth_key/forms.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/forms.rs diff --git a/src/servers/apis/v1/context/auth_key/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/handlers.rs similarity index 61% rename from src/servers/apis/v1/context/auth_key/handlers.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/handlers.rs index fed3ad301..10530287c 100644 --- a/src/servers/apis/v1/context/auth_key/handlers.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/handlers.rs @@ -1,10 +1,12 @@ -//! API handlers for the [`auth_key`](crate::servers::apis::v1::context::auth_key) API context. +//! API handlers for the [`auth_key`](crate::v1::context::auth_key) API context. use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use axum::extract::{self, Path, State}; use axum::response::Response; +use bittorrent_tracker_core::authentication::handler::{AddKeyRequest, KeysHandler}; +use bittorrent_tracker_core::authentication::Key; use serde::Deserialize; use super::forms::AddKeyForm; @@ -12,29 +14,27 @@ use super::responses::{ auth_key_response, failed_to_delete_key_response, failed_to_generate_key_response, failed_to_reload_keys_response, invalid_auth_key_duration_response, invalid_auth_key_response, }; -use crate::core::auth::Key; -use crate::core::{AddKeyRequest, Tracker}; -use crate::servers::apis::v1::context::auth_key::resources::AuthKey; -use crate::servers::apis::v1::responses::{invalid_auth_key_param_response, ok_response}; +use crate::v1::context::auth_key::resources::AuthKey; +use crate::v1::responses::{invalid_auth_key_param_response, ok_response}; /// It handles the request to add a new authentication key. /// /// It returns these types of responses: /// /// - `200` with a json [`AuthKey`] -/// resource. If the key was generated successfully. +/// resource. If the key was generated successfully. /// - `400` with an error if the key couldn't been added because of an invalid -/// request. +/// request. /// - `500` with serialized error in debug format. If the key couldn't be -/// generated. +/// generated. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#generate-a-new-authentication-key) +/// Refer to the [API endpoint documentation](crate::v1::context::auth_key#generate-a-new-authentication-key) /// for more information about this endpoint. pub async fn add_auth_key_handler( - State(tracker): State>, + State(keys_handler): State>, extract::Json(add_key_form): extract::Json, ) -> Response { - match tracker + match keys_handler .add_peer_key(AddKeyRequest { opt_key: add_key_form.opt_key.clone(), opt_seconds_valid: add_key_form.opt_seconds_valid, @@ -43,11 +43,11 @@ pub async fn add_auth_key_handler( { Ok(auth_key) => auth_key_response(&AuthKey::from(auth_key)), Err(err) => match err { - crate::core::error::PeerKeyError::DurationOverflow { seconds_valid } => { + bittorrent_tracker_core::error::PeerKeyError::DurationOverflow { seconds_valid } => { invalid_auth_key_duration_response(seconds_valid) } - crate::core::error::PeerKeyError::InvalidKey { key, source } => invalid_auth_key_response(&key, source), - crate::core::error::PeerKeyError::DatabaseError { source } => failed_to_generate_key_response(source), + bittorrent_tracker_core::error::PeerKeyError::InvalidKey { key, source } => invalid_auth_key_response(&key, source), + bittorrent_tracker_core::error::PeerKeyError::DatabaseError { source } => failed_to_generate_key_response(source), }, } } @@ -57,17 +57,23 @@ pub async fn add_auth_key_handler( /// It returns two types of responses: /// /// - `200` with an json [`AuthKey`] -/// resource. If the key was generated successfully. +/// resource. If the key was generated successfully. /// - `500` with serialized error in debug format. If the key couldn't be -/// generated. +/// generated. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#generate-a-new-authentication-key) +/// Refer to the [API endpoint documentation](crate::v1::context::auth_key#generate-a-new-authentication-key) /// for more information about this endpoint. /// /// This endpoint has been deprecated. Use [`add_auth_key_handler`]. -pub async fn generate_auth_key_handler(State(tracker): State>, Path(seconds_valid_or_key): Path) -> Response { +pub async fn generate_auth_key_handler( + State(keys_handler): State>, + Path(seconds_valid_or_key): Path, +) -> Response { let seconds_valid = seconds_valid_or_key; - match tracker.generate_auth_key(Some(Duration::from_secs(seconds_valid))).await { + match keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) + .await + { Ok(auth_key) => auth_key_response(&AuthKey::from(auth_key)), Err(e) => failed_to_generate_key_response(e), } @@ -95,20 +101,20 @@ pub struct KeyParam(String); /// /// It returns two types of responses: /// -/// - `200` with an json [`ActionStatus::Ok`](crate::servers::apis::v1::responses::ActionStatus::Ok) -/// response. If the key was deleted successfully. +/// - `200` with an json [`ActionStatus::Ok`](crate::v1::responses::ActionStatus::Ok) +/// response. If the key was deleted successfully. /// - `500` with serialized error in debug format. If the key couldn't be -/// deleted. +/// deleted. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#delete-an-authentication-key) +/// Refer to the [API endpoint documentation](crate::v1::context::auth_key#delete-an-authentication-key) /// for more information about this endpoint. pub async fn delete_auth_key_handler( - State(tracker): State>, + State(keys_handler): State>, Path(seconds_valid_or_key): Path, ) -> Response { match Key::from_str(&seconds_valid_or_key.0) { Err(_) => invalid_auth_key_param_response(&seconds_valid_or_key.0), - Ok(key) => match tracker.remove_auth_key(&key).await { + Ok(key) => match keys_handler.remove_peer_key(&key).await { Ok(()) => ok_response(), Err(e) => failed_to_delete_key_response(e), }, @@ -120,15 +126,15 @@ pub async fn delete_auth_key_handler( /// /// It returns two types of responses: /// -/// - `200` with an json [`ActionStatus::Ok`](crate::servers::apis::v1::responses::ActionStatus::Ok) -/// response. If the keys were successfully reloaded. +/// - `200` with an json [`ActionStatus::Ok`](crate::v1::responses::ActionStatus::Ok) +/// response. If the keys were successfully reloaded. /// - `500` with serialized error in debug format. If the they couldn't be -/// reloaded. +/// reloaded. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#reload-authentication-keys) +/// Refer to the [API endpoint documentation](crate::v1::context::auth_key#reload-authentication-keys) /// for more information about this endpoint. -pub async fn reload_keys_handler(State(tracker): State>) -> Response { - match tracker.load_keys_from_database().await { +pub async fn reload_keys_handler(State(keys_handler): State>) -> Response { + match keys_handler.load_peer_keys_from_database().await { Ok(()) => ok_response(), Err(e) => failed_to_reload_keys_response(e), } diff --git a/src/servers/apis/v1/context/auth_key/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/mod.rs similarity index 97% rename from src/servers/apis/v1/context/auth_key/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/mod.rs index b4112f21f..0a3937ef2 100644 --- a/src/servers/apis/v1/context/auth_key/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/mod.rs @@ -64,7 +64,7 @@ //! //! **Resource** //! -//! Refer to the API [`AuthKey`](crate::servers::apis::v1::context::auth_key::resources::AuthKey) +//! Refer to the API [`AuthKey`](crate::v1::context::auth_key::resources::AuthKey) //! resource for more information about the response attributes. //! //! # Delete an authentication key diff --git a/src/servers/apis/v1/context/auth_key/resources.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/resources.rs similarity index 88% rename from src/servers/apis/v1/context/auth_key/resources.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/resources.rs index c26b2c4d3..357f1c365 100644 --- a/src/servers/apis/v1/context/auth_key/resources.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/resources.rs @@ -1,10 +1,9 @@ -//! API resources for the [`auth_key`](crate::servers::apis::v1::context::auth_key) API context. +//! API resources for the [`auth_key`](crate::v1::context::auth_key) API context. +use bittorrent_tracker_core::authentication::{self, Key}; use serde::{Deserialize, Serialize}; use torrust_tracker_clock::conv::convert_from_iso_8601_to_timestamp; -use crate::core::auth::{self, Key}; - /// A resource that represents an authentication key. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct AuthKey { @@ -17,9 +16,9 @@ pub struct AuthKey { pub expiry_time: Option, } -impl From for auth::PeerKey { +impl From for authentication::PeerKey { fn from(auth_key_resource: AuthKey) -> Self { - auth::PeerKey { + authentication::PeerKey { key: auth_key_resource.key.parse::().unwrap(), valid_until: auth_key_resource .expiry_time @@ -29,8 +28,8 @@ impl From for auth::PeerKey { } #[allow(deprecated)] -impl From for AuthKey { - fn from(auth_key: auth::PeerKey) -> Self { +impl From for AuthKey { + fn from(auth_key: authentication::PeerKey) -> Self { match (auth_key.valid_until, auth_key.expiry_time()) { (Some(valid_until), Some(expiry_time)) => AuthKey { key: auth_key.key.to_string(), @@ -50,11 +49,11 @@ impl From for AuthKey { mod tests { use std::time::Duration; + use bittorrent_tracker_core::authentication::{self, Key}; use torrust_tracker_clock::clock::stopped::Stopped as _; use torrust_tracker_clock::clock::{self, Time}; use super::AuthKey; - use crate::core::auth::{self, Key}; use crate::CurrentClock; struct TestTime { @@ -86,8 +85,8 @@ mod tests { }; assert_eq!( - auth::PeerKey::from(auth_key_resource), - auth::PeerKey { + authentication::PeerKey::from(auth_key_resource), + authentication::PeerKey { key: "IaWDneuFNZi8IB4MPA3qW1CD0M30EZSM".parse::().unwrap(), // cspell:disable-line valid_until: Some(CurrentClock::now_add(&Duration::new(one_hour_after_unix_epoch().timestamp, 0)).unwrap()) } @@ -99,7 +98,7 @@ mod tests { fn it_should_be_convertible_from_an_auth_key() { clock::Stopped::local_set_to_unix_epoch(); - let auth_key = auth::PeerKey { + let auth_key = authentication::PeerKey { key: "IaWDneuFNZi8IB4MPA3qW1CD0M30EZSM".parse::().unwrap(), // cspell:disable-line valid_until: Some(CurrentClock::now_add(&Duration::new(one_hour_after_unix_epoch().timestamp, 0)).unwrap()), }; diff --git a/src/servers/apis/v1/context/auth_key/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/responses.rs similarity index 87% rename from src/servers/apis/v1/context/auth_key/responses.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/responses.rs index 4905d9adc..8a0503703 100644 --- a/src/servers/apis/v1/context/auth_key/responses.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/responses.rs @@ -1,11 +1,11 @@ -//! API responses for the [`auth_key`](crate::servers::apis::v1::context::auth_key) API context. +//! API responses for the [`auth_key`](crate::v1::context::auth_key) API context. use std::error::Error; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; -use crate::servers::apis::v1::context::auth_key::resources::AuthKey; -use crate::servers::apis::v1::responses::{bad_request_response, unhandled_rejection_response}; +use crate::v1::context::auth_key::resources::AuthKey; +use crate::v1::responses::{bad_request_response, unhandled_rejection_response}; /// `200` response that contains the `AuthKey` resource as json. /// diff --git a/src/servers/apis/v1/context/auth_key/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/routes.rs similarity index 59% rename from src/servers/apis/v1/context/auth_key/routes.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/auth_key/routes.rs index 60ccd77ab..64a0c1f11 100644 --- a/src/servers/apis/v1/context/auth_key/routes.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/auth_key/routes.rs @@ -1,21 +1,21 @@ -//! API routes for the [`auth_key`](crate::servers::apis::v1::context::auth_key) +//! API routes for the [`auth_key`](crate::v1::context::auth_key) //! API context. //! //! - `POST /key/:seconds_valid` //! - `DELETE /key/:key` //! - `GET /keys/reload` //! -//! Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key). +//! Refer to the [API endpoint documentation](crate::v1::context::auth_key). use std::sync::Arc; use axum::routing::{get, post}; use axum::Router; +use bittorrent_tracker_core::authentication::handler::KeysHandler; use super::handlers::{add_auth_key_handler, delete_auth_key_handler, generate_auth_key_handler, reload_keys_handler}; -use crate::core::Tracker; -/// It adds the routes to the router for the [`auth_key`](crate::servers::apis::v1::context::auth_key) API context. -pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { +/// It adds the routes to the router for the [`auth_key`](crate::v1::context::auth_key) API context. +pub fn add(prefix: &str, router: Router, keys_handler: &Arc) -> Router { // Keys router .route( @@ -27,16 +27,19 @@ pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { // // The POST /key/:seconds_valid has been deprecated and it will removed in the future. // Use POST /keys - &format!("{prefix}/key/:seconds_valid_or_key"), + &format!("{prefix}/key/{{seconds_valid_or_key}}"), post(generate_auth_key_handler) - .with_state(tracker.clone()) + .with_state(keys_handler.clone()) .delete(delete_auth_key_handler) - .with_state(tracker.clone()), + .with_state(keys_handler.clone()), ) // Keys command .route( &format!("{prefix}/keys/reload"), - get(reload_keys_handler).with_state(tracker.clone()), + get(reload_keys_handler).with_state(keys_handler.clone()), + ) + .route( + &format!("{prefix}/keys"), + post(add_auth_key_handler).with_state(keys_handler.clone()), ) - .route(&format!("{prefix}/keys"), post(add_auth_key_handler).with_state(tracker)) } diff --git a/src/servers/apis/v1/context/health_check/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/handlers.rs similarity index 71% rename from src/servers/apis/v1/context/health_check/handlers.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/health_check/handlers.rs index bfbeab549..dfcad1f56 100644 --- a/src/servers/apis/v1/context/health_check/handlers.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API handlers for the [`stats`](crate::v1::context::health_check) //! API context. use axum::Json; diff --git a/src/servers/apis/v1/context/health_check/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/mod.rs similarity index 85% rename from src/servers/apis/v1/context/health_check/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/health_check/mod.rs index b73849511..6b1a1475f 100644 --- a/src/servers/apis/v1/context/health_check/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/mod.rs @@ -28,7 +28,7 @@ //! //! **Resource** //! -//! Refer to the API [`Stats`](crate::servers::apis::v1::context::health_check::resources::Report) +//! Refer to the API [`Stats`](crate::context::health_check::resources::Report) //! resource for more information about the response attributes. pub mod handlers; pub mod resources; diff --git a/src/servers/apis/v1/context/health_check/resources.rs b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/resources.rs similarity index 74% rename from src/servers/apis/v1/context/health_check/resources.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/health_check/resources.rs index 9830e643c..5ea5871f8 100644 --- a/src/servers/apis/v1/context/health_check/resources.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/health_check/resources.rs @@ -1,4 +1,4 @@ -//! API resources for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API resources for the [`stats`](crate::v1::context::health_check) //! API context. use serde::{Deserialize, Serialize}; diff --git a/src/servers/apis/v1/context/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/mod.rs similarity index 100% rename from src/servers/apis/v1/context/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/mod.rs diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs new file mode 100644 index 000000000..1b1f670a0 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs @@ -0,0 +1,98 @@ +//! API handlers for the [`stats`](crate::v1::context::stats) +//! API context. +use std::sync::Arc; + +use axum::extract::State; +use axum::response::Response; +use axum_extra::extract::Query; +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use bittorrent_udp_tracker_core::services::banning::BanService; +use serde::Deserialize; +use tokio::sync::RwLock; +use torrust_rest_tracker_api_core::statistics::services::{get_labeled_metrics, get_metrics}; + +use super::responses::{labeled_metrics_response, labeled_stats_response, metrics_response, stats_response}; + +#[derive(Deserialize, Debug, Default)] +#[serde(rename_all = "lowercase")] +pub enum Format { + #[default] + Json, + Prometheus, +} + +#[derive(Deserialize, Debug)] +pub struct QueryParams { + /// The [`Format`] of the stats. + #[serde(default)] + pub format: Option, +} + +/// It handles the request to get the tracker global metrics. +/// +/// By default it returns a `200` response with the stats in JSON format. +/// +/// You can add the GET parameter `format=prometheus` to get the stats in +/// Prometheus Text Exposition Format. +/// +/// Refer to the [API endpoint documentation](crate::v1::context::stats#get-tracker-statistics) +/// for more information about this endpoint. +#[allow(clippy::type_complexity)] +pub async fn get_stats_handler( + State(state): State<( + Arc, + Arc, + Arc, + Arc, + )>, + params: Query, +) -> Response { + let metrics = get_metrics(state.0.clone(), state.1.clone(), state.2.clone(), state.3.clone()).await; + + match params.0.format { + Some(format) => match format { + Format::Json => stats_response(metrics), + Format::Prometheus => metrics_response(&metrics), + }, + None => stats_response(metrics), + } +} + +/// It handles the request to get the tracker extendable metrics. +/// +/// By default it returns a `200` response with the stats in JSON format. +/// +/// You can add the GET parameter `format=prometheus` to get the stats in +/// Prometheus Text Exposition Format. +#[allow(clippy::type_complexity)] +pub async fn get_metrics_handler( + State(state): State<( + Arc, + Arc>, + Arc, + Arc, + Arc, + Arc, + Arc, + )>, + params: Query, +) -> Response { + let metrics = get_labeled_metrics( + state.0.clone(), + state.1.clone(), + state.2.clone(), + state.3.clone(), + state.4.clone(), + state.5.clone(), + state.6.clone(), + ) + .await; + + match params.0.format { + Some(format) => match format { + Format::Json => labeled_stats_response(metrics), + Format::Prometheus => labeled_metrics_response(&metrics), + }, + None => labeled_stats_response(metrics), + } +} diff --git a/src/servers/apis/v1/context/stats/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/mod.rs similarity index 93% rename from src/servers/apis/v1/context/stats/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/stats/mod.rs index 80f37f73f..5c6b0a39c 100644 --- a/src/servers/apis/v1/context/stats/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/mod.rs @@ -44,7 +44,7 @@ //! //! **Resource** //! -//! Refer to the API [`Stats`](crate::servers::apis::v1::context::stats::resources::Stats) +//! Refer to the API [`Stats`](crate::v1::context::stats::resources::Stats) //! resource for more information about the response attributes. pub mod handlers; pub mod resources; diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs new file mode 100644 index 000000000..ece50383b --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs @@ -0,0 +1,221 @@ +//! API resources for the [`stats`](crate::v1::context::stats) +//! API context. +use serde::{Deserialize, Serialize}; +use torrust_rest_tracker_api_core::statistics::services::{TrackerLabeledMetrics, TrackerMetrics}; +use torrust_tracker_metrics::metric_collection::MetricCollection; + +/// It contains all the statistics generated by the tracker. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Stats { + // Torrent metrics + /// Total number of torrents. + pub torrents: u64, + /// Total number of seeders for all torrents. + pub seeders: u64, + /// Total number of peers that have ever completed downloading for all torrents. + pub completed: u64, + /// Total number of leechers for all torrents. + pub leechers: u64, + + // Protocol metrics + /// Total number of TCP (HTTP tracker) connections from IPv4 peers. + /// Since the HTTP tracker spec does not require a handshake, this metric + /// increases for every HTTP request. + pub tcp4_connections_handled: u64, + /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. + pub tcp4_announces_handled: u64, + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv4 peers. + pub tcp4_scrapes_handled: u64, + + /// Total number of TCP (HTTP tracker) connections from IPv6 peers. + pub tcp6_connections_handled: u64, + /// Total number of TCP (HTTP tracker) `announce` requests from IPv6 peers. + pub tcp6_announces_handled: u64, + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. + pub tcp6_scrapes_handled: u64, + + // UDP + /// Total number of UDP (UDP tracker) requests aborted. + pub udp_requests_aborted: u64, + /// Total number of UDP (UDP tracker) requests banned. + pub udp_requests_banned: u64, + /// Total number of IPs banned for UDP (UDP tracker) requests. + pub udp_banned_ips_total: u64, + /// Average rounded time spent processing UDP connect requests. + pub udp_avg_connect_processing_time_ns: u64, + /// Average rounded time spent processing UDP announce requests. + pub udp_avg_announce_processing_time_ns: u64, + /// Average rounded time spent processing UDP scrape requests. + pub udp_avg_scrape_processing_time_ns: u64, + + // UDPv4 + /// Total number of UDP (UDP tracker) requests from IPv4 peers. + pub udp4_requests: u64, + /// Total number of UDP (UDP tracker) connections from IPv4 peers. + pub udp4_connections_handled: u64, + /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. + pub udp4_announces_handled: u64, + /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. + pub udp4_scrapes_handled: u64, + /// Total number of UDP (UDP tracker) responses from IPv4 peers. + pub udp4_responses: u64, + /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. + pub udp4_errors_handled: u64, + + // UDPv6 + /// Total number of UDP (UDP tracker) requests from IPv6 peers. + pub udp6_requests: u64, + /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. + pub udp6_connections_handled: u64, + /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. + pub udp6_announces_handled: u64, + /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. + pub udp6_scrapes_handled: u64, + /// Total number of UDP (UDP tracker) responses from IPv6 peers. + pub udp6_responses: u64, + /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. + pub udp6_errors_handled: u64, +} + +impl From for Stats { + #[allow(deprecated)] + fn from(metrics: TrackerMetrics) -> Self { + Self { + torrents: metrics.torrents_metrics.total_torrents, + seeders: metrics.torrents_metrics.total_complete, + completed: metrics.torrents_metrics.total_downloaded, + leechers: metrics.torrents_metrics.total_incomplete, + // TCP + tcp4_connections_handled: metrics.protocol_metrics.tcp4_connections_handled, + tcp4_announces_handled: metrics.protocol_metrics.tcp4_announces_handled, + tcp4_scrapes_handled: metrics.protocol_metrics.tcp4_scrapes_handled, + tcp6_connections_handled: metrics.protocol_metrics.tcp6_connections_handled, + tcp6_announces_handled: metrics.protocol_metrics.tcp6_announces_handled, + tcp6_scrapes_handled: metrics.protocol_metrics.tcp6_scrapes_handled, + // UDP + udp_requests_aborted: metrics.protocol_metrics.udp_requests_aborted, + udp_requests_banned: metrics.protocol_metrics.udp_requests_banned, + udp_banned_ips_total: metrics.protocol_metrics.udp_banned_ips_total, + udp_avg_connect_processing_time_ns: metrics.protocol_metrics.udp_avg_connect_processing_time_ns, + udp_avg_announce_processing_time_ns: metrics.protocol_metrics.udp_avg_announce_processing_time_ns, + udp_avg_scrape_processing_time_ns: metrics.protocol_metrics.udp_avg_scrape_processing_time_ns, + // UDPv4 + udp4_requests: metrics.protocol_metrics.udp4_requests, + udp4_connections_handled: metrics.protocol_metrics.udp4_connections_handled, + udp4_announces_handled: metrics.protocol_metrics.udp4_announces_handled, + udp4_scrapes_handled: metrics.protocol_metrics.udp4_scrapes_handled, + udp4_responses: metrics.protocol_metrics.udp4_responses, + udp4_errors_handled: metrics.protocol_metrics.udp4_errors_handled, + // UDPv6 + udp6_requests: metrics.protocol_metrics.udp6_requests, + udp6_connections_handled: metrics.protocol_metrics.udp6_connections_handled, + udp6_announces_handled: metrics.protocol_metrics.udp6_announces_handled, + udp6_scrapes_handled: metrics.protocol_metrics.udp6_scrapes_handled, + udp6_responses: metrics.protocol_metrics.udp6_responses, + udp6_errors_handled: metrics.protocol_metrics.udp6_errors_handled, + } + } +} + +/// It contains all the statistics generated by the tracker. +#[derive(Serialize, Debug, PartialEq)] +pub struct LabeledStats { + metrics: MetricCollection, +} + +impl From for LabeledStats { + #[allow(deprecated)] + fn from(metrics: TrackerLabeledMetrics) -> Self { + Self { + metrics: metrics.metrics, + } + } +} + +#[cfg(test)] +mod tests { + use torrust_rest_tracker_api_core::statistics::metrics::{ProtocolMetrics, TorrentsMetrics}; + use torrust_rest_tracker_api_core::statistics::services::TrackerMetrics; + + use super::Stats; + + #[test] + #[allow(deprecated)] + fn stats_resource_should_be_converted_from_tracker_metrics() { + assert_eq!( + Stats::from(TrackerMetrics { + torrents_metrics: TorrentsMetrics { + total_complete: 1, + total_downloaded: 2, + total_incomplete: 3, + total_torrents: 4 + }, + protocol_metrics: ProtocolMetrics { + // TCP + tcp4_connections_handled: 5, + tcp4_announces_handled: 6, + tcp4_scrapes_handled: 7, + tcp6_connections_handled: 8, + tcp6_announces_handled: 9, + tcp6_scrapes_handled: 10, + // UDP + udp_requests_aborted: 11, + udp_requests_banned: 12, + udp_banned_ips_total: 13, + udp_avg_connect_processing_time_ns: 14, + udp_avg_announce_processing_time_ns: 15, + udp_avg_scrape_processing_time_ns: 16, + // UDPv4 + udp4_requests: 17, + udp4_connections_handled: 18, + udp4_announces_handled: 19, + udp4_scrapes_handled: 20, + udp4_responses: 21, + udp4_errors_handled: 22, + // UDPv6 + udp6_requests: 23, + udp6_connections_handled: 24, + udp6_announces_handled: 25, + udp6_scrapes_handled: 26, + udp6_responses: 27, + udp6_errors_handled: 28 + } + }), + Stats { + torrents: 4, + seeders: 1, + completed: 2, + leechers: 3, + // TCPv4 + tcp4_connections_handled: 5, + tcp4_announces_handled: 6, + tcp4_scrapes_handled: 7, + // TCPv6 + tcp6_connections_handled: 8, + tcp6_announces_handled: 9, + tcp6_scrapes_handled: 10, + // UDP + udp_requests_aborted: 11, + udp_requests_banned: 12, + udp_banned_ips_total: 13, + udp_avg_connect_processing_time_ns: 14, + udp_avg_announce_processing_time_ns: 15, + udp_avg_scrape_processing_time_ns: 16, + // UDPv4 + udp4_requests: 17, + udp4_connections_handled: 18, + udp4_announces_handled: 19, + udp4_scrapes_handled: 20, + udp4_responses: 21, + udp4_errors_handled: 22, + // UDPv6 + udp6_requests: 23, + udp6_connections_handled: 24, + udp6_announces_handled: 25, + udp6_scrapes_handled: 26, + udp6_responses: 27, + udp6_errors_handled: 28 + } + ); + } +} diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs new file mode 100644 index 000000000..e79f7e562 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs @@ -0,0 +1,140 @@ +//! API responses for the [`stats`](crate::v1::context::stats) +//! API context. +use axum::response::{IntoResponse, Json, Response}; +use torrust_rest_tracker_api_core::statistics::services::{TrackerLabeledMetrics, TrackerMetrics}; +use torrust_tracker_metrics::prometheus::PrometheusSerializable; + +use super::resources::{LabeledStats, Stats}; + +/// `200` response that contains the [`LabeledStats`] resource as json. +#[must_use] +pub fn labeled_stats_response(tracker_metrics: TrackerLabeledMetrics) -> Response { + Json(LabeledStats::from(tracker_metrics)).into_response() +} + +#[must_use] +pub fn labeled_metrics_response(tracker_metrics: &TrackerLabeledMetrics) -> Response { + tracker_metrics.metrics.to_prometheus().into_response() +} + +/// `200` response that contains the [`Stats`] resource as json. +#[must_use] +pub fn stats_response(tracker_metrics: TrackerMetrics) -> Response { + Json(Stats::from(tracker_metrics)).into_response() +} + +/// `200` response that contains the [`Stats`] resource in Prometheus Text Exposition Format . +#[allow(deprecated)] +#[must_use] +pub fn metrics_response(tracker_metrics: &TrackerMetrics) -> Response { + let mut lines = vec![]; + + lines.push(format!("torrents {}", tracker_metrics.torrents_metrics.total_torrents)); + lines.push(format!("seeders {}", tracker_metrics.torrents_metrics.total_complete)); + lines.push(format!("completed {}", tracker_metrics.torrents_metrics.total_downloaded)); + lines.push(format!("leechers {}", tracker_metrics.torrents_metrics.total_incomplete)); + + // TCP + + // TCPv4 + + lines.push(format!( + "tcp4_connections_handled {}", + tracker_metrics.protocol_metrics.tcp4_connections_handled + )); + lines.push(format!( + "tcp4_announces_handled {}", + tracker_metrics.protocol_metrics.tcp4_announces_handled + )); + lines.push(format!( + "tcp4_scrapes_handled {}", + tracker_metrics.protocol_metrics.tcp4_scrapes_handled + )); + + // TCPv6 + + lines.push(format!( + "tcp6_connections_handled {}", + tracker_metrics.protocol_metrics.tcp6_connections_handled + )); + lines.push(format!( + "tcp6_announces_handled {}", + tracker_metrics.protocol_metrics.tcp6_announces_handled + )); + lines.push(format!( + "tcp6_scrapes_handled {}", + tracker_metrics.protocol_metrics.tcp6_scrapes_handled + )); + + // UDP + + lines.push(format!( + "udp_requests_aborted {}", + tracker_metrics.protocol_metrics.udp_requests_aborted + )); + lines.push(format!( + "udp_requests_banned {}", + tracker_metrics.protocol_metrics.udp_requests_banned + )); + lines.push(format!( + "udp_banned_ips_total {}", + tracker_metrics.protocol_metrics.udp_banned_ips_total + )); + lines.push(format!( + "udp_avg_connect_processing_time_ns {}", + tracker_metrics.protocol_metrics.udp_avg_connect_processing_time_ns + )); + lines.push(format!( + "udp_avg_announce_processing_time_ns {}", + tracker_metrics.protocol_metrics.udp_avg_announce_processing_time_ns + )); + lines.push(format!( + "udp_avg_scrape_processing_time_ns {}", + tracker_metrics.protocol_metrics.udp_avg_scrape_processing_time_ns + )); + + // UDPv4 + + lines.push(format!("udp4_requests {}", tracker_metrics.protocol_metrics.udp4_requests)); + lines.push(format!( + "udp4_connections_handled {}", + tracker_metrics.protocol_metrics.udp4_connections_handled + )); + lines.push(format!( + "udp4_announces_handled {}", + tracker_metrics.protocol_metrics.udp4_announces_handled + )); + lines.push(format!( + "udp4_scrapes_handled {}", + tracker_metrics.protocol_metrics.udp4_scrapes_handled + )); + lines.push(format!("udp4_responses {}", tracker_metrics.protocol_metrics.udp4_responses)); + lines.push(format!( + "udp4_errors_handled {}", + tracker_metrics.protocol_metrics.udp4_errors_handled + )); + + // UDPv6 + + lines.push(format!("udp6_requests {}", tracker_metrics.protocol_metrics.udp6_requests)); + lines.push(format!( + "udp6_connections_handled {}", + tracker_metrics.protocol_metrics.udp6_connections_handled + )); + lines.push(format!( + "udp6_announces_handled {}", + tracker_metrics.protocol_metrics.udp6_announces_handled + )); + lines.push(format!( + "udp6_scrapes_handled {}", + tracker_metrics.protocol_metrics.udp6_scrapes_handled + )); + lines.push(format!("udp6_responses {}", tracker_metrics.protocol_metrics.udp6_responses)); + lines.push(format!( + "udp6_errors_handled {}", + tracker_metrics.protocol_metrics.udp6_errors_handled + )); + + // Return the plain text response + lines.join("\n").into_response() +} diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs new file mode 100644 index 000000000..2bf3776fd --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs @@ -0,0 +1,42 @@ +//! API routes for the [`stats`](crate::v1::context::stats) API context. +//! +//! - `GET /stats` +//! +//! Refer to the [API endpoint documentation](crate::v1::context::stats). +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; + +use super::handlers::{get_metrics_handler, get_stats_handler}; + +/// It adds the routes to the router for the [`stats`](crate::v1::context::stats) API context. +pub fn add(prefix: &str, router: Router, http_api_container: &Arc) -> Router { + router + .route( + &format!("{prefix}/stats"), + get(get_stats_handler).with_state(( + http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), + http_api_container.tracker_core_container.stats_repository.clone(), + http_api_container.http_stats_repository.clone(), + http_api_container.udp_server_stats_repository.clone(), + )), + ) + .route( + &format!("{prefix}/metrics"), + get(get_metrics_handler).with_state(( + http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), + http_api_container.ban_service.clone(), + // Stats + http_api_container + .swarm_coordination_registry_container + .stats_repository + .clone(), + http_api_container.tracker_core_container.stats_repository.clone(), + http_api_container.http_stats_repository.clone(), + http_api_container.udp_core_stats_repository.clone(), + http_api_container.udp_server_stats_repository.clone(), + )), + ) +} diff --git a/src/servers/apis/v1/context/torrent/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/handlers.rs similarity index 73% rename from src/servers/apis/v1/context/torrent/handlers.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/torrent/handlers.rs index ebca504fd..eecbd9ac3 100644 --- a/src/servers/apis/v1/context/torrent/handlers.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/handlers.rs @@ -1,4 +1,4 @@ -//! API handlers for the [`torrent`](crate::servers::apis::v1::context::torrent) +//! API handlers for the [`torrent`](crate::v1::context::torrent) //! API context. use std::fmt; use std::str::FromStr; @@ -7,30 +7,33 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use axum_extra::extract::Query; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use bittorrent_tracker_core::torrent::services::{get_torrent_info, get_torrents, get_torrents_page}; use serde::{de, Deserialize, Deserializer}; use thiserror::Error; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; use super::responses::{torrent_info_response, torrent_list_response, torrent_not_known_response}; -use crate::core::services::torrent::{get_torrent_info, get_torrents, get_torrents_page}; -use crate::core::Tracker; -use crate::servers::apis::v1::responses::invalid_info_hash_param_response; -use crate::servers::apis::InfoHashParam; +use crate::v1::responses::invalid_info_hash_param_response; +use crate::InfoHashParam; /// It handles the request to get the torrent data. /// /// It returns: /// -/// - `200` response with a json [`Torrent`](crate::servers::apis::v1::context::torrent::resources::torrent::Torrent). +/// - `200` response with a json [`Torrent`](crate::v1::context::torrent::resources::torrent::Torrent). /// - `500` with serialized error in debug format if the torrent is not known. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::torrent#get-a-torrent) +/// Refer to the [API endpoint documentation](crate::v1::context::torrent#get-a-torrent) /// for more information about this endpoint. -pub async fn get_torrent_handler(State(tracker): State>, Path(info_hash): Path) -> Response { +pub async fn get_torrent_handler( + State(in_memory_torrent_repository): State>, + Path(info_hash): Path, +) -> Response { match InfoHash::from_str(&info_hash.0) { Err(_) => invalid_info_hash_param_response(&info_hash.0), - Ok(info_hash) => match get_torrent_info(tracker.clone(), &info_hash).await { + Ok(info_hash) => match get_torrent_info(&in_memory_torrent_repository, &info_hash).await { Some(info) => torrent_info_response(info).into_response(), None => torrent_not_known_response(), }, @@ -71,17 +74,20 @@ pub struct QueryParams { /// It handles the request to get a list of torrents. /// -/// It returns a `200` response with a json array with [`crate::servers::apis::v1::context::torrent::resources::torrent::ListItem`] resources. +/// It returns a `200` response with a json array with [`crate::v1::context::torrent::resources::torrent::ListItem`] resources. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::torrent#list-torrents) +/// Refer to the [API endpoint documentation](crate::v1::context::torrent#list-torrents) /// for more information about this endpoint. -pub async fn get_torrents_handler(State(tracker): State>, pagination: Query) -> Response { +pub async fn get_torrents_handler( + State(in_memory_torrent_repository): State>, + pagination: Query, +) -> Response { tracing::debug!("pagination: {:?}", pagination); if pagination.0.info_hashes.is_empty() { torrent_list_response( &get_torrents_page( - tracker.clone(), + &in_memory_torrent_repository, Some(&Pagination::new_with_options(pagination.0.offset, pagination.0.limit)), ) .await, @@ -89,7 +95,9 @@ pub async fn get_torrents_handler(State(tracker): State>, paginatio .into_response() } else { match parse_info_hashes(pagination.0.info_hashes) { - Ok(info_hashes) => torrent_list_response(&get_torrents(tracker.clone(), &info_hashes).await).into_response(), + Ok(info_hashes) => { + torrent_list_response(&get_torrents(&in_memory_torrent_repository, &info_hashes).await).into_response() + } Err(err) => match err { QueryParamError::InvalidInfoHash { info_hash } => invalid_info_hash_param_response(&info_hash), }, diff --git a/src/servers/apis/v1/context/torrent/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/mod.rs similarity index 92% rename from src/servers/apis/v1/context/torrent/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/torrent/mod.rs index 1658e1748..1a62fef25 100644 --- a/src/servers/apis/v1/context/torrent/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/mod.rs @@ -62,7 +62,7 @@ //! //! **Resource** //! -//! Refer to the API [`Torrent`](crate::servers::apis::v1::context::torrent::resources::torrent::Torrent) +//! Refer to the API [`Torrent`](crate::v1::context::torrent::resources::torrent::Torrent) //! resource for more information about the response attributes. //! //! # List torrents @@ -102,7 +102,7 @@ //! //! **Resource** //! -//! Refer to the API [`ListItem`](crate::servers::apis::v1::context::torrent::resources::torrent::ListItem) +//! Refer to the API [`ListItem`](crate::v1::context::torrent::resources::torrent::ListItem) //! resource for more information about the attributes for a single item in the //! response. //! diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/mod.rs new file mode 100644 index 000000000..8e31036d3 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/mod.rs @@ -0,0 +1,4 @@ +//! API resources for the [`torrent`](crate::v1::context::torrent) +//! API context. +pub mod peer; +pub mod torrent; diff --git a/src/servers/apis/v1/context/torrent/resources/peer.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/peer.rs similarity index 100% rename from src/servers/apis/v1/context/torrent/resources/peer.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/peer.rs diff --git a/src/servers/apis/v1/context/torrent/resources/torrent.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/torrent.rs similarity index 88% rename from src/servers/apis/v1/context/torrent/resources/torrent.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/torrent.rs index 657382c0c..1753b60b9 100644 --- a/src/servers/apis/v1/context/torrent/resources/torrent.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/resources/torrent.rs @@ -4,10 +4,9 @@ //! - `ListItem` is a list item resource on a torrent list. `ListItem` does //! include a `peers` field but it is always `None` in the struct and `null` in //! the JSON response. +use bittorrent_tracker_core::torrent::services::{BasicInfo, Info}; use serde::{Deserialize, Serialize}; -use crate::core::services::torrent::{BasicInfo, Info}; - /// `Torrent` API resource. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Torrent { @@ -22,7 +21,7 @@ pub struct Torrent { /// The torrent's leechers counter. Active peers that are downloading the /// torrent. pub leechers: u64, - /// The torrent's peers. See [`Peer`](crate::servers::apis::v1::context::torrent::resources::peer::Peer). + /// The torrent's peers. See [`Peer`](crate::v1::context::torrent::resources::peer::Peer). #[serde(skip_serializing_if = "Option::is_none")] pub peers: Option>, } @@ -98,13 +97,13 @@ mod tests { use std::str::FromStr; use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; + use bittorrent_tracker_core::torrent::services::{BasicInfo, Info}; use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; use super::Torrent; - use crate::core::services::torrent::{BasicInfo, Info}; - use crate::servers::apis::v1::context::torrent::resources::peer::Peer; - use crate::servers::apis::v1::context::torrent::resources::torrent::ListItem; + use crate::v1::context::torrent::resources::peer::Peer; + use crate::v1::context::torrent::resources::torrent::ListItem; fn sample_peer() -> peer::Peer { peer::Peer { @@ -122,14 +121,14 @@ mod tests { fn torrent_resource_should_be_converted_from_torrent_info() { assert_eq!( Torrent::from(Info { - info_hash: InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), + info_hash: InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), // DevSkim: ignore DS173237 seeders: 1, completed: 2, leechers: 3, peers: Some(vec![sample_peer()]), }), Torrent { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 2, leechers: 3, @@ -142,13 +141,13 @@ mod tests { fn torrent_resource_list_item_should_be_converted_from_the_basic_torrent_info() { assert_eq!( ListItem::from(BasicInfo { - info_hash: InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), + info_hash: InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), // DevSkim: ignore DS173237 seeders: 1, completed: 2, leechers: 3, }), ListItem { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 2, leechers: 3, diff --git a/src/servers/apis/v1/context/torrent/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/responses.rs similarity index 83% rename from src/servers/apis/v1/context/torrent/responses.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/torrent/responses.rs index 5daceaf94..e498c6c59 100644 --- a/src/servers/apis/v1/context/torrent/responses.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/responses.rs @@ -1,10 +1,10 @@ -//! API responses for the [`torrent`](crate::servers::apis::v1::context::torrent) +//! API responses for the [`torrent`](crate::v1::context::torrent) //! API context. use axum::response::{IntoResponse, Json, Response}; +use bittorrent_tracker_core::torrent::services::{BasicInfo, Info}; use serde_json::json; use super::resources::torrent::{ListItem, Torrent}; -use crate::core::services::torrent::{BasicInfo, Info}; /// `200` response that contains an array of /// [`ListItem`] diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/torrent/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/routes.rs new file mode 100644 index 000000000..678fe7783 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/torrent/routes.rs @@ -0,0 +1,27 @@ +//! API routes for the [`torrent`](crate::v1::context::torrent) API context. +//! +//! - `GET /torrent/:info_hash` +//! - `GET /torrents` +//! +//! Refer to the [API endpoint documentation](crate::v1::context::torrent). +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + +use super::handlers::{get_torrent_handler, get_torrents_handler}; + +/// It adds the routes to the router for the [`torrent`](crate::v1::context::torrent) API context. +pub fn add(prefix: &str, router: Router, in_memory_torrent_repository: &Arc) -> Router { + // Torrents + router + .route( + &format!("{prefix}/torrent/{{info_hash}}"), + get(get_torrent_handler).with_state(in_memory_torrent_repository.clone()), + ) + .route( + &format!("{prefix}/torrents"), + get(get_torrents_handler).with_state(in_memory_torrent_repository.clone()), + ) +} diff --git a/src/servers/apis/v1/context/whitelist/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/handlers.rs similarity index 56% rename from src/servers/apis/v1/context/whitelist/handlers.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/whitelist/handlers.rs index 32e434918..bafa8aaff 100644 --- a/src/servers/apis/v1/context/whitelist/handlers.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/handlers.rs @@ -1,35 +1,35 @@ -//! API handlers for the [`whitelist`](crate::servers::apis::v1::context::whitelist) +//! API handlers for the [`whitelist`](crate::v1::context::whitelist) //! API context. use std::str::FromStr; use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::Response; -use torrust_tracker_primitives::info_hash::InfoHash; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::whitelist::manager::WhitelistManager; use super::responses::{ failed_to_reload_whitelist_response, failed_to_remove_torrent_from_whitelist_response, failed_to_whitelist_torrent_response, }; -use crate::core::Tracker; -use crate::servers::apis::v1::responses::{invalid_info_hash_param_response, ok_response}; -use crate::servers::apis::InfoHashParam; +use crate::v1::responses::{invalid_info_hash_param_response, ok_response}; +use crate::InfoHashParam; /// It handles the request to add a torrent to the whitelist. /// /// It returns: /// -/// - `200` response with a [`ActionStatus::Ok`](crate::servers::apis::v1::responses::ActionStatus::Ok) in json. +/// - `200` response with a [`ActionStatus::Ok`](crate::v1::responses::ActionStatus::Ok) in json. /// - `500` with serialized error in debug format if the torrent couldn't be whitelisted. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::whitelist#add-a-torrent-to-the-whitelist) +/// Refer to the [API endpoint documentation](crate::v1::context::whitelist#add-a-torrent-to-the-whitelist) /// for more information about this endpoint. pub async fn add_torrent_to_whitelist_handler( - State(tracker): State>, + State(whitelist_manager): State>, Path(info_hash): Path, ) -> Response { match InfoHash::from_str(&info_hash.0) { Err(_) => invalid_info_hash_param_response(&info_hash.0), - Ok(info_hash) => match tracker.add_torrent_to_whitelist(&info_hash).await { + Ok(info_hash) => match whitelist_manager.add_torrent_to_whitelist(&info_hash).await { Ok(()) => ok_response(), Err(e) => failed_to_whitelist_torrent_response(e), }, @@ -40,19 +40,19 @@ pub async fn add_torrent_to_whitelist_handler( /// /// It returns: /// -/// - `200` response with a [`ActionStatus::Ok`](crate::servers::apis::v1::responses::ActionStatus::Ok) in json. +/// - `200` response with a [`ActionStatus::Ok`](crate::v1::responses::ActionStatus::Ok) in json. /// - `500` with serialized error in debug format if the torrent couldn't be /// removed from the whitelisted. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::whitelist#remove-a-torrent-from-the-whitelist) +/// Refer to the [API endpoint documentation](crate::v1::context::whitelist#remove-a-torrent-from-the-whitelist) /// for more information about this endpoint. pub async fn remove_torrent_from_whitelist_handler( - State(tracker): State>, + State(whitelist_manager): State>, Path(info_hash): Path, ) -> Response { match InfoHash::from_str(&info_hash.0) { Err(_) => invalid_info_hash_param_response(&info_hash.0), - Ok(info_hash) => match tracker.remove_torrent_from_whitelist(&info_hash).await { + Ok(info_hash) => match whitelist_manager.remove_torrent_from_whitelist(&info_hash).await { Ok(()) => ok_response(), Err(e) => failed_to_remove_torrent_from_whitelist_response(e), }, @@ -63,14 +63,14 @@ pub async fn remove_torrent_from_whitelist_handler( /// /// It returns: /// -/// - `200` response with a [`ActionStatus::Ok`](crate::servers::apis::v1::responses::ActionStatus::Ok) in json. +/// - `200` response with a [`ActionStatus::Ok`](crate::v1::responses::ActionStatus::Ok) in json. /// - `500` with serialized error in debug format if the torrent whitelist /// couldn't be reloaded from the database. /// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::whitelist#reload-the-whitelist) +/// Refer to the [API endpoint documentation](crate::v1::context::whitelist#reload-the-whitelist) /// for more information about this endpoint. -pub async fn reload_whitelist_handler(State(tracker): State>) -> Response { - match tracker.load_whitelist_from_database().await { +pub async fn reload_whitelist_handler(State(whitelist_manager): State>) -> Response { + match whitelist_manager.load_whitelist_from_database().await { Ok(()) => ok_response(), Err(e) => failed_to_reload_whitelist_response(e), } diff --git a/src/servers/apis/v1/context/whitelist/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/mod.rs similarity index 100% rename from src/servers/apis/v1/context/whitelist/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/whitelist/mod.rs diff --git a/src/servers/apis/v1/context/whitelist/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/responses.rs similarity index 84% rename from src/servers/apis/v1/context/whitelist/responses.rs rename to packages/axum-rest-tracker-api-server/src/v1/context/whitelist/responses.rs index ce901c2f0..1e4d66f7f 100644 --- a/src/servers/apis/v1/context/whitelist/responses.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/responses.rs @@ -1,10 +1,10 @@ -//! API responses for the [`whitelist`](crate::servers::apis::v1::context::whitelist) +//! API responses for the [`whitelist`](crate::v1::context::whitelist) //! API context. use std::error::Error; use axum::response::Response; -use crate::servers::apis::v1::responses::unhandled_rejection_response; +use crate::v1::responses::unhandled_rejection_response; /// `500` error response when a torrent cannot be removed from the whitelist. #[must_use] diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/routes.rs new file mode 100644 index 000000000..c99b008b3 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/context/whitelist/routes.rs @@ -0,0 +1,35 @@ +//! API routes for the [`whitelist`](crate::v1::context::whitelist) API context. +//! +//! - `POST /whitelist/:info_hash` +//! - `DELETE /whitelist/:info_hash` +//! - `GET /whitelist/reload` +//! +//! Refer to the [API endpoint documentation](crate::v1::context::torrent). +use std::sync::Arc; + +use axum::routing::{delete, get, post}; +use axum::Router; +use bittorrent_tracker_core::whitelist::manager::WhitelistManager; + +use super::handlers::{add_torrent_to_whitelist_handler, reload_whitelist_handler, remove_torrent_from_whitelist_handler}; + +/// It adds the routes to the router for the [`whitelist`](crate::v1::context::whitelist) API context. +pub fn add(prefix: &str, router: Router, whitelist_manager: &Arc) -> Router { + let prefix = format!("{prefix}/whitelist"); + + router + // Whitelisted torrents + .route( + &format!("{prefix}/{{info_hash}}"), + post(add_torrent_to_whitelist_handler).with_state(whitelist_manager.clone()), + ) + .route( + &format!("{prefix}/{{info_hash}}"), + delete(remove_torrent_from_whitelist_handler).with_state(whitelist_manager.clone()), + ) + // Whitelist commands + .route( + &format!("{prefix}/reload"), + get(reload_whitelist_handler).with_state(whitelist_manager.clone()), + ) +} diff --git a/packages/axum-rest-tracker-api-server/src/v1/middlewares/auth.rs b/packages/axum-rest-tracker-api-server/src/v1/middlewares/auth.rs new file mode 100644 index 000000000..9b5ec2320 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/middlewares/auth.rs @@ -0,0 +1,170 @@ +//! Authentication middleware for the API. +//! +//! It uses a "token" to authenticate the user. The token must be one of the +//! `access_tokens` in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi). +//! +//! There are two ways to provide the token: +//! +//! 1. As a `Bearer` token in the `Authorization` header. +//! 2. As a `token` GET param in the URL. +//! +//! Using the `Authorization` header: +//! +//! ```console +//! curl -H "Authorization: Bearer MyAccessToken" http://:/api/v1/ +//! ``` +//! +//! Using the `token` GET param: +//! +//! `http://:/api/v1/?token=`. +//! +//! > **NOTICE**: the token can be at any position in the URL, not just at the +//! > beginning or at the end. +//! +//! The token must be one of the `access_tokens` in the tracker +//! [HTTP API configuration](torrust_tracker_configuration::HttpApi). +//! +//! The configuration file `tracker.toml` contains a list of tokens: +//! +//! ```toml +//! [http_api.access_tokens] +//! admin = "MyAccessToken" +//! ``` +//! +//! All the tokes have the same permissions, so it is not possible to have +//! different permissions for different tokens. The label is only used to +//! identify the token. +//! +//! NOTICE: The token is not encrypted, so it is recommended to use HTTPS to +//! protect the token from being intercepted. +//! +//! NOTICE: If both the `Authorization` header and the `token` GET param are +//! provided, the `Authorization` header will be used. +use std::sync::Arc; + +use axum::extract::{self}; +use axum::http::Request; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use torrust_tracker_configuration::AccessTokens; + +use crate::v1::responses::unhandled_rejection_response; + +pub const AUTH_BEARER_TOKEN_HEADER_PREFIX: &str = "Bearer"; + +/// Container for the `token` extracted from the query params. +#[derive(Deserialize, Debug)] +pub struct QueryParams { + pub token: Option, +} + +#[derive(Clone, Debug)] +pub struct State { + pub access_tokens: Arc, +} + +/// Middleware for authentication. +/// +/// The token must be one of the tokens in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi). +pub async fn auth( + extract::State(state): extract::State, + extract::Query(params): extract::Query, + request: Request, + next: Next, +) -> Response { + let token_from_header = match extract_bearer_token_from_header(&request) { + Ok(token) => token, + Err(err) => return err.into_response(), + }; + + let token_from_get_param = params.token.clone(); + + let provided_tokens = (token_from_header, token_from_get_param); + + let token = match provided_tokens { + (Some(token_from_header), Some(_token_from_get_param)) => token_from_header, + (Some(token_from_header), None) => token_from_header, + (None, Some(token_from_get_param)) => token_from_get_param, + (None, None) => return AuthError::Unauthorized.into_response(), + }; + + if !authenticate(&token, &state.access_tokens) { + return AuthError::TokenNotValid.into_response(); + } + + next.run(request).await +} + +fn extract_bearer_token_from_header(request: &Request) -> Result, AuthError> { + let headers = request.headers(); + + let header_value = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|header_value| header_value.to_str().ok()); + + match header_value { + None => Ok(None), + Some(header_value) => { + if header_value == AUTH_BEARER_TOKEN_HEADER_PREFIX { + // Empty token + return Ok(Some(String::new())); + } + + if !header_value.starts_with(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string()) { + // Invalid token type. Missing "Bearer" prefix. + return Err(AuthError::UnknownTokenProvided); + } + + Ok(header_value + .strip_prefix(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string()) + .map(std::string::ToString::to_string)) + } + } +} + +enum AuthError { + /// Missing token for authentication. + Unauthorized, + + /// Token was provided but it is not valid. + TokenNotValid, + + /// Token was provided but it is not in a format that the server can't understands. + UnknownTokenProvided, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + AuthError::Unauthorized => unauthorized_response(), + AuthError::TokenNotValid => token_not_valid_response(), + AuthError::UnknownTokenProvided => unknown_auth_data_provided_response(), + } + } +} + +fn authenticate(token: &str, tokens: &AccessTokens) -> bool { + tokens.values().any(|t| t == token) +} + +/// `500` error response returned when the token is missing. +#[must_use] +pub fn unauthorized_response() -> Response { + unhandled_rejection_response("unauthorized".to_string()) +} + +/// `500` error response when the provided token is not valid. +#[must_use] +pub fn token_not_valid_response() -> Response { + unhandled_rejection_response("token not valid".to_string()) +} + +/// `500` error response when the provided token type is not valid. +/// +/// The client has provided authentication information that the server does not +/// understand. +#[must_use] +pub fn unknown_auth_data_provided_response() -> Response { + unhandled_rejection_response("unknown token provided".to_string()) +} diff --git a/src/servers/apis/v1/middlewares/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/middlewares/mod.rs similarity index 100% rename from src/servers/apis/v1/middlewares/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/middlewares/mod.rs diff --git a/src/servers/apis/v1/mod.rs b/packages/axum-rest-tracker-api-server/src/v1/mod.rs similarity index 58% rename from src/servers/apis/v1/mod.rs rename to packages/axum-rest-tracker-api-server/src/v1/mod.rs index 372ae0ff9..7910d7d4d 100644 --- a/src/servers/apis/v1/mod.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/mod.rs @@ -4,17 +4,17 @@ //! //! Context | Description | Version //! ---|---|--- -//! `Stats` | Tracker statistics | [`v1`](crate::servers::apis::v1::context::stats) -//! `Torrents` | Torrents | [`v1`](crate::servers::apis::v1::context::torrent) -//! `Whitelist` | Torrents whitelist | [`v1`](crate::servers::apis::v1::context::whitelist) -//! `Authentication keys` | Authentication keys | [`v1`](crate::servers::apis::v1::context::auth_key) +//! `Stats` | Tracker statistics | [`v1`](crate::v1::context::stats) +//! `Torrents` | Torrents | [`v1`](crate::v1::context::torrent) +//! `Whitelist` | Torrents whitelist | [`v1`](crate::v1::context::whitelist) +//! `Authentication keys` | Authentication keys | [`v1`](crate::v1::context::auth_key) //! //! > **NOTICE**: //! - The authentication keys are only used by the HTTP tracker. //! - The whitelist is only used when the tracker is running in `listed` or //! `private_listed` mode. //! -//! Refer to the [authentication middleware](crate::servers::apis::v1::middlewares::auth) +//! Refer to the [authentication middleware](crate::v1::middlewares::auth) //! for more information about the authentication process. pub mod context; pub mod middlewares; diff --git a/src/servers/apis/v1/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/responses.rs similarity index 100% rename from src/servers/apis/v1/responses.rs rename to packages/axum-rest-tracker-api-server/src/v1/responses.rs diff --git a/packages/axum-rest-tracker-api-server/src/v1/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/routes.rs new file mode 100644 index 000000000..f7057a852 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/src/v1/routes.rs @@ -0,0 +1,30 @@ +//! Route initialization for the v1 API. +use std::sync::Arc; + +use axum::Router; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; + +use super::context::{auth_key, stats, torrent, whitelist}; + +/// Add the routes for the v1 API. +pub fn add(prefix: &str, router: Router, http_api_container: &Arc) -> Router { + let v1_prefix = format!("{prefix}/v1"); + + let router = auth_key::routes::add( + &v1_prefix, + router, + &http_api_container.tracker_core_container.keys_handler.clone(), + ); + let router = stats::routes::add(&v1_prefix, router, http_api_container); + let router = whitelist::routes::add( + &v1_prefix, + router, + &http_api_container.tracker_core_container.whitelist_manager, + ); + + torrent::routes::add( + &v1_prefix, + router, + &http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), + ) +} diff --git a/tests/common/fixtures.rs b/packages/axum-rest-tracker-api-server/tests/common/fixtures.rs similarity index 85% rename from tests/common/fixtures.rs rename to packages/axum-rest-tracker-api-server/tests/common/fixtures.rs index bbdebff76..4589ea2ce 100644 --- a/tests/common/fixtures.rs +++ b/packages/axum-rest-tracker-api-server/tests/common/fixtures.rs @@ -1,11 +1,10 @@ -#[allow(dead_code)] pub fn invalid_info_hashes() -> Vec { [ "0".to_string(), "-1".to_string(), "1.1".to_string(), "INVALID INFOHASH".to_string(), - "9c38422213e30bff212b30c360d26f9a0213642".to_string(), // 39-char length instead of 40 + "9c38422213e30bff212b30c360d26f9a0213642".to_string(), // 39-char length instead of 40. DevSkim: ignore DS173237 "9c38422213e30bff212b30c360d26f9a0213642&".to_string(), // Invalid char ] .to_vec() diff --git a/packages/axum-rest-tracker-api-server/tests/common/mod.rs b/packages/axum-rest-tracker-api-server/tests/common/mod.rs new file mode 100644 index 000000000..d066349cc --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod fixtures; diff --git a/packages/axum-rest-tracker-api-server/tests/integration.rs b/packages/axum-rest-tracker-api-server/tests/integration.rs new file mode 100644 index 000000000..878ac203d --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/integration.rs @@ -0,0 +1,20 @@ +//! Integration tests. +//! +//! ```text +//! cargo test --test integration +//! ``` + +use torrust_tracker_clock::clock; +mod common; +mod server; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/packages/axum-rest-tracker-api-server/tests/server/connection_info.rs b/packages/axum-rest-tracker-api-server/tests/server/connection_info.rs new file mode 100644 index 000000000..6459c9a2f --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/connection_info.rs @@ -0,0 +1,9 @@ +use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin}; + +pub fn connection_with_invalid_token(origin: Origin) -> ConnectionInfo { + ConnectionInfo::authenticated(origin, "invalid token") +} + +pub fn connection_with_no_token(origin: Origin) -> ConnectionInfo { + ConnectionInfo::anonymous(origin) +} diff --git a/packages/axum-rest-tracker-api-server/tests/server/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/mod.rs new file mode 100644 index 000000000..9dea49a4c --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/mod.rs @@ -0,0 +1,19 @@ +pub mod connection_info; +pub mod v1; + +use std::sync::Arc; + +use bittorrent_tracker_core::databases::Database; + +/// It forces a database error by dropping all tables. That makes all queries +/// fail. +/// +/// code-review: +/// +/// Alternatively we could: +/// +/// - Inject a database mock in the future. +/// - Inject directly the database reference passed to the Tracker type. +pub fn force_database_error(tracker: &Arc>) { + tracker.drop_database_tables().unwrap(); +} diff --git a/tests/servers/api/v1/asserts.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/asserts.rs similarity index 94% rename from tests/servers/api/v1/asserts.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/asserts.rs index aeecfa170..abd60cf94 100644 --- a/tests/servers/api/v1/asserts.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/asserts.rs @@ -1,9 +1,9 @@ // code-review: should we use macros to return the exact line where the assert fails? use reqwest::Response; -use torrust_tracker::servers::apis::v1::context::auth_key::resources::AuthKey; -use torrust_tracker::servers::apis::v1::context::stats::resources::Stats; -use torrust_tracker::servers::apis::v1::context::torrent::resources::torrent::{ListItem, Torrent}; +use torrust_axum_rest_tracker_api_server::v1::context::auth_key::resources::AuthKey; +use torrust_axum_rest_tracker_api_server::v1::context::stats::resources::Stats; +use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::{ListItem, Torrent}; // Resource responses @@ -117,7 +117,7 @@ pub async fn assert_unprocessable_auth_key_duration_param(response: Response, _i pub async fn assert_invalid_key_duration_param(response: Response, invalid_key_duration: &str) { assert_bad_request( response, - &format!("Invalid URL: Cannot parse `\"{invalid_key_duration}\"` to a `u64`"), + &format!("Invalid URL: Cannot parse `{invalid_key_duration}` to a `u64`"), ) .await; } diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/authentication.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/authentication.rs new file mode 100644 index 000000000..be291a50c --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/authentication.rs @@ -0,0 +1,294 @@ +mod given_that_the_token_is_only_provided_in_the_authentication_header { + use hyper::header; + use torrust_axum_rest_tracker_api_server::environment::Started; + use torrust_rest_tracker_api_client::common::http::Query; + use torrust_rest_tracker_api_client::connection_info::ConnectionInfo; + use torrust_rest_tracker_api_client::v1::client::{ + headers_with_auth_token, headers_with_request_id, Client, AUTH_BEARER_TOKEN_HEADER_PREFIX, + }; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + use uuid::Uuid; + + use crate::server::v1::asserts::assert_token_not_valid; + + #[tokio::test] + async fn it_should_authenticate_requests_when_the_token_is_provided_in_the_authentication_header() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let token = env.get_connection_info().api_token.unwrap(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_request_with_query("stats", Query::default(), Some(headers_with_auth_token(&token))) + .await; + + assert_eq!(response.status(), 200); + + env.stop().await; + } + + #[tokio::test] + async fn it_should_not_authenticate_requests_when_the_token_is_empty() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let mut headers = headers_with_request_id(request_id); + + // Send the header with an empty token + headers.insert( + header::AUTHORIZATION, + format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ") + .parse() + .expect("the auth token is not a valid header value"), + ); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_request_with_query("stats", Query::default(), Some(headers)) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; + } + + #[tokio::test] + async fn it_should_not_authenticate_requests_when_the_token_is_invalid() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let mut headers = headers_with_request_id(request_id); + + // Send the header with an empty token + headers.insert( + header::AUTHORIZATION, + "Bearer INVALID TOKEN" + .parse() + .expect("the auth token is not a valid header value"), + ); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + let response = Client::new(connection_info) + .unwrap() + .get_request_with_query("stats", Query::default(), Some(headers)) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; + } +} +mod given_that_the_token_is_only_provided_in_the_query_param { + + use torrust_axum_rest_tracker_api_server::environment::Started; + use torrust_rest_tracker_api_client::common::http::{Query, QueryParam}; + use torrust_rest_tracker_api_client::connection_info::ConnectionInfo; + use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client, TOKEN_PARAM_NAME}; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + use uuid::Uuid; + + use crate::server::v1::asserts::assert_token_not_valid; + + #[tokio::test] + async fn it_should_authenticate_requests_when_the_token_is_provided_as_a_query_param() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let token = env.get_connection_info().api_token.unwrap(); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + let response = Client::new(connection_info) + .unwrap() + .get_request_with_query( + "stats", + Query::params([QueryParam::new(TOKEN_PARAM_NAME, &token)].to_vec()), + None, + ) + .await; + + assert_eq!(response.status(), 200); + + env.stop().await; + } + + #[tokio::test] + async fn it_should_not_authenticate_requests_when_the_token_is_empty() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + let response = Client::new(connection_info) + .unwrap() + .get_request_with_query( + "stats", + Query::params([QueryParam::new(TOKEN_PARAM_NAME, "")].to_vec()), + Some(headers_with_request_id(request_id)), + ) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; + } + + #[tokio::test] + async fn it_should_not_authenticate_requests_when_the_token_is_invalid() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + let response = Client::new(connection_info) + .unwrap() + .get_request_with_query( + "stats", + Query::params([QueryParam::new(TOKEN_PARAM_NAME, "INVALID TOKEN")].to_vec()), + Some(headers_with_request_id(request_id)), + ) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; + } + + #[tokio::test] + async fn it_should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let token = env.get_connection_info().api_token.unwrap(); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + // At the beginning of the query component + let response = Client::new(connection_info) + .unwrap() + .get_request(&format!("torrents?token={token}&limit=1")) + .await; + + assert_eq!(response.status(), 200); + + // At the end of the query component + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_request(&format!("torrents?limit=1&token={token}")) + .await; + + assert_eq!(response.status(), 200); + + env.stop().await; + } +} + +mod given_that_not_token_is_provided { + + use torrust_axum_rest_tracker_api_server::environment::Started; + use torrust_rest_tracker_api_client::common::http::Query; + use torrust_rest_tracker_api_client::connection_info::ConnectionInfo; + use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client}; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + use uuid::Uuid; + + use crate::server::v1::asserts::assert_unauthorized; + + #[tokio::test] + async fn it_should_not_authenticate_requests_when_the_token_is_missing() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let connection_info = ConnectionInfo::anonymous(env.get_connection_info().origin); + + let response = Client::new(connection_info) + .unwrap() + .get_request_with_query("stats", Query::default(), Some(headers_with_request_id(request_id))) + .await; + + assert_unauthorized(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; + } +} + +mod given_that_token_is_provided_via_get_param_and_authentication_header { + use torrust_axum_rest_tracker_api_server::environment::Started; + use torrust_rest_tracker_api_client::common::http::{Query, QueryParam}; + use torrust_rest_tracker_api_client::v1::client::{headers_with_auth_token, Client, TOKEN_PARAM_NAME}; + use torrust_tracker_test_helpers::{configuration, logging}; + + #[tokio::test] + async fn it_should_authenticate_requests_using_the_token_provided_in_the_authentication_header() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let authorized_token = env.get_connection_info().api_token.unwrap(); + + let non_authorized_token = "NonAuthorizedToken"; + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_request_with_query( + "stats", + Query::params([QueryParam::new(TOKEN_PARAM_NAME, non_authorized_token)].to_vec()), + Some(headers_with_auth_token(&authorized_token)), + ) + .await; + + // The token provided in the query param should be ignored and the token + // in the authentication header should be used. + assert_eq!(response.status(), 200); + + env.stop().await; + } +} diff --git a/tests/servers/api/v1/contract/context/auth_key.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs similarity index 50% rename from tests/servers/api/v1/contract/context/auth_key.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs index 2792a513c..3781f4f60 100644 --- a/tests/servers/api/v1/contract/context/auth_key.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/auth_key.rs @@ -1,39 +1,46 @@ use std::time::Duration; +use bittorrent_tracker_core::authentication::Key; use serde::Serialize; -use torrust_tracker::core::auth::Key; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::connection_info::{connection_with_invalid_token, connection_with_no_token}; -use crate::servers::api::v1::asserts::{ +use torrust_axum_rest_tracker_api_server::environment::Started; +use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, AddKeyForm, Client}; +use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; +use torrust_tracker_test_helpers::{configuration, logging}; +use uuid::Uuid; + +use crate::server::connection_info::{connection_with_invalid_token, connection_with_no_token}; +use crate::server::force_database_error; +use crate::server::v1::asserts::{ assert_auth_key_utf8, assert_failed_to_delete_key, assert_failed_to_generate_key, assert_failed_to_reload_keys, assert_invalid_auth_key_get_param, assert_invalid_auth_key_post_param, assert_ok, assert_token_not_valid, assert_unauthorized, assert_unprocessable_auth_key_duration_param, }; -use crate::servers::api::v1::client::{AddKeyForm, Client}; -use crate::servers::api::{force_database_error, Started}; #[tokio::test] async fn should_allow_generating_a_new_random_auth_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .add_auth_key(AddKeyForm { - opt_key: None, - seconds_valid: Some(60), - }) + .unwrap() + .add_auth_key( + AddKeyForm { + opt_key: None, + seconds_valid: Some(60), + }, + Some(headers_with_request_id(request_id)), + ) .await; let auth_key_resource = assert_auth_key_utf8(response).await; assert!(env - .tracker + .container + .tracker_core_container + .authentication_service .authenticate(&auth_key_resource.key.parse::().unwrap()) .await .is_ok()); @@ -43,23 +50,29 @@ async fn should_allow_generating_a_new_random_auth_key() { #[tokio::test] async fn should_allow_uploading_a_preexisting_auth_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .add_auth_key(AddKeyForm { - opt_key: Some("Xc1L4PbQJSFGlrgSRZl8wxSFAuMa21z5".to_string()), - seconds_valid: Some(60), - }) + .unwrap() + .add_auth_key( + AddKeyForm { + opt_key: Some("Xc1L4PbQJSFGlrgSRZl8wxSFAuMa21z5".to_string()), + seconds_valid: Some(60), + }, + Some(headers_with_request_id(request_id)), + ) .await; let auth_key_resource = assert_auth_key_utf8(response).await; assert!(env - .tracker + .container + .tracker_core_container + .authentication_service .authenticate(&auth_key_resource.key.parse::().unwrap()) .await .is_ok()); @@ -69,72 +82,104 @@ async fn should_allow_uploading_a_preexisting_auth_key() { #[tokio::test] async fn should_not_allow_generating_a_new_auth_key_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .add_auth_key(AddKeyForm { - opt_key: None, - seconds_valid: Some(60), - }) + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .add_auth_key( + AddKeyForm { + opt_key: None, + seconds_valid: Some(60), + }, + Some(headers_with_request_id(request_id)), + ) .await; assert_token_not_valid(response).await; - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .add_auth_key(AddKeyForm { - opt_key: None, - seconds_valid: Some(60), - }) + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .add_auth_key( + AddKeyForm { + opt_key: None, + seconds_valid: Some(60), + }, + Some(headers_with_request_id(request_id)), + ) .await; assert_unauthorized(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_fail_when_the_auth_key_cannot_be_generated() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.tracker); + force_database_error(&env.container.tracker_core_container.database); + + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .add_auth_key(AddKeyForm { - opt_key: None, - seconds_valid: Some(60), - }) + .unwrap() + .add_auth_key( + AddKeyForm { + opt_key: None, + seconds_valid: Some(60), + }, + Some(headers_with_request_id(request_id)), + ) .await; assert_failed_to_generate_key(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_allow_deleting_an_auth_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let seconds_valid = 60; let auth_key = env - .tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .delete_auth_key(&auth_key.key.to_string()) + .unwrap() + .delete_auth_key(&auth_key.key.to_string(), Some(headers_with_request_id(request_id))) .await; assert_ok(response).await; @@ -151,9 +196,7 @@ async fn should_fail_generating_a_new_auth_key_when_the_provided_key_is_invalid( pub seconds_valid: u64, } - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -167,13 +210,17 @@ async fn should_fail_generating_a_new_auth_key_when_the_provided_key_is_invalid( ]; for invalid_key in invalid_keys { + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) + .unwrap() .post_form( "keys", &InvalidAddKeyForm { opt_key: Some(invalid_key.to_string()), seconds_valid: 60, }, + Some(headers_with_request_id(request_id)), ) .await; @@ -192,9 +239,7 @@ async fn should_fail_generating_a_new_auth_key_when_the_key_duration_is_invalid( pub seconds_valid: String, } - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -205,13 +250,17 @@ async fn should_fail_generating_a_new_auth_key_when_the_key_duration_is_invalid( ]; for invalid_key_duration in invalid_key_durations { + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) + .unwrap() .post_form( "keys", &InvalidAddKeyForm { opt_key: None, seconds_valid: invalid_key_duration.to_string(), }, + Some(headers_with_request_id(request_id)), ) .await; @@ -223,9 +272,7 @@ async fn should_fail_generating_a_new_auth_key_when_the_key_duration_is_invalid( #[tokio::test] async fn should_fail_deleting_an_auth_key_when_the_key_id_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -240,7 +287,12 @@ async fn should_fail_deleting_an_auth_key_when_the_key_id_is_invalid() { ]; for invalid_auth_key in &invalid_auth_keys { - let response = Client::new(env.get_connection_info()).delete_auth_key(invalid_auth_key).await; + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .delete_auth_key(invalid_auth_key, Some(headers_with_request_id(request_id))) + .await; assert_invalid_auth_key_get_param(response, invalid_auth_key).await; } @@ -250,35 +302,41 @@ async fn should_fail_deleting_an_auth_key_when_the_key_id_is_invalid() { #[tokio::test] async fn should_fail_when_the_auth_key_cannot_be_deleted() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let seconds_valid = 60; let auth_key = env - .tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - force_database_error(&env.tracker); + force_database_error(&env.container.tracker_core_container.database); + + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .delete_auth_key(&auth_key.key.to_string()) + .unwrap() + .delete_auth_key(&auth_key.key.to_string(), Some(headers_with_request_id(request_id))) .await; assert_failed_to_delete_key(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_not_allow_deleting_an_auth_key_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -286,48 +344,73 @@ async fn should_not_allow_deleting_an_auth_key_for_unauthenticated_users() { // Generate new auth key let auth_key = env - .tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .delete_auth_key(&auth_key.key.to_string()) + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .delete_auth_key(&auth_key.key.to_string(), Some(headers_with_request_id(request_id))) .await; assert_token_not_valid(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + // Generate new auth key let auth_key = env - .tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + .container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .delete_auth_key(&auth_key.key.to_string()) + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .delete_auth_key(&auth_key.key.to_string(), Some(headers_with_request_id(request_id))) .await; assert_unauthorized(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_allow_reloading_keys() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let seconds_valid = 60; - env.tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + env.container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - let response = Client::new(env.get_connection_info()).reload_keys().await; + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .reload_keys(Some(headers_with_request_id(request_id))) + .await; assert_ok(response).await; @@ -336,87 +419,117 @@ async fn should_allow_reloading_keys() { #[tokio::test] async fn should_fail_when_keys_cannot_be_reloaded() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; + let request_id = Uuid::new_v4(); let seconds_valid = 60; - env.tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + + env.container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - force_database_error(&env.tracker); + force_database_error(&env.container.tracker_core_container.database); - let response = Client::new(env.get_connection_info()).reload_keys().await; + let response = Client::new(env.get_connection_info()) + .unwrap() + .reload_keys(Some(headers_with_request_id(request_id))) + .await; assert_failed_to_reload_keys(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_not_allow_reloading_keys_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let seconds_valid = 60; - env.tracker - .generate_auth_key(Some(Duration::from_secs(seconds_valid))) + env.container + .tracker_core_container + .keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) .await .unwrap(); - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .reload_keys() + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .reload_keys(Some(headers_with_request_id(request_id))) .await; assert_token_not_valid(response).await; - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .reload_keys() + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .reload_keys(Some(headers_with_request_id(request_id))) .await; assert_unauthorized(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } mod deprecated_generate_key_endpoint { - use torrust_tracker::core::auth::Key; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; + use bittorrent_tracker_core::authentication::Key; + use torrust_axum_rest_tracker_api_server::environment::Started; + use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client}; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + use uuid::Uuid; - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::api::connection_info::{connection_with_invalid_token, connection_with_no_token}; - use crate::servers::api::v1::asserts::{ + use crate::server::connection_info::{connection_with_invalid_token, connection_with_no_token}; + use crate::server::force_database_error; + use crate::server::v1::asserts::{ assert_auth_key_utf8, assert_failed_to_generate_key, assert_invalid_key_duration_param, assert_token_not_valid, assert_unauthorized, }; - use crate::servers::api::v1::client::Client; - use crate::servers::api::{force_database_error, Started}; #[tokio::test] async fn should_allow_generating_a_new_auth_key() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let seconds_valid = 60; - let response = Client::new(env.get_connection_info()).generate_auth_key(seconds_valid).await; + let response = Client::new(env.get_connection_info()) + .unwrap() + .generate_auth_key(seconds_valid, None) + .await; let auth_key_resource = assert_auth_key_utf8(response).await; assert!(env - .tracker + .container + .tracker_core_container + .authentication_service .authenticate(&auth_key_resource.key.parse::().unwrap()) .await .is_ok()); @@ -426,34 +539,38 @@ mod deprecated_generate_key_endpoint { #[tokio::test] async fn should_not_allow_generating_a_new_auth_key_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; + let request_id = Uuid::new_v4(); let seconds_valid = 60; - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .generate_auth_key(seconds_valid) + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .generate_auth_key(seconds_valid, Some(headers_with_request_id(request_id))) .await; assert_token_not_valid(response).await; - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .generate_auth_key(seconds_valid) + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .generate_auth_key(seconds_valid, None) .await; assert_unauthorized(response).await; env.stop().await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); } #[tokio::test] async fn should_fail_generating_a_new_auth_key_when_the_key_duration_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; @@ -465,7 +582,8 @@ mod deprecated_generate_key_endpoint { for invalid_key_duration in invalid_key_durations { let response = Client::new(env.get_connection_info()) - .post_empty(&format!("key/{invalid_key_duration}")) + .unwrap() + .post_empty(&format!("key/{invalid_key_duration}"), None) .await; assert_invalid_key_duration_param(response, invalid_key_duration).await; @@ -476,19 +594,26 @@ mod deprecated_generate_key_endpoint { #[tokio::test] async fn should_fail_when_the_auth_key_cannot_be_generated() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - force_database_error(&env.tracker); + force_database_error(&env.container.tracker_core_container.database); + let request_id = Uuid::new_v4(); let seconds_valid = 60; - let response = Client::new(env.get_connection_info()).generate_auth_key(seconds_valid).await; + let response = Client::new(env.get_connection_info()) + .unwrap() + .generate_auth_key(seconds_valid, Some(headers_with_request_id(request_id))) + .await; assert_failed_to_generate_key(response).await; env.stop().await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); } } diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/health_check.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/health_check.rs new file mode 100644 index 000000000..3a08c6d51 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/health_check.rs @@ -0,0 +1,22 @@ +use torrust_axum_rest_tracker_api_server::environment::Started; +use torrust_axum_rest_tracker_api_server::v1::context::health_check::resources::{Report, Status}; +use torrust_rest_tracker_api_client::v1::client::get; +use torrust_tracker_test_helpers::{configuration, logging}; +use url::Url; + +#[tokio::test] +async fn health_check_endpoint_should_return_status_ok_if_api_is_running() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let url = Url::parse(&format!("{}api/health_check", env.get_connection_info().origin)).unwrap(); + + let response = get(url, None, None).await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + env.stop().await; +} diff --git a/tests/servers/api/v1/contract/context/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/mod.rs similarity index 100% rename from tests/servers/api/v1/contract/context/mod.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/mod.rs diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/stats.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/stats.rs new file mode 100644 index 000000000..7cae0abbf --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/stats.rs @@ -0,0 +1,111 @@ +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_axum_rest_tracker_api_server::environment::Started; +use torrust_axum_rest_tracker_api_server::v1::context::stats::resources::Stats; +use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client}; +use torrust_tracker_primitives::peer::fixture::PeerBuilder; +use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; +use torrust_tracker_test_helpers::{configuration, logging}; +use uuid::Uuid; + +use crate::server::connection_info::{connection_with_invalid_token, connection_with_no_token}; +use crate::server::v1::asserts::{assert_stats, assert_token_not_valid, assert_unauthorized}; + +#[tokio::test] +async fn should_allow_getting_tracker_statistics() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + env.add_torrent_peer( + &InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), // DevSkim: ignore DS173237 + &PeerBuilder::default().into(), + ) + .await; + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_tracker_statistics(Some(headers_with_request_id(request_id))) + .await; + + assert_stats( + response, + Stats { + torrents: 1, + seeders: 1, + completed: 0, + leechers: 0, + // TCP + tcp4_connections_handled: 0, + tcp4_announces_handled: 0, + tcp4_scrapes_handled: 0, + tcp6_connections_handled: 0, + tcp6_announces_handled: 0, + tcp6_scrapes_handled: 0, + // UDP + udp_requests_aborted: 0, + udp_requests_banned: 0, + udp_banned_ips_total: 0, + udp_avg_connect_processing_time_ns: 0, + udp_avg_announce_processing_time_ns: 0, + udp_avg_scrape_processing_time_ns: 0, + // UDPv4 + udp4_requests: 0, + udp4_connections_handled: 0, + udp4_announces_handled: 0, + udp4_scrapes_handled: 0, + udp4_responses: 0, + udp4_errors_handled: 0, + // UDPv6 + udp6_requests: 0, + udp6_connections_handled: 0, + udp6_announces_handled: 0, + udp6_scrapes_handled: 0, + udp6_responses: 0, + udp6_errors_handled: 0, + }, + ) + .await; + + env.stop().await; +} + +#[tokio::test] +async fn should_not_allow_getting_tracker_statistics_for_unauthenticated_users() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .get_tracker_statistics(Some(headers_with_request_id(request_id))) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .get_tracker_statistics(Some(headers_with_request_id(request_id))) + .await; + + assert_unauthorized(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} diff --git a/tests/servers/api/v1/contract/context/torrent.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/torrent.rs similarity index 54% rename from tests/servers/api/v1/contract/context/torrent.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/torrent.rs index f5e930be3..ae9819785 100644 --- a/tests/servers/api/v1/contract/context/torrent.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/torrent.rs @@ -1,43 +1,44 @@ use std::str::FromStr; -use torrust_tracker::servers::apis::v1::context::torrent::resources::peer::Peer; -use torrust_tracker::servers::apis::v1::context::torrent::resources::torrent::{self, Torrent}; -use torrust_tracker_primitives::info_hash::InfoHash; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_axum_rest_tracker_api_server::environment::Started; +use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::peer::Peer; +use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::{self, Torrent}; +use torrust_rest_tracker_api_client::common::http::{Query, QueryParam}; +use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client}; use torrust_tracker_primitives::peer::fixture::PeerBuilder; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; +use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; +use torrust_tracker_test_helpers::{configuration, logging}; +use uuid::Uuid; -use crate::common::http::{Query, QueryParam}; -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::connection_info::{connection_with_invalid_token, connection_with_no_token}; -use crate::servers::api::v1::asserts::{ +use crate::server::connection_info::{connection_with_invalid_token, connection_with_no_token}; +use crate::server::v1::asserts::{ assert_bad_request, assert_invalid_infohash_param, assert_not_found, assert_token_not_valid, assert_torrent_info, assert_torrent_list, assert_torrent_not_known, assert_unauthorized, }; -use crate::servers::api::v1::client::Client; -use crate::servers::api::v1::contract::fixtures::{ - invalid_infohashes_returning_bad_request, invalid_infohashes_returning_not_found, -}; -use crate::servers::api::Started; +use crate::server::v1::contract::fixtures::{invalid_infohashes_returning_bad_request, invalid_infohashes_returning_not_found}; #[tokio::test] async fn should_allow_getting_all_torrents() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 - env.add_torrent_peer(&info_hash, &PeerBuilder::default().into()); + env.add_torrent_peer(&info_hash, &PeerBuilder::default().into()).await; - let response = Client::new(env.get_connection_info()).get_torrents(Query::empty()).await; + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_torrents(Query::empty(), Some(headers_with_request_id(request_id))) + .await; assert_torrent_list( response, vec![torrent::ListItem { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 0, leechers: 0, @@ -50,27 +51,31 @@ async fn should_allow_getting_all_torrents() { #[tokio::test] async fn should_allow_limiting_the_torrents_in_the_result() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; // torrents are ordered alphabetically by infohashes - let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); - let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); + let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 + let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); // DevSkim: ignore DS173237 + + env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()).await; + env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()).await; - env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()); - env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()); + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params([QueryParam::new("limit", "1")].to_vec())) + .unwrap() + .get_torrents( + Query::params([QueryParam::new("limit", "1")].to_vec()), + Some(headers_with_request_id(request_id)), + ) .await; assert_torrent_list( response, vec![torrent::ListItem { - info_hash: "0b3aea4adc213ce32295be85d3883a63bca25446".to_string(), + info_hash: "0b3aea4adc213ce32295be85d3883a63bca25446".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 0, leechers: 0, @@ -83,27 +88,31 @@ async fn should_allow_limiting_the_torrents_in_the_result() { #[tokio::test] async fn should_allow_the_torrents_result_pagination() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; // torrents are ordered alphabetically by infohashes - let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); - let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); + let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 + let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); // DevSkim: ignore DS173237 - env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()); - env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()); + env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()).await; + env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()).await; + + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params([QueryParam::new("offset", "1")].to_vec())) + .unwrap() + .get_torrents( + Query::params([QueryParam::new("offset", "1")].to_vec()), + Some(headers_with_request_id(request_id)), + ) .await; assert_torrent_list( response, vec![torrent::ListItem { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 0, leechers: 0, @@ -116,26 +125,30 @@ async fn should_allow_the_torrents_result_pagination() { #[tokio::test] async fn should_allow_getting_a_list_of_torrents_providing_infohashes() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); // DevSkim: ignore DS173237 - env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()); - env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()); + env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()).await; + env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()).await; + + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params( - [ - QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237 - QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237 - ] - .to_vec(), - )) + .unwrap() + .get_torrents( + Query::params( + [ + QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237 + QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237 + ] + .to_vec(), + ), + Some(headers_with_request_id(request_id)), + ) .await; assert_torrent_list( @@ -162,20 +175,28 @@ async fn should_allow_getting_a_list_of_torrents_providing_infohashes() { #[tokio::test] async fn should_fail_getting_torrents_when_the_offset_query_parameter_cannot_be_parsed() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let invalid_offsets = [" ", "-1", "1.1", "INVALID OFFSET"]; for invalid_offset in &invalid_offsets { + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params([QueryParam::new("offset", invalid_offset)].to_vec())) + .unwrap() + .get_torrents( + Query::params([QueryParam::new("offset", invalid_offset)].to_vec()), + Some(headers_with_request_id(request_id)), + ) .await; - assert_bad_request(response, "Failed to deserialize query string: invalid digit found in string").await; + assert_bad_request( + response, + "Failed to deserialize query string: offset: invalid digit found in string", + ) + .await; } env.stop().await; @@ -183,20 +204,28 @@ async fn should_fail_getting_torrents_when_the_offset_query_parameter_cannot_be_ #[tokio::test] async fn should_fail_getting_torrents_when_the_limit_query_parameter_cannot_be_parsed() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let invalid_limits = [" ", "-1", "1.1", "INVALID LIMIT"]; for invalid_limit in &invalid_limits { + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params([QueryParam::new("limit", invalid_limit)].to_vec())) + .unwrap() + .get_torrents( + Query::params([QueryParam::new("limit", invalid_limit)].to_vec()), + Some(headers_with_request_id(request_id)), + ) .await; - assert_bad_request(response, "Failed to deserialize query string: invalid digit found in string").await; + assert_bad_request( + response, + "Failed to deserialize query string: limit: invalid digit found in string", + ) + .await; } env.stop().await; @@ -204,17 +233,21 @@ async fn should_fail_getting_torrents_when_the_limit_query_parameter_cannot_be_p #[tokio::test] async fn should_fail_getting_torrents_when_the_info_hash_parameter_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; let invalid_info_hashes = [" ", "-1", "1.1", "INVALID INFO_HASH"]; for invalid_info_hash in &invalid_info_hashes { + let request_id = Uuid::new_v4(); + let response = Client::new(env.get_connection_info()) - .get_torrents(Query::params([QueryParam::new("info_hash", invalid_info_hash)].to_vec())) + .unwrap() + .get_torrents( + Query::params([QueryParam::new("info_hash", invalid_info_hash)].to_vec()), + Some(headers_with_request_id(request_id)), + ) .await; assert_bad_request( @@ -229,49 +262,64 @@ async fn should_fail_getting_torrents_when_the_info_hash_parameter_is_invalid() #[tokio::test] async fn should_not_allow_getting_torrents_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .get_torrents(Query::empty()) + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .get_torrents(Query::empty(), Some(headers_with_request_id(request_id))) .await; assert_token_not_valid(response).await; - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .get_torrents(Query::default()) + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .get_torrents(Query::default(), Some(headers_with_request_id(request_id))) .await; assert_unauthorized(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } #[tokio::test] async fn should_allow_getting_a_torrent_info() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 let peer = PeerBuilder::default().into(); - env.add_torrent_peer(&info_hash, &peer); + env.add_torrent_peer(&info_hash, &peer).await; + + let request_id = Uuid::new_v4(); let response = Client::new(env.get_connection_info()) - .get_torrent(&info_hash.to_string()) + .unwrap() + .get_torrent(&info_hash.to_string(), Some(headers_with_request_id(request_id))) .await; assert_torrent_info( response, Torrent { - info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237 seeders: 1, completed: 0, leechers: 0, @@ -285,16 +333,16 @@ async fn should_allow_getting_a_torrent_info() { #[tokio::test] async fn should_fail_while_getting_a_torrent_info_when_the_torrent_does_not_exist() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let request_id = Uuid::new_v4(); + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 let response = Client::new(env.get_connection_info()) - .get_torrent(&info_hash.to_string()) + .unwrap() + .get_torrent(&info_hash.to_string(), Some(headers_with_request_id(request_id))) .await; assert_torrent_not_known(response).await; @@ -304,20 +352,28 @@ async fn should_fail_while_getting_a_torrent_info_when_the_torrent_does_not_exis #[tokio::test] async fn should_fail_getting_a_torrent_info_when_the_provided_infohash_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; for invalid_infohash in &invalid_infohashes_returning_bad_request() { - let response = Client::new(env.get_connection_info()).get_torrent(invalid_infohash).await; + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_torrent(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; assert_invalid_infohash_param(response, invalid_infohash).await; } for invalid_infohash in &invalid_infohashes_returning_not_found() { - let response = Client::new(env.get_connection_info()).get_torrent(invalid_infohash).await; + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .get_torrent(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; assert_not_found(response).await; } @@ -327,27 +383,41 @@ async fn should_fail_getting_a_torrent_info_when_the_provided_infohash_is_invali #[tokio::test] async fn should_not_allow_getting_a_torrent_info_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); + logging::setup(); let env = Started::new(&configuration::ephemeral().into()).await; - let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237 + + env.add_torrent_peer(&info_hash, &PeerBuilder::default().into()).await; - env.add_torrent_peer(&info_hash, &PeerBuilder::default().into()); + let request_id = Uuid::new_v4(); - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .get_torrent(&info_hash.to_string()) + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .get_torrent(&info_hash.to_string(), Some(headers_with_request_id(request_id))) .await; assert_token_not_valid(response).await; - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .get_torrent(&info_hash.to_string()) + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .get_torrent(&info_hash.to_string(), Some(headers_with_request_id(request_id))) .await; assert_unauthorized(response).await; + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + env.stop().await; } diff --git a/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs new file mode 100644 index 000000000..61fc233d0 --- /dev/null +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/context/whitelist.rs @@ -0,0 +1,412 @@ +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_axum_rest_tracker_api_server::environment::Started; +use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client}; +use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; +use torrust_tracker_test_helpers::{configuration, logging}; +use uuid::Uuid; + +use crate::server::connection_info::{connection_with_invalid_token, connection_with_no_token}; +use crate::server::force_database_error; +use crate::server::v1::asserts::{ + assert_failed_to_reload_whitelist, assert_failed_to_remove_torrent_from_whitelist, assert_failed_to_whitelist_torrent, + assert_invalid_infohash_param, assert_not_found, assert_ok, assert_token_not_valid, assert_unauthorized, +}; +use crate::server::v1::contract::fixtures::{invalid_infohashes_returning_bad_request, invalid_infohashes_returning_not_found}; + +#[tokio::test] +async fn should_allow_whitelisting_a_torrent() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + + let response = Client::new(env.get_connection_info()) + .unwrap() + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + + assert_ok(response).await; + assert!( + env.container + .tracker_core_container + .in_memory_whitelist + .contains(&InfoHash::from_str(&info_hash).unwrap()) + .await + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_allow_whitelisting_a_torrent_that_has_been_already_whitelisted() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + + let api_client = Client::new(env.get_connection_info()).unwrap(); + + let request_id = Uuid::new_v4(); + + let response = api_client + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + assert_ok(response).await; + + let request_id = Uuid::new_v4(); + + let response = api_client + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + assert_ok(response).await; + + env.stop().await; +} + +#[tokio::test] +async fn should_not_allow_whitelisting_a_torrent_for_unauthenticated_users() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + + assert_unauthorized(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_fail_when_the_torrent_cannot_be_whitelisted() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + + force_database_error(&env.container.tracker_core_container.database); + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .whitelist_a_torrent(&info_hash, Some(headers_with_request_id(request_id))) + .await; + + assert_failed_to_whitelist_torrent(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_fail_whitelisting_a_torrent_when_the_provided_infohash_is_invalid() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let request_id = Uuid::new_v4(); + + for invalid_infohash in &invalid_infohashes_returning_bad_request() { + let response = Client::new(env.get_connection_info()) + .unwrap() + .whitelist_a_torrent(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; + + assert_invalid_infohash_param(response, invalid_infohash).await; + } + + let request_id = Uuid::new_v4(); + + for invalid_infohash in &invalid_infohashes_returning_not_found() { + let response = Client::new(env.get_connection_info()) + .unwrap() + .whitelist_a_torrent(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; + + assert_not_found(response).await; + } + + env.stop().await; +} + +#[tokio::test] +async fn should_allow_removing_a_torrent_from_the_whitelist() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .remove_torrent_from_whitelist(&hash, Some(headers_with_request_id(request_id))) + .await; + + assert_ok(response).await; + assert!( + !env.container + .tracker_core_container + .in_memory_whitelist + .contains(&info_hash) + .await + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_not_fail_trying_to_remove_a_non_whitelisted_torrent_from_the_whitelist() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let non_whitelisted_torrent_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .remove_torrent_from_whitelist(&non_whitelisted_torrent_hash, Some(headers_with_request_id(request_id))) + .await; + + assert_ok(response).await; + + env.stop().await; +} + +#[tokio::test] +async fn should_fail_removing_a_torrent_from_the_whitelist_when_the_provided_infohash_is_invalid() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + for invalid_infohash in &invalid_infohashes_returning_bad_request() { + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .remove_torrent_from_whitelist(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; + + assert_invalid_infohash_param(response, invalid_infohash).await; + } + + for invalid_infohash in &invalid_infohashes_returning_not_found() { + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .remove_torrent_from_whitelist(invalid_infohash, Some(headers_with_request_id(request_id))) + .await; + + assert_not_found(response).await; + } + + env.stop().await; +} + +#[tokio::test] +async fn should_fail_when_the_torrent_cannot_be_removed_from_the_whitelist() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + force_database_error(&env.container.tracker_core_container.database); + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .remove_torrent_from_whitelist(&hash, Some(headers_with_request_id(request_id))) + .await; + + assert_failed_to_remove_torrent_from_whitelist(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_not_allow_removing_a_torrent_from_the_whitelist_for_unauthenticated_users() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_invalid_token(env.get_connection_info().origin)) + .unwrap() + .remove_torrent_from_whitelist(&hash, Some(headers_with_request_id(request_id))) + .await; + + assert_token_not_valid(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + let request_id = Uuid::new_v4(); + + let response = Client::new(connection_with_no_token(env.get_connection_info().origin)) + .unwrap() + .remove_torrent_from_whitelist(&hash, Some(headers_with_request_id(request_id))) + .await; + + assert_unauthorized(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} + +#[tokio::test] +async fn should_allow_reload_the_whitelist_from_the_database() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .reload_whitelist(Some(headers_with_request_id(request_id))) + .await; + + assert_ok(response).await; + /* todo: this assert fails because the whitelist has not been reloaded yet. + We could add a new endpoint GET /api/whitelist/:info_hash to check if a torrent + is whitelisted and use that endpoint to check if the torrent is still there after reloading. + assert!( + !(env + .tracker + .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) + .await) + ); + */ + + env.stop().await; +} + +#[tokio::test] +async fn should_fail_when_the_whitelist_cannot_be_reloaded_from_the_database() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + env.container + .tracker_core_container + .whitelist_manager + .add_torrent_to_whitelist(&info_hash) + .await + .unwrap(); + + force_database_error(&env.container.tracker_core_container.database); + + let request_id = Uuid::new_v4(); + + let response = Client::new(env.get_connection_info()) + .unwrap() + .reload_whitelist(Some(headers_with_request_id(request_id))) + .await; + + assert_failed_to_reload_whitelist(response).await; + + assert!( + logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]), + "Expected logs to contain: ERROR ... API ... request_id={request_id}" + ); + + env.stop().await; +} diff --git a/tests/servers/api/v1/contract/fixtures.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/fixtures.rs similarity index 100% rename from tests/servers/api/v1/contract/fixtures.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/contract/fixtures.rs diff --git a/tests/servers/api/v1/contract/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/mod.rs similarity index 71% rename from tests/servers/api/v1/contract/mod.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/contract/mod.rs index 38b4a2b37..2a3f78afd 100644 --- a/tests/servers/api/v1/contract/mod.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/contract/mod.rs @@ -1,4 +1,3 @@ pub mod authentication; -pub mod configuration; pub mod context; pub mod fixtures; diff --git a/tests/servers/api/v1/mod.rs b/packages/axum-rest-tracker-api-server/tests/server/v1/mod.rs similarity index 68% rename from tests/servers/api/v1/mod.rs rename to packages/axum-rest-tracker-api-server/tests/server/v1/mod.rs index 37298b377..e2db6b4ce 100644 --- a/tests/servers/api/v1/mod.rs +++ b/packages/axum-rest-tracker-api-server/tests/server/v1/mod.rs @@ -1,3 +1,2 @@ pub mod asserts; -pub mod client; pub mod contract; diff --git a/packages/axum-server/Cargo.toml b/packages/axum-server/Cargo.toml new file mode 100644 index 000000000..a60bab885 --- /dev/null +++ b/packages/axum-server/Cargo.toml @@ -0,0 +1,32 @@ +[package] +authors.workspace = true +description = "A wrapper for the Axum server for Torrust HTTP servers to add timeouts." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["axum", "server", "torrust", "wrapper"] +license.workspace = true +name = "torrust-axum-server" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +axum-server = { version = "0", features = ["tls-rustls-no-provider"] } +camino = { version = "1", features = ["serde", "serde1"] } +futures-util = "0" +http-body = "1" +hyper = "1" +hyper-util = { version = "0", features = ["http1", "http2", "tokio"] } +pin-project-lite = "0" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +tower = { version = "0", features = ["timeout"] } +tracing = "0" + +[dev-dependencies] diff --git a/packages/axum-server/LICENSE b/packages/axum-server/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/axum-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/axum-server/README.md b/packages/axum-server/README.md new file mode 100644 index 000000000..d2f396915 --- /dev/null +++ b/packages/axum-server/README.md @@ -0,0 +1,11 @@ +# Torrust Axum Server + +A wrapper for the Axum server for Torrust HTTP servers to add timeouts. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-axum-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/src/servers/custom_axum_server.rs b/packages/axum-server/src/custom_axum_server.rs similarity index 89% rename from src/servers/custom_axum_server.rs rename to packages/axum-server/src/custom_axum_server.rs index 5705ef24e..0328198ec 100644 --- a/src/servers/custom_axum_server.rs +++ b/packages/axum-server/src/custom_axum_server.rs @@ -18,7 +18,7 @@ //! If you want to know more about Axum and timeouts see . use std::future::Ready; use std::io::ErrorKind; -use std::net::TcpListener; +use std::net::{SocketAddr, TcpListener}; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; @@ -36,21 +36,32 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::time::{Instant, Sleep}; use tower::Service; +type RustlsServerResult = Result, std::io::Error>; +type ServerResult = Result, std::io::Error>; + const HTTP1_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(5); const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5); const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5); -#[must_use] -pub fn from_tcp_with_timeouts(socket: TcpListener) -> Server { - add_timeouts(axum_server::from_tcp(socket)) +/// Creates an Axum server from a TCP listener with configured timeouts. +/// +/// # Errors +/// +/// Returns an error if the server cannot be created from the TCP socket. +pub fn from_tcp_with_timeouts(socket: TcpListener) -> ServerResult { + axum_server::from_tcp(socket).map(add_timeouts) } -#[must_use] -pub fn from_tcp_rustls_with_timeouts(socket: TcpListener, tls: RustlsConfig) -> Server { - add_timeouts(axum_server::from_tcp_rustls(socket, tls)) +/// Creates an Axum server from a TCP listener with TLS and configured timeouts. +/// +/// # Errors +/// +/// Returns an error if the server cannot be created from the TCP socket or if TLS configuration fails. +pub fn from_tcp_rustls_with_timeouts(socket: TcpListener, tls: RustlsConfig) -> RustlsServerResult { + axum_server::from_tcp_rustls(socket, tls).map(add_timeouts) } -fn add_timeouts(mut server: Server) -> Server { +fn add_timeouts(mut server: Server) -> Server { server.http_builder().http1().timer(TokioTimer::new()); server.http_builder().http2().timer(TokioTimer::new()); diff --git a/packages/axum-server/src/lib.rs b/packages/axum-server/src/lib.rs new file mode 100644 index 000000000..88bf25f19 --- /dev/null +++ b/packages/axum-server/src/lib.rs @@ -0,0 +1,3 @@ +pub mod custom_axum_server; +pub mod signals; +pub mod tsl; diff --git a/packages/axum-server/src/signals.rs b/packages/axum-server/src/signals.rs new file mode 100644 index 000000000..360879e32 --- /dev/null +++ b/packages/axum-server/src/signals.rs @@ -0,0 +1,49 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use tokio::time::{sleep, Instant}; +use torrust_server_lib::signals::{shutdown_signal_with_message, Halted}; +use tracing::instrument; + +#[instrument(skip(handle, rx_halt, message))] +pub async fn graceful_shutdown( + handle: axum_server::Handle, + rx_halt: tokio::sync::oneshot::Receiver, + message: String, + address: SocketAddr, +) { + shutdown_signal_with_message(rx_halt, message.clone()).await; + + let grace_period = Duration::from_secs(90); + let max_wait = Duration::from_secs(95); + let start = Instant::now(); + + handle.graceful_shutdown(Some(grace_period)); + + tracing::info!("!! {} in {} seconds !!", message, grace_period.as_secs()); + + loop { + if handle.connection_count() == 0 { + tracing::info!("All connections closed, shutting down server in address {}", address); + break; + } + + if start.elapsed() >= max_wait { + tracing::warn!( + "Shutdown timeout of {} seconds reached. Forcing shutdown in address {} with {} active connections.", + max_wait.as_secs(), + address, + handle.connection_count() + ); + break; + } + + tracing::info!( + "Remaining alive connections: {} ({}s elapsed)", + handle.connection_count(), + start.elapsed().as_secs() + ); + + sleep(Duration::from_secs(1)).await; + } +} diff --git a/packages/axum-server/src/tsl.rs b/packages/axum-server/src/tsl.rs new file mode 100644 index 000000000..5d68b5b4c --- /dev/null +++ b/packages/axum-server/src/tsl.rs @@ -0,0 +1,85 @@ +use std::panic::Location; +use std::sync::Arc; + +use axum_server::tls_rustls::RustlsConfig; +use thiserror::Error; +use torrust_tracker_configuration::TslConfig; +use torrust_tracker_located_error::{DynError, LocatedError}; +use tracing::instrument; + +/// Error returned by the Bootstrap Process. +#[derive(Error, Debug)] +pub enum Error { + /// Enabled tls but missing config. + #[error("tls config missing")] + MissingTlsConfig { location: &'static Location<'static> }, + + /// Unable to parse tls Config. + #[error("bad tls config: {source}")] + BadTlsConfig { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + }, +} + +#[instrument(skip(opt_tsl_config))] +pub async fn make_rust_tls(opt_tsl_config: &Option) -> Option> { + match opt_tsl_config { + Some(tsl_config) => { + let cert = tsl_config.ssl_cert_path.clone(); + let key = tsl_config.ssl_key_path.clone(); + + if !cert.exists() || !key.exists() { + return Some(Err(Error::MissingTlsConfig { + location: Location::caller(), + })); + } + + tracing::info!("Using https: cert path: {cert}."); + tracing::info!("Using https: key path: {key}."); + + Some( + RustlsConfig::from_pem_file(cert, key) + .await + .map_err(|err| Error::BadTlsConfig { + source: (Arc::new(err) as DynError).into(), + }), + ) + } + None => None, + } +} + +#[cfg(test)] +mod tests { + + use camino::Utf8PathBuf; + use torrust_tracker_configuration::TslConfig; + + use super::{make_rust_tls, Error}; + + #[tokio::test] + async fn it_should_error_on_bad_tls_config() { + let err = make_rust_tls(&Some(TslConfig { + ssl_cert_path: Utf8PathBuf::from("bad cert path"), + ssl_key_path: Utf8PathBuf::from("bad key path"), + })) + .await + .expect("tls_was_enabled") + .expect_err("bad_cert_and_key_files"); + + assert!(matches!(err, Error::MissingTlsConfig { location: _ })); + } + + #[tokio::test] + async fn it_should_error_on_missing_cert_or_key_paths() { + let err = make_rust_tls(&Some(TslConfig { + ssl_cert_path: Utf8PathBuf::from(""), + ssl_key_path: Utf8PathBuf::from(""), + })) + .await + .expect("tls_was_enabled") + .expect_err("missing_config"); + + assert!(matches!(err, Error::MissingTlsConfig { location: _ })); + } +} diff --git a/packages/clock/Cargo.toml b/packages/clock/Cargo.toml index f95c12a0c..3bd00d2b0 100644 --- a/packages/clock/Cargo.toml +++ b/packages/clock/Cargo.toml @@ -18,7 +18,8 @@ version.workspace = true [dependencies] chrono = { version = "0", default-features = false, features = ["clock"] } lazy_static = "1" +tracing = "0" -torrust-tracker-primitives = { version = "3.0.0-rc.1", path = "../primitives" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } [dev-dependencies] diff --git a/packages/clock/src/clock/stopped/mod.rs b/packages/clock/src/clock/stopped/mod.rs index 57655ab75..5d0b2ec4e 100644 --- a/packages/clock/src/clock/stopped/mod.rs +++ b/packages/clock/src/clock/stopped/mod.rs @@ -1,6 +1,5 @@ /// Trait for types that can be used as a timestamp clock stopped /// at a given time. - #[allow(clippy::module_name_repetitions)] pub struct StoppedClock {} diff --git a/packages/clock/src/conv/mod.rs b/packages/clock/src/conv/mod.rs index 894083061..0ac278171 100644 --- a/packages/clock/src/conv/mod.rs +++ b/packages/clock/src/conv/mod.rs @@ -48,7 +48,6 @@ pub fn convert_from_timestamp_to_datetime_utc(duration: DurationSinceUnixEpoch) } #[cfg(test)] - mod tests { use chrono::DateTime; use torrust_tracker_primitives::DurationSinceUnixEpoch; diff --git a/packages/clock/src/lib.rs b/packages/clock/src/lib.rs index 295d22c16..ff0527714 100644 --- a/packages/clock/src/lib.rs +++ b/packages/clock/src/lib.rs @@ -22,15 +22,15 @@ //! > **NOTICE**: the timestamp does not depend on the time zone. That gives you //! > the ability to use the clock regardless of the underlying system time zone //! > configuration. See [Unix time Wikipedia entry](https://en.wikipedia.org/wiki/Unix_time). - pub mod clock; pub mod conv; pub mod static_time; -pub mod time_extent; #[macro_use] extern crate lazy_static; +use tracing::instrument; + /// This code needs to be copied into each crate. /// Working version, for production. #[cfg(not(test))] @@ -42,12 +42,15 @@ pub(crate) type CurrentClock = clock::Working; #[allow(dead_code)] pub(crate) type CurrentClock = clock::Stopped; -/// Working version, for production. -#[cfg(not(test))] -#[allow(dead_code)] -pub(crate) type DefaultTimeExtentMaker = time_extent::WorkingTimeExtentMaker; - -/// Stopped version, for testing. -#[cfg(test)] -#[allow(dead_code)] -pub(crate) type DefaultTimeExtentMaker = time_extent::StoppedTimeExtentMaker; +/// It initializes the application static values. +/// +/// These values are accessible throughout the entire application: +/// +/// - The time when the application started. +/// - An ephemeral instance random seed. This seed is used for encryption and +/// it's changed when the main application process is restarted. +#[instrument(skip())] +pub fn initialize_static() { + // Set the time of Torrust app starting + lazy_static::initialize(&static_time::TIME_AT_APP_START); +} diff --git a/packages/clock/src/time_extent/mod.rs b/packages/clock/src/time_extent/mod.rs deleted file mode 100644 index c51849f21..000000000 --- a/packages/clock/src/time_extent/mod.rs +++ /dev/null @@ -1,665 +0,0 @@ -//! It includes functionality to handle time extents. -//! -//! Time extents are used to represent a duration of time which contains -//! N times intervals of the same duration. -//! -//! Given a duration of: 60 seconds. -//! -//! ```text -//! |------------------------------------------------------------| -//! ``` -//! -//! If we define a **base** duration of `10` seconds, we would have `6` intervals. -//! -//! ```text -//! |----------|----------|----------|----------|----------|----------| -//! ^--- 10 seconds -//! ``` -//! -//! Then, You can represent half of the duration (`30` seconds) as: -//! -//! ```text -//! |----------|----------|----------|----------|----------|----------| -//! ^--- 30 seconds -//! ``` -//! -//! `3` times (**multiplier**) the **base** interval (3*10 = 30 seconds): -//! -//! ```text -//! |----------|----------|----------|----------|----------|----------| -//! ^--- 30 seconds (3 units of 10 seconds) -//! ``` -//! -//! Time extents are a way to measure time duration using only one unit of time -//! (**base** duration) repeated `N` times (**multiplier**). -//! -//! Time extents are not clocks in a sense that they do not have a start time. -//! They are not synchronized with the real time. In order to measure time, -//! you need to define a start time for the intervals. -//! -//! For example, we could measure time is "lustrums" (5 years) since the start -//! of the 21st century. The time extent would contains a base 5-year duration -//! and the multiplier. The current "lustrum" (2023) would be 5th one if we -//! start counting "lustrums" at 1. -//! -//! ```text -//! Lustrum 1: 2000-2004 -//! Lustrum 2: 2005-2009 -//! Lustrum 3: 2010-2014 -//! Lustrum 4: 2015-2019 -//! Lustrum 5: 2020-2024 -//! ``` -//! -//! More practically time extents are used to represent number of time intervals -//! since the Unix Epoch. Each interval is typically an amount of seconds. -//! It's specially useful to check expiring dates. For example, you can have an -//! authentication token that expires after 120 seconds. If you divide the -//! current timestamp by 120 you get the number of 2-minute intervals since the -//! Unix Epoch, you can hash that value with a secret key and send it to a -//! client. The client can authenticate by sending the hashed value back to the -//! server. The server can build the same hash and compare it with the one sent -//! by the client. The hash would be the same during the 2-minute interval, but -//! it would change after that. This method is one of the methods used by UDP -//! trackers to generate and verify a connection ID, which a a token sent to -//! the client to identify the connection. -use std::num::{IntErrorKind, TryFromIntError}; -use std::time::Duration; - -use crate::clock::{self, Stopped, Working}; - -/// This trait defines the operations that can be performed on a `TimeExtent`. -pub trait Extent: Sized + Default { - type Base; - type Multiplier; - type Product; - - /// It creates a new `TimeExtent`. - fn new(unit: &Self::Base, count: &Self::Multiplier) -> Self; - - /// It increases the `TimeExtent` by a multiplier. - /// - /// # Errors - /// - /// Will return `IntErrorKind` if `add` would overflow the internal `Duration`. - fn increase(&self, add: Self::Multiplier) -> Result; - - /// It decreases the `TimeExtent` by a multiplier. - /// - /// # Errors - /// - /// Will return `IntErrorKind` if `sub` would underflow the internal `Duration`. - fn decrease(&self, sub: Self::Multiplier) -> Result; - - /// It returns the total `Duration` of the `TimeExtent`. - fn total(&self) -> Option>; - - /// It returns the total `Duration` of the `TimeExtent` plus one increment. - fn total_next(&self) -> Option>; -} - -/// The `TimeExtent` base `Duration`, which is the duration of a single interval. -pub type Base = Duration; -/// The `TimeExtent` `Multiplier`, which is the number of `Base` duration intervals. -pub type Multiplier = u64; -/// The `TimeExtent` product, which is the total duration of the `TimeExtent`. -pub type Product = Base; - -/// A `TimeExtent` is a duration of time which contains N times intervals -/// of the same duration. -#[derive(Debug, Default, Hash, PartialEq, Eq)] -pub struct TimeExtent { - pub increment: Base, - pub amount: Multiplier, -} - -/// A zero time extent. It's the additive identity for a `TimeExtent`. -pub const ZERO: TimeExtent = TimeExtent { - increment: Base::ZERO, - amount: Multiplier::MIN, -}; - -/// The maximum value for a `TimeExtent`. -pub const MAX: TimeExtent = TimeExtent { - increment: Base::MAX, - amount: Multiplier::MAX, -}; - -impl TimeExtent { - #[must_use] - pub const fn from_sec(seconds: u64, amount: &Multiplier) -> Self { - Self { - increment: Base::from_secs(seconds), - amount: *amount, - } - } -} - -fn checked_duration_from_nanos(time: u128) -> Result { - const NANOS_PER_SEC: u32 = 1_000_000_000; - - let secs = time.div_euclid(u128::from(NANOS_PER_SEC)); - let nanos = time.rem_euclid(u128::from(NANOS_PER_SEC)); - - assert!(nanos < u128::from(NANOS_PER_SEC)); - - match u64::try_from(secs) { - Err(error) => Err(error), - Ok(secs) => Ok(Duration::new(secs, nanos.try_into().unwrap())), - } -} - -impl Extent for TimeExtent { - type Base = Base; - type Multiplier = Multiplier; - type Product = Product; - - fn new(increment: &Self::Base, amount: &Self::Multiplier) -> Self { - Self { - increment: *increment, - amount: *amount, - } - } - - fn increase(&self, add: Self::Multiplier) -> Result { - match self.amount.checked_add(add) { - None => Err(IntErrorKind::PosOverflow), - Some(amount) => Ok(Self { - increment: self.increment, - amount, - }), - } - } - - fn decrease(&self, sub: Self::Multiplier) -> Result { - match self.amount.checked_sub(sub) { - None => Err(IntErrorKind::NegOverflow), - Some(amount) => Ok(Self { - increment: self.increment, - amount, - }), - } - } - - fn total(&self) -> Option> { - self.increment - .as_nanos() - .checked_mul(u128::from(self.amount)) - .map(checked_duration_from_nanos) - } - - fn total_next(&self) -> Option> { - self.increment - .as_nanos() - .checked_mul(u128::from(self.amount) + 1) - .map(checked_duration_from_nanos) - } -} - -/// A `TimeExtent` maker. It's a clock base on time extents. -/// It gives you the time in time extents. -pub trait Make: Sized -where - Clock: clock::Time, -{ - /// It gives you the current time extent (with a certain increment) for - /// the current time. It gets the current timestamp front the `Clock`. - /// - /// For example: - /// - /// - If the base increment is `1` second, it will return a time extent - /// whose duration is `1 second` and whose multiplier is the the number - /// of seconds since the Unix Epoch (time extent). - /// - If the base increment is `1` minute, it will return a time extent - /// whose duration is `60 seconds` and whose multiplier is the number of - /// minutes since the Unix Epoch (time extent). - #[must_use] - fn now(increment: &Base) -> Option> { - Clock::now() - .as_nanos() - .checked_div((*increment).as_nanos()) - .map(|amount| match Multiplier::try_from(amount) { - Err(error) => Err(error), - Ok(amount) => Ok(TimeExtent::new(increment, &amount)), - }) - } - - /// Same as [`now`](crate::time_extent::Make::now), but it - /// will add an extra duration to the current time before calculating the - /// time extent. It gives you a time extent for a time in the future. - #[must_use] - fn now_after(increment: &Base, add_time: &Duration) -> Option> { - match Clock::now_add(add_time) { - None => None, - Some(time) => time - .as_nanos() - .checked_div(increment.as_nanos()) - .map(|amount| match Multiplier::try_from(amount) { - Err(error) => Err(error), - Ok(amount) => Ok(TimeExtent::new(increment, &amount)), - }), - } - } - - /// Same as [`now`](crate::time_extent::Make::now), but it - /// will subtract a duration to the current time before calculating the - /// time extent. It gives you a time extent for a time in the past. - #[must_use] - fn now_before(increment: &Base, sub_time: &Duration) -> Option> { - match Clock::now_sub(sub_time) { - None => None, - Some(time) => time - .as_nanos() - .checked_div(increment.as_nanos()) - .map(|amount| match Multiplier::try_from(amount) { - Err(error) => Err(error), - Ok(amount) => Ok(TimeExtent::new(increment, &amount)), - }), - } - } -} - -/// A `TimeExtent` maker which makes `TimeExtents`. -/// -/// It's a clock which measures time in `TimeExtents`. -#[derive(Debug)] -pub struct Maker { - clock: std::marker::PhantomData, -} - -/// A `TimeExtent` maker which makes `TimeExtents` from the `Working` clock. -pub type WorkingTimeExtentMaker = Maker; - -/// A `TimeExtent` maker which makes `TimeExtents` from the `Stopped` clock. -pub type StoppedTimeExtentMaker = Maker; - -impl Make for WorkingTimeExtentMaker {} -impl Make for StoppedTimeExtentMaker {} - -#[cfg(test)] -mod test { - use crate::time_extent::TimeExtent; - - const TIME_EXTENT_VAL: TimeExtent = TimeExtent::from_sec(2, &239_812_388_723); - - mod fn_checked_duration_from_nanos { - use std::time::Duration; - - use crate::time_extent::checked_duration_from_nanos; - use crate::time_extent::test::TIME_EXTENT_VAL; - - const NANOS_PER_SEC: u32 = 1_000_000_000; - - #[test] - fn it_should_give_zero_for_zero_input() { - assert_eq!(checked_duration_from_nanos(0).unwrap(), Duration::ZERO); - } - - #[test] - fn it_should_be_the_same_as_duration_implementation_for_u64_numbers() { - assert_eq!( - checked_duration_from_nanos(1_232_143_214_343_432).unwrap(), - Duration::from_nanos(1_232_143_214_343_432) - ); - assert_eq!( - checked_duration_from_nanos(u128::from(u64::MAX)).unwrap(), - Duration::from_nanos(u64::MAX) - ); - } - - #[test] - fn it_should_work_for_some_numbers_larger_than_u64() { - assert_eq!( - checked_duration_from_nanos(u128::from(TIME_EXTENT_VAL.amount) * u128::from(NANOS_PER_SEC)).unwrap(), - Duration::from_secs(TIME_EXTENT_VAL.amount) - ); - } - - #[test] - fn it_should_fail_for_numbers_that_are_too_large() { - assert_eq!( - checked_duration_from_nanos(u128::MAX).unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - - mod time_extent { - - mod fn_default { - use crate::time_extent::{TimeExtent, ZERO}; - - #[test] - fn it_should_default_initialize_to_zero() { - assert_eq!(TimeExtent::default(), ZERO); - } - } - - mod fn_from_sec { - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Multiplier, TimeExtent, ZERO}; - - #[test] - fn it_should_make_empty_for_zero() { - assert_eq!(TimeExtent::from_sec(u64::MIN, &Multiplier::MIN), ZERO); - } - #[test] - fn it_should_make_from_seconds() { - assert_eq!( - TimeExtent::from_sec(TIME_EXTENT_VAL.increment.as_secs(), &TIME_EXTENT_VAL.amount), - TIME_EXTENT_VAL - ); - } - } - - mod fn_new { - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Base, Extent, Multiplier, TimeExtent, ZERO}; - - #[test] - fn it_should_make_empty_for_zero() { - assert_eq!(TimeExtent::new(&Base::ZERO, &Multiplier::MIN), ZERO); - } - - #[test] - fn it_should_make_new() { - assert_eq!( - TimeExtent::new(&Base::from_millis(2), &TIME_EXTENT_VAL.amount), - TimeExtent { - increment: Base::from_millis(2), - amount: TIME_EXTENT_VAL.amount - } - ); - } - } - - mod fn_increase { - use std::num::IntErrorKind; - - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Extent, TimeExtent, ZERO}; - - #[test] - fn it_should_not_increase_for_zero() { - assert_eq!(ZERO.increase(0).unwrap(), ZERO); - } - - #[test] - fn it_should_increase() { - assert_eq!( - TIME_EXTENT_VAL.increase(50).unwrap(), - TimeExtent { - increment: TIME_EXTENT_VAL.increment, - amount: TIME_EXTENT_VAL.amount + 50, - } - ); - } - - #[test] - fn it_should_fail_when_attempting_to_increase_beyond_bounds() { - assert_eq!(TIME_EXTENT_VAL.increase(u64::MAX), Err(IntErrorKind::PosOverflow)); - } - } - - mod fn_decrease { - use std::num::IntErrorKind; - - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Extent, TimeExtent, ZERO}; - - #[test] - fn it_should_not_decrease_for_zero() { - assert_eq!(ZERO.decrease(0).unwrap(), ZERO); - } - - #[test] - fn it_should_decrease() { - assert_eq!( - TIME_EXTENT_VAL.decrease(50).unwrap(), - TimeExtent { - increment: TIME_EXTENT_VAL.increment, - amount: TIME_EXTENT_VAL.amount - 50, - } - ); - } - - #[test] - fn it_should_fail_when_attempting_to_decrease_beyond_bounds() { - assert_eq!(TIME_EXTENT_VAL.decrease(u64::MAX), Err(IntErrorKind::NegOverflow)); - } - } - - mod fn_total { - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Base, Extent, Product, TimeExtent, MAX, ZERO}; - - #[test] - fn it_should_be_zero_for_zero() { - assert_eq!(ZERO.total().unwrap().unwrap(), Product::ZERO); - } - - #[test] - fn it_should_give_a_total() { - assert_eq!( - TIME_EXTENT_VAL.total().unwrap().unwrap(), - Product::from_secs(TIME_EXTENT_VAL.increment.as_secs() * TIME_EXTENT_VAL.amount) - ); - - assert_eq!( - TimeExtent::new(&Base::from_millis(2), &(TIME_EXTENT_VAL.amount * 1000)) - .total() - .unwrap() - .unwrap(), - Product::from_secs(TIME_EXTENT_VAL.increment.as_secs() * TIME_EXTENT_VAL.amount) - ); - - assert_eq!( - TimeExtent::new(&Base::from_secs(1), &(u64::MAX)).total().unwrap().unwrap(), - Product::from_secs(u64::MAX) - ); - } - - #[test] - fn it_should_fail_when_too_large() { - assert_eq!(MAX.total(), None); - } - - #[test] - fn it_should_fail_when_product_is_too_large() { - let time_extent = TimeExtent { - increment: MAX.increment, - amount: 2, - }; - assert_eq!( - time_extent.total().unwrap().unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - - mod fn_total_next { - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Base, Extent, Product, TimeExtent, MAX, ZERO}; - - #[test] - fn it_should_be_zero_for_zero() { - assert_eq!(ZERO.total_next().unwrap().unwrap(), Product::ZERO); - } - - #[test] - fn it_should_give_a_total() { - assert_eq!( - TIME_EXTENT_VAL.total_next().unwrap().unwrap(), - Product::from_secs(TIME_EXTENT_VAL.increment.as_secs() * (TIME_EXTENT_VAL.amount + 1)) - ); - - assert_eq!( - TimeExtent::new(&Base::from_millis(2), &(TIME_EXTENT_VAL.amount * 1000)) - .total_next() - .unwrap() - .unwrap(), - Product::new( - TIME_EXTENT_VAL.increment.as_secs() * (TIME_EXTENT_VAL.amount), - Base::from_millis(2).as_nanos().try_into().unwrap() - ) - ); - - assert_eq!( - TimeExtent::new(&Base::from_secs(1), &(u64::MAX - 1)) - .total_next() - .unwrap() - .unwrap(), - Product::from_secs(u64::MAX) - ); - } - - #[test] - fn it_should_fail_when_too_large() { - assert_eq!(MAX.total_next(), None); - } - - #[test] - fn it_should_fail_when_product_is_too_large() { - let time_extent = TimeExtent { - increment: MAX.increment, - amount: 2, - }; - assert_eq!( - time_extent.total_next().unwrap().unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - } - - mod make_time_extent { - - mod fn_now { - use torrust_tracker_primitives::DurationSinceUnixEpoch; - - use crate::clock::stopped::Stopped as _; - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Base, Make, TimeExtent}; - use crate::{CurrentClock, DefaultTimeExtentMaker}; - - #[test] - fn it_should_give_a_time_extent() { - assert_eq!( - DefaultTimeExtentMaker::now(&TIME_EXTENT_VAL.increment).unwrap().unwrap(), - TimeExtent { - increment: TIME_EXTENT_VAL.increment, - amount: 0 - } - ); - - CurrentClock::local_set(&DurationSinceUnixEpoch::from_secs(TIME_EXTENT_VAL.amount * 2)); - - assert_eq!( - DefaultTimeExtentMaker::now(&TIME_EXTENT_VAL.increment).unwrap().unwrap(), - TIME_EXTENT_VAL - ); - } - - #[test] - fn it_should_fail_for_zero() { - assert_eq!(DefaultTimeExtentMaker::now(&Base::ZERO), None); - } - - #[test] - fn it_should_fail_if_amount_exceeds_bounds() { - CurrentClock::local_set(&DurationSinceUnixEpoch::MAX); - assert_eq!( - DefaultTimeExtentMaker::now(&Base::from_millis(1)).unwrap().unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - - mod fn_now_after { - use std::time::Duration; - - use torrust_tracker_primitives::DurationSinceUnixEpoch; - - use crate::clock::stopped::Stopped as _; - use crate::time_extent::test::TIME_EXTENT_VAL; - use crate::time_extent::{Base, Make}; - use crate::{CurrentClock, DefaultTimeExtentMaker}; - - #[test] - fn it_should_give_a_time_extent() { - assert_eq!( - DefaultTimeExtentMaker::now_after( - &TIME_EXTENT_VAL.increment, - &Duration::from_secs(TIME_EXTENT_VAL.amount * 2) - ) - .unwrap() - .unwrap(), - TIME_EXTENT_VAL - ); - } - - #[test] - fn it_should_fail_for_zero() { - assert_eq!(DefaultTimeExtentMaker::now_after(&Base::ZERO, &Duration::ZERO), None); - - CurrentClock::local_set(&DurationSinceUnixEpoch::MAX); - assert_eq!(DefaultTimeExtentMaker::now_after(&Base::ZERO, &Duration::MAX), None); - } - - #[test] - fn it_should_fail_if_amount_exceeds_bounds() { - CurrentClock::local_set(&DurationSinceUnixEpoch::MAX); - assert_eq!( - DefaultTimeExtentMaker::now_after(&Base::from_millis(1), &Duration::ZERO) - .unwrap() - .unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - mod fn_now_before { - use std::time::Duration; - - use torrust_tracker_primitives::DurationSinceUnixEpoch; - - use crate::clock::stopped::Stopped as _; - use crate::time_extent::{Base, Make, TimeExtent}; - use crate::{CurrentClock, DefaultTimeExtentMaker}; - - #[test] - fn it_should_give_a_time_extent() { - CurrentClock::local_set(&DurationSinceUnixEpoch::MAX); - - assert_eq!( - DefaultTimeExtentMaker::now_before( - &Base::from_secs(u64::from(u32::MAX)), - &Duration::from_secs(u64::from(u32::MAX)) - ) - .unwrap() - .unwrap(), - TimeExtent { - increment: Base::from_secs(u64::from(u32::MAX)), - amount: 4_294_967_296 - } - ); - } - - #[test] - fn it_should_fail_for_zero() { - assert_eq!(DefaultTimeExtentMaker::now_before(&Base::ZERO, &Duration::ZERO), None); - - assert_eq!(DefaultTimeExtentMaker::now_before(&Base::ZERO, &Duration::MAX), None); - } - - #[test] - fn it_should_fail_if_amount_exceeds_bounds() { - CurrentClock::local_set(&DurationSinceUnixEpoch::MAX); - assert_eq!( - DefaultTimeExtentMaker::now_before(&Base::from_millis(1), &Duration::ZERO) - .unwrap() - .unwrap_err(), - u64::try_from(u128::MAX).unwrap_err() - ); - } - } - } -} diff --git a/packages/configuration/Cargo.toml b/packages/configuration/Cargo.toml index 7b8b3c3bf..e213f7c0c 100644 --- a/packages/configuration/Cargo.toml +++ b/packages/configuration/Cargo.toml @@ -16,14 +16,16 @@ version.workspace = true [dependencies] camino = { version = "1", features = ["serde", "serde1"] } -derive_more = { version = "1", features = ["constructor", "display"] } +derive_more = { version = "2", features = ["constructor", "display"] } figment = { version = "0", features = ["env", "test", "toml"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } serde_with = "3" -thiserror = "1" +thiserror = "2" toml = "0" -torrust-tracker-located-error = { version = "3.0.0-rc.1", path = "../located-error" } +torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +tracing = "0" +tracing-subscriber = { version = "0", features = ["json"] } url = "2" [dev-dependencies] diff --git a/packages/configuration/src/lib.rs b/packages/configuration/src/lib.rs index 1ab3479fa..d12020b8c 100644 --- a/packages/configuration/src/lib.rs +++ b/packages/configuration/src/lib.rs @@ -3,7 +3,8 @@ //! This module contains the configuration data structures for the //! Torrust Tracker, which is a `BitTorrent` tracker server. //! -//! The current version for configuration is [`v2`]. +//! The current version for configuration is [`v2_0_0`]. +pub mod logging; pub mod v2_0_0; pub mod validator; @@ -37,6 +38,7 @@ pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_TRACKER_CONFIG_TOML_PATH"; pub type Configuration = v2_0_0::Configuration; pub type Core = v2_0_0::core::Core; +pub type Logging = v2_0_0::logging::Logging; pub type HealthCheckApi = v2_0_0::health_check_api::HealthCheckApi; pub type HttpApi = v2_0_0::tracker_api::HttpApi; pub type HttpTracker = v2_0_0::http_tracker::HttpTracker; diff --git a/src/bootstrap/logging.rs b/packages/configuration/src/logging.rs similarity index 83% rename from src/bootstrap/logging.rs rename to packages/configuration/src/logging.rs index 34809c1ca..b8db27b8c 100644 --- a/src/bootstrap/logging.rs +++ b/packages/configuration/src/logging.rs @@ -13,22 +13,23 @@ //! Refer to the [configuration crate documentation](https://docs.rs/torrust-tracker-configuration) to know how to change log settings. use std::sync::Once; -use torrust_tracker_configuration::{Configuration, Threshold}; use tracing::level_filters::LevelFilter; +use crate::{Logging, Threshold}; + static INIT: Once = Once::new(); /// It redirects the log info to the standard output with the log threshold /// defined in the configuration. -pub fn setup(cfg: &Configuration) { - let tracing_level = map_to_tracing_level_filter(&cfg.logging.threshold); +pub fn setup(cfg: &Logging) { + let tracing_level = map_to_tracing_level_filter(&cfg.threshold); if tracing_level == LevelFilter::OFF { return; } INIT.call_once(|| { - tracing_stdout_init(tracing_level, &TraceStyle::Default); + tracing_init(tracing_level, &TraceStyle::Default); }); } @@ -43,8 +44,11 @@ fn map_to_tracing_level_filter(threshold: &Threshold) -> LevelFilter { } } -fn tracing_stdout_init(filter: LevelFilter, style: &TraceStyle) { - let builder = tracing_subscriber::fmt().with_max_level(filter).with_ansi(true); +fn tracing_init(filter: LevelFilter, style: &TraceStyle) { + let builder = tracing_subscriber::fmt() + .with_max_level(filter) + .with_ansi(true) + .with_test_writer(); let () = match style { TraceStyle::Default => builder.init(), diff --git a/packages/configuration/src/v2_0_0/core.rs b/packages/configuration/src/v2_0_0/core.rs index ed3e6aeb7..32dac8b3c 100644 --- a/packages/configuration/src/v2_0_0/core.rs +++ b/packages/configuration/src/v2_0_0/core.rs @@ -103,6 +103,7 @@ impl Core { fn default_tracker_policy() -> TrackerPolicy { TrackerPolicy::default() } + fn default_tracker_usage_statistics() -> bool { true } diff --git a/packages/configuration/src/v2_0_0/health_check_api.rs b/packages/configuration/src/v2_0_0/health_check_api.rs index 61178fa80..368f26c42 100644 --- a/packages/configuration/src/v2_0_0/health_check_api.rs +++ b/packages/configuration/src/v2_0_0/health_check_api.rs @@ -25,6 +25,6 @@ impl Default for HealthCheckApi { impl HealthCheckApi { fn default_bind_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1313) + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1313) } } diff --git a/packages/configuration/src/v2_0_0/http_tracker.rs b/packages/configuration/src/v2_0_0/http_tracker.rs index 42ec02bf2..ae00257d8 100644 --- a/packages/configuration/src/v2_0_0/http_tracker.rs +++ b/packages/configuration/src/v2_0_0/http_tracker.rs @@ -19,6 +19,10 @@ pub struct HttpTracker { /// TSL config. #[serde(default = "HttpTracker::default_tsl_config")] pub tsl_config: Option, + + /// Weather the tracker should collect statistics about tracker usage. + #[serde(default = "HttpTracker::default_tracker_usage_statistics")] + pub tracker_usage_statistics: bool, } impl Default for HttpTracker { @@ -26,16 +30,21 @@ impl Default for HttpTracker { Self { bind_address: Self::default_bind_address(), tsl_config: Self::default_tsl_config(), + tracker_usage_statistics: Self::default_tracker_usage_statistics(), } } } impl HttpTracker { fn default_bind_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 7070) + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7070) } fn default_tsl_config() -> Option { None } + + fn default_tracker_usage_statistics() -> bool { + false + } } diff --git a/packages/configuration/src/v2_0_0/mod.rs b/packages/configuration/src/v2_0_0/mod.rs index 5067210bb..b3fbc881e 100644 --- a/packages/configuration/src/v2_0_0/mod.rs +++ b/packages/configuration/src/v2_0_0/mod.rs @@ -39,11 +39,11 @@ //! Please refer to the documentation of each structure for more information //! about each section. //! -//! - [`Core configuration`](crate::v2::Configuration) -//! - [`HTTP API configuration`](crate::v2::tracker_api::HttpApi) -//! - [`HTTP Tracker configuration`](crate::v2::http_tracker::HttpTracker) -//! - [`UDP Tracker configuration`](crate::v2::udp_tracker::UdpTracker) -//! - [`Health Check API configuration`](crate::v2::health_check_api::HealthCheckApi) +//! - [`Core configuration`](crate::v2_0_0::Configuration) +//! - [`HTTP API configuration`](crate::v2_0_0::tracker_api::HttpApi) +//! - [`HTTP Tracker configuration`](crate::v2_0_0::http_tracker::HttpTracker) +//! - [`UDP Tracker configuration`](crate::v2_0_0::udp_tracker::UdpTracker) +//! - [`Health Check API configuration`](crate::v2_0_0::health_check_api::HealthCheckApi) //! //! ## Port binding //! @@ -78,7 +78,7 @@ //! //! Alternatively, you could setup a reverse proxy like Nginx or Apache to //! handle the SSL/TLS part and forward the requests to the tracker. If you do -//! that, you should set [`on_reverse_proxy`](crate::v2::network::Network::on_reverse_proxy) +//! that, you should set [`on_reverse_proxy`](crate::v2_0_0::network::Network::on_reverse_proxy) //! to `true` in the configuration file. It's out of scope for this //! documentation to explain in detail how to setup a reverse proxy, but the //! configuration file should be something like this: @@ -492,10 +492,7 @@ mod tests { fn configuration_should_contain_the_external_ip() { let configuration = Configuration::default(); - assert_eq!( - configuration.core.net.external_ip, - Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))) - ); + assert_eq!(configuration.core.net.external_ip, Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); } #[test] @@ -524,6 +521,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_file() { figment::Jail::expect_with(|jail| { jail.create_file( @@ -555,6 +553,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_content() { figment::Jail::expect_with(|_jail| { let config_toml = r#" @@ -584,6 +583,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn default_configuration_could_be_overwritten_from_a_single_env_var_with_toml_contents() { figment::Jail::expect_with(|_jail| { let config_toml = r#" @@ -616,6 +616,7 @@ mod tests { } #[test] + #[allow(clippy::result_large_err)] fn default_configuration_could_be_overwritten_from_a_toml_config_file() { figment::Jail::expect_with(|jail| { jail.create_file( @@ -649,6 +650,7 @@ mod tests { }); } + #[allow(clippy::result_large_err)] #[test] fn configuration_should_allow_to_overwrite_the_default_tracker_api_token_for_admin_with_an_env_var() { figment::Jail::expect_with(|jail| { diff --git a/packages/configuration/src/v2_0_0/network.rs b/packages/configuration/src/v2_0_0/network.rs index 8e53d419c..7a4668727 100644 --- a/packages/configuration/src/v2_0_0/network.rs +++ b/packages/configuration/src/v2_0_0/network.rs @@ -32,7 +32,7 @@ impl Default for Network { impl Network { #[allow(clippy::unnecessary_wraps)] fn default_external_ip() -> Option { - Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))) + Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)) } fn default_on_reverse_proxy() -> bool { diff --git a/packages/configuration/src/v2_0_0/tracker_api.rs b/packages/configuration/src/v2_0_0/tracker_api.rs index 2da21758b..9433c8c8c 100644 --- a/packages/configuration/src/v2_0_0/tracker_api.rs +++ b/packages/configuration/src/v2_0_0/tracker_api.rs @@ -43,7 +43,7 @@ impl Default for HttpApi { impl HttpApi { fn default_bind_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1212) + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1212) } #[allow(clippy::unnecessary_wraps)] diff --git a/packages/configuration/src/v2_0_0/udp_tracker.rs b/packages/configuration/src/v2_0_0/udp_tracker.rs index b3d420d72..133018e86 100644 --- a/packages/configuration/src/v2_0_0/udp_tracker.rs +++ b/packages/configuration/src/v2_0_0/udp_tracker.rs @@ -1,4 +1,5 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -10,17 +11,36 @@ pub struct UdpTracker { /// system to choose a random port, use port `0`. #[serde(default = "UdpTracker::default_bind_address")] pub bind_address: SocketAddr, + + /// The lifetime of the server-generated connection cookie, that is passed + /// the client as the `ConnectionId`. + #[serde(default = "UdpTracker::default_cookie_lifetime")] + pub cookie_lifetime: Duration, + + /// Weather the tracker should collect statistics about tracker usage. + #[serde(default = "UdpTracker::default_tracker_usage_statistics")] + pub tracker_usage_statistics: bool, } impl Default for UdpTracker { fn default() -> Self { Self { bind_address: Self::default_bind_address(), + cookie_lifetime: Self::default_cookie_lifetime(), + tracker_usage_statistics: Self::default_tracker_usage_statistics(), } } } impl UdpTracker { fn default_bind_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 6969) + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 6969) + } + + fn default_cookie_lifetime() -> Duration { + Duration::from_secs(120) + } + + fn default_tracker_usage_statistics() -> bool { + false } } diff --git a/packages/events/.gitignore b/packages/events/.gitignore new file mode 100644 index 000000000..0b1372e5c --- /dev/null +++ b/packages/events/.gitignore @@ -0,0 +1 @@ +./.coverage diff --git a/packages/events/Cargo.toml b/packages/events/Cargo.toml new file mode 100644 index 000000000..1d183cddb --- /dev/null +++ b/packages/events/Cargo.toml @@ -0,0 +1,22 @@ +[package] +description = "A library with functionality to handle events in Torrust tracker packages." +keywords = ["events", "library", "rust", "torrust", "tracker"] +name = "torrust-tracker-events" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +futures = "0" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } + +[dev-dependencies] +mockall = "0" diff --git a/packages/events/LICENSE b/packages/events/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/events/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 000000000..42a5a2f61 --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1,11 @@ +# Torrust Tracker Events + +A library with functionality to handle events in [Torrust Tracker](https://github.com/torrust/torrust-tracker) packages. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-tracker-events). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/events/src/broadcaster.rs b/packages/events/src/broadcaster.rs new file mode 100644 index 000000000..79c83df8a --- /dev/null +++ b/packages/events/src/broadcaster.rs @@ -0,0 +1,117 @@ +use futures::future::BoxFuture; +use futures::FutureExt; +use tokio::sync::broadcast::{self}; + +use crate::receiver::{Receiver, RecvError}; +use crate::sender::{SendError, Sender}; + +const CHANNEL_CAPACITY: usize = 65536; + +/// An event sender and receiver implementation using a broadcast channel. +#[derive(Clone, Debug)] +pub struct Broadcaster { + pub(crate) sender: broadcast::Sender, +} + +impl Default for Broadcaster { + fn default() -> Self { + let (sender, _receiver) = broadcast::channel(CHANNEL_CAPACITY); + Self { sender } + } +} + +impl Broadcaster { + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} + +impl Sender for Broadcaster { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'_, Option>>> { + async move { Some(self.sender.send(event).map_err(std::convert::Into::into)) }.boxed() + } +} + +impl Receiver for broadcast::Receiver { + type Event = Event; + + fn recv(&mut self) -> BoxFuture<'_, Result> { + async move { self.recv().await.map_err(std::convert::Into::into) }.boxed() + } +} + +impl From> for SendError { + fn from(err: broadcast::error::SendError) -> Self { + SendError(err.0) + } +} + +impl From for RecvError { + fn from(err: broadcast::error::RecvError) -> Self { + match err { + broadcast::error::RecvError::Lagged(amt) => RecvError::Lagged(amt), + broadcast::error::RecvError::Closed => RecvError::Closed, + } + } +} + +#[cfg(test)] +mod tests { + use tokio::time::{timeout, Duration}; + + use super::*; + + #[tokio::test] + async fn it_should_allow_sending_an_event_and_received_it() { + let broadcaster = Broadcaster::::default(); + + let mut receiver = broadcaster.subscribe(); + + let event = "test"; + + let _unused = broadcaster.send(event.to_owned()).await.unwrap().unwrap(); + + let received_event = receiver.recv().await.unwrap(); + + assert_eq!(received_event, event); + } + + #[tokio::test] + async fn it_should_return_the_number_of_receivers_when_and_event_is_sent() { + let broadcaster = Broadcaster::::default(); + let mut _receiver = broadcaster.subscribe(); + + let number_of_receivers = broadcaster.send("test".into()).await; + + assert!(matches!(number_of_receivers, Some(Ok(1)))); + } + + #[tokio::test] + async fn it_should_fail_when_trying_tos_send_with_no_subscribers() { + let event = String::from("test"); + + let broadcaster = Broadcaster::::default(); + + let result: Result> = broadcaster.send(event).await.unwrap(); + + assert!(matches!(result, Err(SendError::(_event)))); + } + + #[tokio::test] + async fn it_should_allow_subscribing_multiple_receivers() { + let broadcaster = Broadcaster::::default(); + let mut r1 = broadcaster.subscribe(); + let mut r2 = broadcaster.subscribe(); + + let _ = broadcaster.send(1).await; + + let val1 = timeout(Duration::from_secs(1), r1.recv()).await.unwrap().unwrap(); + let val2 = timeout(Duration::from_secs(1), r2.recv()).await.unwrap().unwrap(); + + assert_eq!(val1, 1); + assert_eq!(val2, 1); + } +} diff --git a/packages/events/src/bus.rs b/packages/events/src/bus.rs new file mode 100644 index 000000000..b42fb4fc5 --- /dev/null +++ b/packages/events/src/bus.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use crate::broadcaster::Broadcaster; +use crate::{receiver, sender}; + +#[derive(Clone, Debug)] +pub enum SenderStatus { + Enabled, + Disabled, +} + +impl From for SenderStatus { + fn from(enabled: bool) -> Self { + if enabled { + Self::Enabled + } else { + Self::Disabled + } + } +} + +impl From for bool { + fn from(sender_status: SenderStatus) -> Self { + match sender_status { + SenderStatus::Enabled => true, + SenderStatus::Disabled => false, + } + } +} + +#[derive(Clone, Debug)] +pub struct EventBus { + pub sender_status: SenderStatus, + pub broadcaster: Broadcaster, +} + +impl Default for EventBus { + fn default() -> Self { + let sender_status = SenderStatus::Enabled; + let broadcaster = Broadcaster::::default(); + + Self::new(sender_status, broadcaster) + } +} + +impl EventBus { + #[must_use] + pub fn new(sender_status: SenderStatus, broadcaster: Broadcaster) -> Self { + Self { + sender_status, + broadcaster, + } + } + + #[must_use] + pub fn sender(&self) -> Option>> { + match self.sender_status { + SenderStatus::Enabled => Some(Arc::new(self.broadcaster.clone())), + SenderStatus::Disabled => None, + } + } + + #[must_use] + pub fn receiver(&self) -> Box> { + Box::new(self.broadcaster.subscribe()) + } +} + +#[cfg(test)] +mod tests { + use tokio::time::{timeout, Duration}; + + use super::*; + + #[tokio::test] + async fn it_should_provide_an_event_sender_when_enabled() { + let bus = EventBus::::new(SenderStatus::Enabled, Broadcaster::default()); + + assert!(bus.sender().is_some()); + } + + #[tokio::test] + async fn it_should_not_provide_event_sender_when_disabled() { + let bus = EventBus::::new(SenderStatus::Disabled, Broadcaster::default()); + + assert!(bus.sender().is_none()); + } + + #[tokio::test] + async fn it_should_enabled_by_default() { + let bus = EventBus::::default(); + + assert!(bus.sender().is_some()); + } + + #[tokio::test] + async fn it_should_allow_sending_events_that_are_received_by_receivers() { + let bus = EventBus::::default(); + let sender = bus.sender().unwrap(); + let mut receiver = bus.receiver(); + + let event = "hello".to_string(); + + let _unused = sender.send(event.clone()).await.unwrap().unwrap(); + + let result = timeout(Duration::from_secs(1), receiver.recv()).await; + + assert_eq!(result.unwrap().unwrap(), event); + } + + #[tokio::test] + async fn it_should_send_a_closed_events_to_receivers_when_sender_is_dropped() { + let bus = EventBus::::default(); + + let mut receiver = bus.receiver(); + + let future = receiver.recv(); + + drop(bus); // explicitly drop sender + + let result = timeout(Duration::from_secs(1), future).await; + + assert!(matches!(result.unwrap(), Err(crate::receiver::RecvError::Closed))); + } +} diff --git a/packages/events/src/lib.rs b/packages/events/src/lib.rs new file mode 100644 index 000000000..d933b304c --- /dev/null +++ b/packages/events/src/lib.rs @@ -0,0 +1,7 @@ +pub mod broadcaster; +pub mod bus; +pub mod receiver; +pub mod sender; + +/// Target for tracing crate logs. +pub const EVENTS_TARGET: &str = "EVENTS"; diff --git a/packages/events/src/receiver.rs b/packages/events/src/receiver.rs new file mode 100644 index 000000000..15adb816a --- /dev/null +++ b/packages/events/src/receiver.rs @@ -0,0 +1,38 @@ +use std::fmt; + +use futures::future::BoxFuture; +#[cfg(test)] +use mockall::{automock, predicate::str}; + +/// A trait for receiving events. +#[cfg_attr(test, automock(type Event=();))] +pub trait Receiver: Sync + Send { + type Event: Send + Clone; + + fn recv(&mut self) -> BoxFuture<'_, Result>; +} + +/// An error returned from the [`recv`] function on a [`Receiver`]. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum RecvError { + /// There are no more active senders implying no further messages will ever + /// be sent. + Closed, + + /// The receiver lagged too far behind. Attempting to receive again will + /// return the oldest message still retained by the channel. + /// + /// Includes the number of skipped messages. + Lagged(u64), +} + +impl fmt::Display for RecvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RecvError::Closed => write!(f, "channel closed"), + RecvError::Lagged(amt) => write!(f, "channel lagged by {amt}"), + } + } +} + +impl std::error::Error for RecvError {} diff --git a/packages/events/src/sender.rs b/packages/events/src/sender.rs new file mode 100644 index 000000000..3dccade4c --- /dev/null +++ b/packages/events/src/sender.rs @@ -0,0 +1,39 @@ +use std::fmt; +use std::fmt::Debug; + +use futures::future::BoxFuture; +#[cfg(test)] +use mockall::{automock, predicate::str}; + +/// A trait for sending events. +#[cfg_attr(test, automock(type Event=();))] +pub trait Sender: Sync + Send { + type Event: Send + Clone; + + /// Sends an event to all active receivers. + /// + /// Returns a future that resolves to an `Option>>`: + /// + /// - `Some(Ok(n))` — the event was successfully sent to `n` receivers. + /// - `Some(Err(e))` — an error occurred while sending the event. + /// - `None` — the sender is inactive or disconnected, and the event was not sent. + /// + /// The `Option` allows implementations to express cases where sending is not possible + /// (e.g., when the sender is disabled or there are no active receivers). + /// + /// The `usize` typically represents the number of receivers the message was delivered to, + /// but its semantics may vary depending on the concrete implementation. + fn send(&self, event: Self::Event) -> BoxFuture<'_, Option>>>; +} + +/// Error returned by the [`send`] function on a [`Sender`]. +#[derive(Debug)] +pub struct SendError(pub Event); + +impl fmt::Display for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "channel closed") + } +} + +impl std::error::Error for SendError {} diff --git a/packages/events/src/shutdown.rs b/packages/events/src/shutdown.rs new file mode 100644 index 000000000..e69de29bb diff --git a/packages/http-protocol/Cargo.toml b/packages/http-protocol/Cargo.toml new file mode 100644 index 000000000..7803fe78e --- /dev/null +++ b/packages/http-protocol/Cargo.toml @@ -0,0 +1,31 @@ +[package] +description = "A library with the primitive types and functions for the BitTorrent HTTP tracker protocol." +keywords = ["api", "library", "primitives"] +name = "bittorrent-http-tracker-protocol" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +multimap = "0" +percent-encoding = "2" +serde = { version = "1", features = ["derive"] } +serde_bencode = "0" +thiserror = "2" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-contrib-bencode = { version = "3.0.0-develop", path = "../../contrib/bencode" } +torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } diff --git a/packages/http-protocol/LICENSE b/packages/http-protocol/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/http-protocol/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/http-protocol/README.md b/packages/http-protocol/README.md new file mode 100644 index 000000000..5f0a31a78 --- /dev/null +++ b/packages/http-protocol/README.md @@ -0,0 +1,11 @@ +# BitTorrent HTTP Tracker Protocol + +A library with the primitive types and functions used by BitTorrent HTTP trackers. + +## Documentation + +[Crate documentation](https://docs.rs/bittorrent-http-tracker-protocol). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/http-protocol/src/lib.rs b/packages/http-protocol/src/lib.rs new file mode 100644 index 000000000..326a5b182 --- /dev/null +++ b/packages/http-protocol/src/lib.rs @@ -0,0 +1,16 @@ +//! Primitive types and function for `BitTorrent` HTTP trackers. +pub mod percent_encoding; +pub mod v1; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/src/servers/http/percent_encoding.rs b/packages/http-protocol/src/percent_encoding.rs similarity index 89% rename from src/servers/http/percent_encoding.rs rename to packages/http-protocol/src/percent_encoding.rs index c3243d597..e58bf94be 100644 --- a/src/servers/http/percent_encoding.rs +++ b/packages/http-protocol/src/percent_encoding.rs @@ -16,7 +16,7 @@ //! - //! - use aquatic_udp_protocol::PeerId; -use torrust_tracker_primitives::info_hash::{self, InfoHash}; +use bittorrent_primitives::info_hash::{self, InfoHash}; use torrust_tracker_primitives::peer; /// Percent decodes a percent encoded infohash. Internally an @@ -27,8 +27,8 @@ use torrust_tracker_primitives::peer; /// /// ```rust /// use std::str::FromStr; -/// use torrust_tracker::servers::http::percent_encoding::percent_decode_info_hash; -/// use torrust_tracker_primitives::info_hash::InfoHash; +/// use bittorrent_http_tracker_protocol::percent_encoding::percent_decode_info_hash; +/// use bittorrent_primitives::info_hash::InfoHash; /// use torrust_tracker_primitives::peer; /// /// let encoded_infohash = "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"; @@ -60,8 +60,8 @@ pub fn percent_decode_info_hash(raw_info_hash: &str) -> Result }, + + #[error("Cannot extract authentication key param from URL path. Error in {location}")] + CannotExtractKeyParam { location: &'static Location<'static> }, +} diff --git a/packages/http-protocol/src/v1/mod.rs b/packages/http-protocol/src/v1/mod.rs new file mode 100644 index 000000000..6de653e66 --- /dev/null +++ b/packages/http-protocol/src/v1/mod.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod query; +pub mod requests; +pub mod responses; +pub mod services; diff --git a/src/servers/http/v1/query.rs b/packages/http-protocol/src/v1/query.rs similarity index 92% rename from src/servers/http/v1/query.rs rename to packages/http-protocol/src/v1/query.rs index 3a078daae..9f53ef54f 100644 --- a/src/servers/http/v1/query.rs +++ b/packages/http-protocol/src/v1/query.rs @@ -31,7 +31,7 @@ impl Query { /// input `name` exists. For example: /// /// ```rust - /// use torrust_tracker::servers::http::v1::query::Query; + /// use bittorrent_http_tracker_protocol::v1::query::Query; /// /// let raw_query = "param1=value1¶m2=value2"; /// @@ -44,7 +44,7 @@ impl Query { /// It returns only the first param value even if it has multiple values: /// /// ```rust - /// use torrust_tracker::servers::http::v1::query::Query; + /// use bittorrent_http_tracker_protocol::v1::query::Query; /// /// let raw_query = "param1=value1¶m1=value2"; /// @@ -60,7 +60,7 @@ impl Query { /// Returns all the param values as a vector. /// /// ```rust - /// use torrust_tracker::servers::http::v1::query::Query; + /// use bittorrent_http_tracker_protocol::v1::query::Query; /// /// let query = "param1=value1¶m1=value2".parse::().unwrap(); /// @@ -73,7 +73,7 @@ impl Query { /// Returns all the param values as a vector even if it has only one value. /// /// ```rust - /// use torrust_tracker::servers::http::v1::query::Query; + /// use bittorrent_http_tracker_protocol::v1::query::Query; /// /// let query = "param1=value1".parse::().unwrap(); /// @@ -86,7 +86,7 @@ impl Query { self.params.get_vec(name).map(|pairs| { let mut param_values = vec![]; for pair in pairs { - param_values.push(pair.value.to_string()); + param_values.push(pair.value.clone()); } param_values }) @@ -224,7 +224,7 @@ impl std::fmt::Display for FieldValuePairSet { mod tests { mod url_query { - use crate::servers::http::v1::query::Query; + use crate::v1::query::Query; #[test] fn should_parse_the_query_params_from_an_url_query_string() { @@ -249,6 +249,13 @@ mod tests { assert_eq!(query.get_param("param2"), Some("value2".to_string())); } + #[test] + fn should_ignore_duplicate_param_values_when_asked_to_return_only_one_value() { + let query = Query::from(vec![("param1", "value1"), ("param1", "value2")]); + + assert_eq!(query.get_param("param1"), Some("value1".to_string())); + } + #[test] fn should_fail_parsing_an_invalid_query_string() { let invalid_raw_query = "name=value=value"; @@ -277,7 +284,7 @@ mod tests { } mod should_allow_more_than_one_value_for_the_same_param { - use crate::servers::http::v1::query::Query; + use crate::v1::query::Query; #[test] fn instantiated_from_a_vector() { @@ -299,7 +306,7 @@ mod tests { } mod should_be_displayed { - use crate::servers::http::v1::query::Query; + use crate::v1::query::Query; #[test] fn with_one_param() { @@ -320,7 +327,7 @@ mod tests { } mod param_name_value_pair { - use crate::servers::http::v1::query::NameValuePair; + use crate::v1::query::NameValuePair; #[test] fn should_parse_a_single_query_param() { diff --git a/src/servers/http/v1/requests/announce.rs b/packages/http-protocol/src/v1/requests/announce.rs similarity index 87% rename from src/servers/http/v1/requests/announce.rs rename to packages/http-protocol/src/v1/requests/announce.rs index 029bdbc01..a04738749 100644 --- a/src/servers/http/v1/requests/announce.rs +++ b/packages/http-protocol/src/v1/requests/announce.rs @@ -2,18 +2,21 @@ //! //! Data structures and logic for parsing the `announce` request. use std::fmt; +use std::net::{IpAddr, SocketAddr}; use std::panic::Location; use std::str::FromStr; -use aquatic_udp_protocol::{NumberOfBytes, PeerId}; +use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; +use bittorrent_primitives::info_hash::{self, InfoHash}; use thiserror::Error; +use torrust_tracker_clock::clock::Time; use torrust_tracker_located_error::{Located, LocatedError}; -use torrust_tracker_primitives::info_hash::{self, InfoHash}; use torrust_tracker_primitives::peer; -use crate::servers::http::percent_encoding::{percent_decode_info_hash, percent_decode_peer_id}; -use crate::servers::http::v1::query::{ParseQueryError, Query}; -use crate::servers::http::v1::responses; +use crate::percent_encoding::{percent_decode_info_hash, percent_decode_peer_id}; +use crate::v1::query::{ParseQueryError, Query}; +use crate::v1::responses; +use crate::CurrentClock; // Query param names const INFO_HASH: &str = "info_hash"; @@ -31,8 +34,8 @@ const NUMWANT: &str = "numwant"; /// /// ```rust /// use aquatic_udp_protocol::{NumberOfBytes, PeerId}; -/// use torrust_tracker::servers::http::v1::requests::announce::{Announce, Compact, Event}; -/// use torrust_tracker_primitives::info_hash::InfoHash; +/// use bittorrent_http_tracker_protocol::v1::requests::announce::{Announce, Compact, Event}; +/// use bittorrent_primitives::info_hash::InfoHash; /// /// let request = Announce { /// // Mandatory params @@ -142,15 +145,21 @@ pub enum ParseAnnounceQueryError { /// /// Refer to [BEP 03. The `BitTorrent Protocol` Specification](https://www.bittorrent.org/beps/bep_0003.html) /// for more information. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Debug, Clone)] pub enum Event { /// Event sent when a download first begins. Started, + /// Event sent when the downloader cease downloading. Stopped, + /// Event sent when the download is complete. - /// No `completed` is sent if the file was complete when started + /// No `completed` is sent if the file was complete when started. Completed, + + /// It is the same as not being present. If not present, this is one of the + /// announcements done at regular intervals. + Empty, } impl FromStr for Event { @@ -161,6 +170,7 @@ impl FromStr for Event { "started" => Ok(Self::Started), "stopped" => Ok(Self::Stopped), "completed" => Ok(Self::Completed), + "empty" => Ok(Self::Empty), _ => Err(ParseAnnounceQueryError::InvalidParam { param_name: EVENT.to_owned(), param_value: raw_param.to_owned(), @@ -176,6 +186,29 @@ impl fmt::Display for Event { Event::Started => write!(f, "started"), Event::Stopped => write!(f, "stopped"), Event::Completed => write!(f, "completed"), + Event::Empty => write!(f, "empty"), + } + } +} + +impl From for Event { + fn from(event: aquatic_udp_protocol::request::AnnounceEvent) -> Self { + match event { + AnnounceEvent::Started => Self::Started, + AnnounceEvent::Stopped => Self::Stopped, + AnnounceEvent::Completed => Self::Completed, + AnnounceEvent::None => Self::Empty, + } + } +} + +impl From for aquatic_udp_protocol::request::AnnounceEvent { + fn from(event: Event) -> Self { + match event { + Event::Started => Self::Started, + Event::Stopped => Self::Stopped, + Event::Completed => Self::Completed, + Event::Empty => Self::None, } } } @@ -185,8 +218,8 @@ impl fmt::Display for Event { /// Depending on the value of this param, the tracker will return a different /// response: /// -/// - [`Normal`](crate::servers::http::v1::responses::announce::Normal), i.e. a `non-compact` response. -/// - [`Compact`](crate::servers::http::v1::responses::announce::Compact) response. +/// - [`Normal`](crate::v1::responses::announce::Normal), i.e. a `non-compact` response. +/// - [`Compact`](crate::v1::responses::announce::Compact) response. /// /// Refer to [BEP 23. Tracker Returns Compact Peer Lists](https://www.bittorrent.org/beps/bep_0023.html) #[derive(PartialEq, Debug)] @@ -226,7 +259,7 @@ impl FromStr for Compact { impl From for responses::error::Error { fn from(err: ParseQueryError) -> Self { responses::error::Error { - failure_reason: format!("Cannot parse query params: {err}"), + failure_reason: format!("Bad request. Cannot parse query params: {err}"), } } } @@ -234,7 +267,7 @@ impl From for responses::error::Error { impl From for responses::error::Error { fn from(err: ParseAnnounceQueryError) -> Self { responses::error::Error { - failure_reason: format!("Cannot parse query params for announce request: {err}"), + failure_reason: format!("Bad request. Cannot parse query params for announce request: {err}"), } } } @@ -373,16 +406,35 @@ fn extract_numwant(query: &Query) -> Result, ParseAnnounceQueryError } } +/// It builds a `Peer` from the announce request. +/// +/// It ignores the peer address in the announce request params. +#[must_use] +pub fn peer_from_request(announce_request: &Announce, peer_ip: &IpAddr) -> peer::Peer { + peer::Peer { + peer_id: announce_request.peer_id, + peer_addr: SocketAddr::new(*peer_ip, announce_request.port), + updated: CurrentClock::now(), + uploaded: announce_request.uploaded.unwrap_or(NumberOfBytes::new(0)), + downloaded: announce_request.downloaded.unwrap_or(NumberOfBytes::new(0)), + left: announce_request.left.unwrap_or(NumberOfBytes::new(0)), + event: match &announce_request.event { + Some(event) => event.clone().into(), + None => AnnounceEvent::None, + }, + } +} + #[cfg(test)] mod tests { mod announce_request { use aquatic_udp_protocol::{NumberOfBytes, PeerId}; - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; - use crate::servers::http::v1::query::Query; - use crate::servers::http::v1::requests::announce::{ + use crate::v1::query::Query; + use crate::v1::requests::announce::{ Announce, Compact, Event, COMPACT, DOWNLOADED, EVENT, INFO_HASH, LEFT, NUMWANT, PEER_ID, PORT, UPLOADED, }; @@ -402,7 +454,7 @@ mod tests { assert_eq!( announce_request, Announce { - info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), + info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, downloaded: None, @@ -437,7 +489,7 @@ mod tests { assert_eq!( announce_request, Announce { - info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), + info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, downloaded: Some(NumberOfBytes::new(1)), @@ -452,8 +504,8 @@ mod tests { mod when_it_is_instantiated_from_the_url_query_params { - use crate::servers::http::v1::query::Query; - use crate::servers::http::v1::requests::announce::{ + use crate::v1::query::Query; + use crate::v1::requests::announce::{ Announce, COMPACT, DOWNLOADED, EVENT, INFO_HASH, LEFT, NUMWANT, PEER_ID, PORT, UPLOADED, }; diff --git a/packages/http-protocol/src/v1/requests/mod.rs b/packages/http-protocol/src/v1/requests/mod.rs new file mode 100644 index 000000000..d19bd78d3 --- /dev/null +++ b/packages/http-protocol/src/v1/requests/mod.rs @@ -0,0 +1,3 @@ +//! HTTP requests for the HTTP tracker. +pub mod announce; +pub mod scrape; diff --git a/src/servers/http/v1/requests/scrape.rs b/packages/http-protocol/src/v1/requests/scrape.rs similarity index 84% rename from src/servers/http/v1/requests/scrape.rs rename to packages/http-protocol/src/v1/requests/scrape.rs index c61d3be1f..ae8e41cc2 100644 --- a/src/servers/http/v1/requests/scrape.rs +++ b/packages/http-protocol/src/v1/requests/scrape.rs @@ -3,13 +3,13 @@ //! Data structures and logic for parsing the `scrape` request. use std::panic::Location; +use bittorrent_primitives::info_hash::{self, InfoHash}; use thiserror::Error; use torrust_tracker_located_error::{Located, LocatedError}; -use torrust_tracker_primitives::info_hash::{self, InfoHash}; -use crate::servers::http::percent_encoding::percent_decode_info_hash; -use crate::servers::http::v1::query::Query; -use crate::servers::http::v1::responses; +use crate::percent_encoding::percent_decode_info_hash; +use crate::v1::query::Query; +use crate::v1::responses; // Query param names const INFO_HASH: &str = "info_hash"; @@ -39,7 +39,7 @@ pub enum ParseScrapeQueryError { impl From for responses::error::Error { fn from(err: ParseScrapeQueryError) -> Self { responses::error::Error { - failure_reason: format!("Cannot parse query params for scrape request: {err}"), + failure_reason: format!("Bad request. Cannot parse query params for scrape request: {err}"), } } } @@ -84,10 +84,10 @@ mod tests { mod scrape_request { - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; - use crate::servers::http::v1::query::Query; - use crate::servers::http::v1::requests::scrape::{Scrape, INFO_HASH}; + use crate::v1::query::Query; + use crate::v1::requests::scrape::{Scrape, INFO_HASH}; #[test] fn should_be_instantiated_from_the_url_query_with_only_one_infohash() { @@ -107,8 +107,8 @@ mod tests { mod when_it_is_instantiated_from_the_url_query_params { - use crate::servers::http::v1::query::Query; - use crate::servers::http::v1::requests::scrape::{Scrape, INFO_HASH}; + use crate::v1::query::Query; + use crate::v1::requests::scrape::{Scrape, INFO_HASH}; #[test] fn it_should_fail_if_the_query_does_not_include_the_info_hash_param() { diff --git a/src/servers/http/v1/responses/announce.rs b/packages/http-protocol/src/v1/responses/announce.rs similarity index 89% rename from src/servers/http/v1/responses/announce.rs rename to packages/http-protocol/src/v1/responses/announce.rs index f223a4bb0..7175b019a 100644 --- a/src/servers/http/v1/responses/announce.rs +++ b/packages/http-protocol/src/v1/responses/announce.rs @@ -1,18 +1,14 @@ -//! `Announce` response for the HTTP tracker [`announce`](crate::servers::http::v1::requests::announce::Announce) request. +//! `Announce` response for the HTTP tracker [`announce`](crate::v1::requests::announce::Announce) request. //! //! Data structures and logic to build the `announce` response. use std::io::Write; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use axum::http::StatusCode; use derive_more::{AsRef, Constructor, From}; use torrust_tracker_contrib_bencode::{ben_bytes, ben_int, ben_list, ben_map, BMutAccess, BencodeMut}; +use torrust_tracker_primitives::core::AnnounceData; use torrust_tracker_primitives::peer; -use super::Response; -use crate::core::AnnounceData; -use crate::servers::http::v1::responses; - /// An [`Announce`] response, that can be anything that is convertible from [`AnnounceData`]. /// /// The [`Announce`] can built from any data that implements: [`From`] and [`Into>`]. @@ -35,7 +31,7 @@ pub struct Announce where E: From + Into>, { - data: E, + pub data: E, } /// Build any [`Announce`] from an [`AnnounceData`]. @@ -45,24 +41,6 @@ impl + Into>> From for Announce { } } -/// Convert any Announce [`Announce`] into a [`axum::response::Response`] -impl + Into>> axum::response::IntoResponse for Announce -where - Announce: Response, -{ - fn into_response(self) -> axum::response::Response { - axum::response::IntoResponse::into_response(self.body().map(|bytes| (StatusCode::OK, bytes))) - } -} - -/// Implement the [`Response`] for the [`Announce`]. -/// -impl + Into>> Response for Announce { - fn body(self) -> Result, responses::error::Error> { - Ok(self.data.into()) - } -} - /// Format of the [`Normal`] (Non-Compact) Encoding pub struct Normal { complete: i64, @@ -154,7 +132,7 @@ impl Into> for Compact { /// /// ```rust /// use std::net::{IpAddr, Ipv4Addr}; -/// use torrust_tracker::servers::http::v1::responses::announce::{Normal, NormalPeer}; +/// use bittorrent_http_tracker_protocol::v1::responses::announce::{Normal, NormalPeer}; /// /// let peer = NormalPeer { /// peer_id: *b"-qB00000000000000001", @@ -206,7 +184,7 @@ impl From<&NormalPeer> for BencodeMut<'_> { /// /// ```rust /// use std::net::{IpAddr, Ipv4Addr}; -/// use torrust_tracker::servers::http::v1::responses::announce::{Compact, CompactPeer, CompactPeerData}; +/// use bittorrent_http_tracker_protocol::v1::responses::announce::{Compact, CompactPeer, CompactPeerData}; /// /// let peer = CompactPeer::V4(CompactPeerData { /// ip: Ipv4Addr::new(0x69, 0x69, 0x69, 0x69), // 105.105.105.105 @@ -302,11 +280,11 @@ mod tests { use aquatic_udp_protocol::PeerId; use torrust_tracker_configuration::AnnouncePolicy; + use torrust_tracker_primitives::core::AnnounceData; use torrust_tracker_primitives::peer::fixture::PeerBuilder; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - use crate::core::AnnounceData; - use crate::servers::http::v1::responses::announce::{Announce, Compact, Normal, Response}; + use crate::v1::responses::announce::{Announce, Compact, Normal}; // Some ascii values used in tests: // @@ -345,7 +323,7 @@ mod tests { #[test] fn non_compact_announce_response_can_be_bencoded() { let response: Announce = setup_announce_data().into(); - let bytes = response.body().expect("it should encode the response"); + let bytes = response.data.into(); // cspell:disable-next-line let expected_bytes = b"d8:completei333e10:incompletei444e8:intervali111e12:min intervali222e5:peersld2:ip15:105.105.105.1057:peer id20:-qB000000000000000014:porti28784eed2:ip39:6969:6969:6969:6969:6969:6969:6969:69697:peer id20:-qB000000000000000024:porti28784eeee"; @@ -359,7 +337,7 @@ mod tests { #[test] fn compact_announce_response_can_be_bencoded() { let response: Announce = setup_announce_data().into(); - let bytes = response.body().expect("it should encode the response"); + let bytes = response.data.into(); let expected_bytes = // cspell:disable-next-line diff --git a/packages/http-protocol/src/v1/responses/error.rs b/packages/http-protocol/src/v1/responses/error.rs new file mode 100644 index 000000000..2e7a36d0a --- /dev/null +++ b/packages/http-protocol/src/v1/responses/error.rs @@ -0,0 +1,131 @@ +//! `Error` response for the HTTP tracker. +//! +//! Data structures and logic to build the error responses. +//! +//! From the [BEP 03. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html): +//! +//! _"Tracker responses are bencoded dictionaries. If a tracker response has a +//! key failure reason, then that maps to a human readable string which explains +//! why the query failed, and no other keys are required."_ +//! +//! > **NOTICE**: error responses are bencoded and always have a `200 OK` status +//! > code. The official `BitTorrent` specification does not specify the status +//! > code. +use serde::Serialize; + +use crate::v1::auth; +use crate::v1::services::peer_ip_resolver::PeerIpResolutionError; + +/// `Error` response for the HTTP tracker. +#[derive(Serialize, Debug, PartialEq)] +pub struct Error { + /// Human readable string which explains why the request failed. + #[serde(rename = "failure reason")] + pub failure_reason: String, +} + +impl Error { + /// Returns the bencoded representation of the `Error` struct. + /// + /// ```rust + /// use bittorrent_http_tracker_protocol::v1::responses::error::Error; + /// + /// let err = Error { + /// failure_reason: "error message".to_owned(), + /// }; + /// + /// // cspell:disable-next-line + /// assert_eq!(err.write(), "d14:failure reason13:error messagee"); + /// ``` + /// + /// # Panics + /// + /// It would panic if the `Error` struct contained an inappropriate field + /// type. + #[must_use] + pub fn write(&self) -> String { + serde_bencode::to_string(&self).unwrap() + } +} + +impl From for Error { + fn from(err: auth::Error) -> Self { + Self { + failure_reason: format!("Tracker authentication error: {err}"), + } + } +} + +impl From for Error { + fn from(err: PeerIpResolutionError) -> Self { + Self { + failure_reason: format!("Error resolving peer IP: {err}"), + } + } +} + +impl From for Error { + fn from(err: bittorrent_tracker_core::error::AnnounceError) -> Self { + Error { + failure_reason: format!("Tracker announce error: {err}"), + } + } +} + +impl From for Error { + fn from(err: bittorrent_tracker_core::error::ScrapeError) -> Self { + Error { + failure_reason: format!("Tracker scrape error: {err}"), + } + } +} + +impl From for Error { + fn from(err: bittorrent_tracker_core::error::WhitelistError) -> Self { + Error { + failure_reason: format!("Tracker whitelist error: {err}"), + } + } +} + +impl From for Error { + fn from(err: bittorrent_tracker_core::authentication::Error) -> Self { + Error { + failure_reason: format!("Tracker authentication error: {err}"), + } + } +} + +#[cfg(test)] +mod tests { + use std::panic::Location; + + use super::Error; + use crate::v1::responses; + use crate::v1::services::peer_ip_resolver::PeerIpResolutionError; + + #[test] + fn http_tracker_errors_can_be_bencoded() { + let err = Error { + failure_reason: "error message".to_owned(), + }; + + assert_eq!(err.write(), "d14:failure reason13:error messagee"); // cspell:disable-line + } + + fn assert_error_response(error: &responses::error::Error, error_message: &str) { + assert!( + error.failure_reason.contains(error_message), + "Error response does not contain message: '{error_message}'. Error: {error:?}" + ); + } + + #[test] + fn it_should_map_a_peer_ip_resolution_error_into_an_error_response() { + let response = responses::error::Error::from(PeerIpResolutionError::MissingRightMostXForwardedForIp { + location: Location::caller(), + }); + + assert_error_response(&response, "Error resolving peer IP"); + } +} diff --git a/packages/http-protocol/src/v1/responses/mod.rs b/packages/http-protocol/src/v1/responses/mod.rs new file mode 100644 index 000000000..e704d8908 --- /dev/null +++ b/packages/http-protocol/src/v1/responses/mod.rs @@ -0,0 +1,6 @@ +//! HTTP responses for the HTTP tracker. +pub mod announce; +pub mod error; +pub mod scrape; + +pub use announce::{Announce, Compact, Normal}; diff --git a/src/servers/http/v1/responses/scrape.rs b/packages/http-protocol/src/v1/responses/scrape.rs similarity index 83% rename from src/servers/http/v1/responses/scrape.rs rename to packages/http-protocol/src/v1/responses/scrape.rs index 9690d4392..022735abc 100644 --- a/src/servers/http/v1/responses/scrape.rs +++ b/packages/http-protocol/src/v1/responses/scrape.rs @@ -1,21 +1,18 @@ -//! `Scrape` response for the HTTP tracker [`scrape`](crate::servers::http::v1::requests::scrape::Scrape) request. +//! `Scrape` response for the HTTP tracker [`scrape`](crate::v1::requests::scrape::Scrape) request. //! //! Data structures and logic to build the `scrape` response. use std::borrow::Cow; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; use torrust_tracker_contrib_bencode::{ben_int, ben_map, BMutAccess}; - -use crate::core::ScrapeData; +use torrust_tracker_primitives::core::ScrapeData; /// The `Scrape` response for the HTTP tracker. /// /// ```rust -/// use torrust_tracker::servers::http::v1::responses::scrape::Bencoded; -/// use torrust_tracker_primitives::info_hash::InfoHash; +/// use bittorrent_http_tracker_protocol::v1::responses::scrape::Bencoded; +/// use bittorrent_primitives::info_hash::InfoHash; /// use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -/// use torrust_tracker::core::ScrapeData; +/// use torrust_tracker_primitives::core::ScrapeData; /// /// let info_hash = InfoHash::from_bytes(&[0x69; 20]); /// let mut scrape_data = ScrapeData::empty(); @@ -82,21 +79,15 @@ impl From for Bencoded { } } -impl IntoResponse for Bencoded { - fn into_response(self) -> Response { - (StatusCode::OK, self.body()).into_response() - } -} - #[cfg(test)] mod tests { mod scrape_response { - use torrust_tracker_primitives::info_hash::InfoHash; + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::core::ScrapeData; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - use crate::core::ScrapeData; - use crate::servers::http::v1::responses::scrape::Bencoded; + use crate::v1::responses::scrape::Bencoded; fn sample_scrape_data() -> ScrapeData { let info_hash = InfoHash::from_bytes(&[0x69; 20]); diff --git a/packages/http-protocol/src/v1/services/mod.rs b/packages/http-protocol/src/v1/services/mod.rs new file mode 100644 index 000000000..de800f630 --- /dev/null +++ b/packages/http-protocol/src/v1/services/mod.rs @@ -0,0 +1 @@ +pub mod peer_ip_resolver; diff --git a/packages/http-protocol/src/v1/services/peer_ip_resolver.rs b/packages/http-protocol/src/v1/services/peer_ip_resolver.rs new file mode 100644 index 000000000..ceaa7e11c --- /dev/null +++ b/packages/http-protocol/src/v1/services/peer_ip_resolver.rs @@ -0,0 +1,262 @@ +//! This service resolves the remote client address. +//! +//! The peer IP is used to identify the peer in the tracker. It's the peer IP +//! that is used in the `announce` responses (peer list). And it's also used to +//! send statistics events. +//! +//! Given this request chain: +//! +//! ```text +//! client <-> http proxy 1 <-> http proxy 2 <-> server +//! ip: 126.0.0.1 ip: 126.0.0.2 ip: 126.0.0.3 ip: 126.0.0.4 +//! X-Forwarded-For: 126.0.0.1 X-Forwarded-For: 126.0.0.1,126.0.0.2 +//! ``` +//! +//! This `ClientIpSources` contains two options for the peer IP: +//! +//! ```text +//! right_most_x_forwarded_for = 126.0.0.2 +//! connection_info_ip = 126.0.0.3 +//! ``` +//! +//! Which one to use depends on the `ReverseProxyMode`. +use std::net::{IpAddr, SocketAddr}; +use std::panic::Location; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Resolves the client's real address considering proxy headers. Port is also +/// included when available. +/// +/// # Errors +/// +/// This function returns an error if the IP address cannot be resolved. +pub fn resolve_remote_client_addr( + reverse_proxy_mode: &ReverseProxyMode, + client_ip_sources: &ClientIpSources, +) -> Result { + let ip = match reverse_proxy_mode { + ReverseProxyMode::Enabled => ResolvedIp::FromXForwardedFor(client_ip_sources.try_client_ip_from_proxy_header()?), + ReverseProxyMode::Disabled => ResolvedIp::FromSocketAddr(client_ip_sources.try_client_ip_from_connection_info()?), + }; + + let port = client_ip_sources.client_port_from_connection_info(); + + Ok(RemoteClientAddr::new(ip, port)) +} + +/// This struct indicates whether the tracker is running on reverse proxy mode. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ReverseProxyMode { + Enabled, + Disabled, +} + +impl From for bool { + fn from(reverse_proxy_mode: ReverseProxyMode) -> Self { + match reverse_proxy_mode { + ReverseProxyMode::Enabled => true, + ReverseProxyMode::Disabled => false, + } + } +} + +impl From for ReverseProxyMode { + fn from(reverse_proxy_mode: bool) -> Self { + if reverse_proxy_mode { + ReverseProxyMode::Enabled + } else { + ReverseProxyMode::Disabled + } + } +} +/// This struct contains the sources from which the peer IP can be obtained. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub struct ClientIpSources { + /// The right most IP from the `X-Forwarded-For` HTTP header. + pub right_most_x_forwarded_for: Option, + + /// The client's socket address from the connection info. + pub connection_info_socket_address: Option, +} + +impl ClientIpSources { + fn try_client_ip_from_connection_info(&self) -> Result { + if let Some(socket_addr) = self.connection_info_socket_address { + Ok(socket_addr.ip()) + } else { + Err(PeerIpResolutionError::MissingClientIp { + location: Location::caller(), + }) + } + } + + fn try_client_ip_from_proxy_header(&self) -> Result { + if let Some(ip) = self.right_most_x_forwarded_for { + Ok(ip) + } else { + Err(PeerIpResolutionError::MissingRightMostXForwardedForIp { + location: Location::caller(), + }) + } + } + + fn client_port_from_connection_info(&self) -> Option { + if self.connection_info_socket_address.is_some() { + self.connection_info_socket_address.map(|socket_addr| socket_addr.port()) + } else { + None + } + } +} + +/// The error that can occur when resolving the peer IP. +#[derive(Error, Debug, Clone)] +pub enum PeerIpResolutionError { + /// The peer IP cannot be obtained because the tracker is configured as a + /// reverse proxy but the `X-Forwarded-For` HTTP header is missing or + /// invalid. + #[error( + "missing or invalid the right most X-Forwarded-For IP (mandatory on reverse proxy tracker configuration) in {location}" + )] + MissingRightMostXForwardedForIp { location: &'static Location<'static> }, + + /// The peer IP cannot be obtained because the tracker is not configured as + /// a reverse proxy but the connection info was not provided to the Axum + /// framework via a route extension. + #[error("cannot get the client IP from the connection info in {location}")] + MissingClientIp { location: &'static Location<'static> }, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +pub struct RemoteClientAddr { + ip: ResolvedIp, + port: Option, +} + +impl RemoteClientAddr { + #[must_use] + pub fn new(ip: ResolvedIp, port: Option) -> Self { + Self { ip, port } + } + + #[must_use] + pub fn ip(&self) -> IpAddr { + match self.ip { + ResolvedIp::FromSocketAddr(ip) | ResolvedIp::FromXForwardedFor(ip) => ip, + } + } + + #[must_use] + pub fn port(&self) -> Option { + self.port + } +} + +/// This enum indicates the source of the resolved IP address. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ResolvedIp { + FromXForwardedFor(IpAddr), + FromSocketAddr(IpAddr), +} + +#[cfg(test)] +mod tests { + use super::resolve_remote_client_addr; + + mod working_without_reverse_proxy { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + use super::resolve_remote_client_addr; + use crate::v1::services::peer_ip_resolver::{ + ClientIpSources, PeerIpResolutionError, RemoteClientAddr, ResolvedIp, ReverseProxyMode, + }; + + #[test] + fn it_should_get_the_remote_client_address_from_the_connection_info() { + let reverse_proxy_mode = ReverseProxyMode::Disabled; + + let ip = resolve_remote_client_addr( + &reverse_proxy_mode, + &ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080)), + }, + ) + .unwrap(); + + assert_eq!( + ip, + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::from_str("203.0.113.195").unwrap()), + Some(8080) + ) + ); + } + + #[test] + fn it_should_return_an_error_if_it_cannot_get_the_remote_client_ip_from_the_connection_info() { + let reverse_proxy_mode = ReverseProxyMode::Disabled; + + let error = resolve_remote_client_addr( + &reverse_proxy_mode, + &ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }, + ) + .unwrap_err(); + + assert!(matches!(error, PeerIpResolutionError::MissingClientIp { .. })); + } + } + + mod working_on_reverse_proxy_mode { + use std::net::IpAddr; + use std::str::FromStr; + + use crate::v1::services::peer_ip_resolver::{ + resolve_remote_client_addr, ClientIpSources, PeerIpResolutionError, RemoteClientAddr, ResolvedIp, ReverseProxyMode, + }; + + #[test] + fn it_should_get_the_remote_client_ip_from_the_right_most_ip_in_the_x_forwarded_for_header() { + let reverse_proxy_mode = ReverseProxyMode::Enabled; + + let ip = resolve_remote_client_addr( + &reverse_proxy_mode, + &ClientIpSources { + right_most_x_forwarded_for: Some(IpAddr::from_str("203.0.113.195").unwrap()), + connection_info_socket_address: None, + }, + ) + .unwrap(); + + assert_eq!( + ip, + RemoteClientAddr::new( + ResolvedIp::FromXForwardedFor(IpAddr::from_str("203.0.113.195").unwrap()), + None + ) + ); + } + + #[test] + fn it_should_return_an_error_if_it_cannot_get_the_right_most_ip_from_the_x_forwarded_for_header() { + let reverse_proxy_mode = ReverseProxyMode::Enabled; + + let error = resolve_remote_client_addr( + &reverse_proxy_mode, + &ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: None, + }, + ) + .unwrap_err(); + + assert!(matches!(error, PeerIpResolutionError::MissingRightMostXForwardedForIp { .. })); + } + } +} diff --git a/packages/http-tracker-core/Cargo.toml b/packages/http-tracker-core/Cargo.toml new file mode 100644 index 000000000..04a6c96b6 --- /dev/null +++ b/packages/http-tracker-core/Cargo.toml @@ -0,0 +1,43 @@ +[package] +authors.workspace = true +description = "A library with the core functionality needed to implement a BitTorrent HTTP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["api", "bittorrent", "core", "library", "tracker"] +license.workspace = true +name = "bittorrent-http-tracker-core" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-http-tracker-protocol = { version = "3.0.0-develop", path = "../http-protocol" } +bittorrent-primitives = "0.1.0" +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +criterion = { version = "0.5.1", features = ["async_tokio"] } +futures = "0" +serde = "1.0.219" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +tracing = "0" + +[dev-dependencies] +formatjson = "0.3.1" +mockall = "0" +serde_json = "1.0.140" +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } + +[[bench]] +harness = false +name = "http_tracker_core_benchmark" diff --git a/packages/http-tracker-core/LICENSE b/packages/http-tracker-core/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/http-tracker-core/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/http-tracker-core/README.md b/packages/http-tracker-core/README.md new file mode 100644 index 000000000..0dd915c24 --- /dev/null +++ b/packages/http-tracker-core/README.md @@ -0,0 +1,15 @@ +# BitTorrent HTTP Tracker Core library + +A library with the core functionality needed to implement a BitTorrent HTTP tracker. + +You usually don’t need to use this library directly. Instead, you should use the [Torrust Tracker](https://github.com/torrust/torrust-tracker). If you want to build your own tracker, you can use this library as the core functionality. + +> **Disclaimer**: This library is actively under development. We’re currently extracting and refining common types from the[Torrust Tracker](https://github.com/torrust/torrust-tracker) to make them available to the BitTorrent community in Rust. While these types are functional, they are not yet ready for use in production or third-party projects. + +## Documentation + +[Crate documentation](https://docs.rs/bittorrent-http-tracker-core). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/http-tracker-core/benches/helpers/mod.rs b/packages/http-tracker-core/benches/helpers/mod.rs new file mode 100644 index 000000000..4a91f2224 --- /dev/null +++ b/packages/http-tracker-core/benches/helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod sync; +pub mod util; diff --git a/packages/http-tracker-core/benches/helpers/sync.rs b/packages/http-tracker-core/benches/helpers/sync.rs new file mode 100644 index 000000000..dbf0dac83 --- /dev/null +++ b/packages/http-tracker-core/benches/helpers/sync.rs @@ -0,0 +1,38 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::{Duration, Instant}; + +use bittorrent_http_tracker_core::services::announce::AnnounceService; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + +use crate::helpers::util::{initialize_core_tracker_services, sample_announce_request_for_peer, sample_peer}; + +#[must_use] +pub async fn return_announce_data_once(samples: u64) -> Duration { + let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services(); + + let peer = sample_peer(); + + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + + let announce_service = AnnounceService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_tracker_services.whitelist_authorization.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let start = Instant::now(); + + for _ in 0..samples { + let _announce_data = announce_service + .handle_announce(&announce_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + + start.elapsed() +} diff --git a/packages/http-tracker-core/benches/helpers/util.rs b/packages/http-tracker-core/benches/helpers/util.rs new file mode 100644 index 000000000..028d7c535 --- /dev/null +++ b/packages/http-tracker-core/benches/helpers/util.rs @@ -0,0 +1,135 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; + +use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; +use bittorrent_http_tracker_core::event::bus::EventBus; +use bittorrent_http_tracker_core::event::sender::Broadcaster; +use bittorrent_http_tracker_core::event::Event; +use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; +use bittorrent_http_tracker_core::statistics::repository::Repository; +use bittorrent_http_tracker_protocol::v1::requests::announce::Announce; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::announce_handler::AnnounceHandler; +use bittorrent_tracker_core::authentication::key::repository::in_memory::InMemoryKeyRepository; +use bittorrent_tracker_core::authentication::service::AuthenticationService; +use bittorrent_tracker_core::databases::setup::initialize_database; +use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; +use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; +use futures::future::BoxFuture; +use mockall::mock; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::{Configuration, Core}; +use torrust_tracker_events::sender::SendError; +use torrust_tracker_primitives::peer::Peer; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; +use torrust_tracker_test_helpers::configuration; + +pub struct CoreTrackerServices { + pub core_config: Arc, + pub announce_handler: Arc, + pub authentication_service: Arc, + pub whitelist_authorization: Arc, +} + +pub struct CoreHttpTrackerServices { + pub http_stats_event_sender: bittorrent_http_tracker_core::event::sender::Sender, +} + +pub fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services_with_config(&configuration::ephemeral_public()) +} + +pub fn initialize_core_tracker_services_with_config(config: &Configuration) -> (CoreTrackerServices, CoreHttpTrackerServices) { + let cancellation_token = CancellationToken::new(); + + let core_config = Arc::new(config.core.clone()); + let database = initialize_database(&config.core); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(&core_config, &in_memory_key_repository)); + + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + config.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + if config.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + ( + CoreTrackerServices { + core_config, + announce_handler, + authentication_service, + whitelist_authorization, + }, + CoreHttpTrackerServices { http_stats_event_sender }, + ) +} + +pub fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Started, + } +} + +pub fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSources) { + let announce_request = Announce { + info_hash: sample_info_hash(), + peer_id: peer.peer_id, + port: peer.peer_addr.port(), + uploaded: Some(peer.uploaded), + downloaded: Some(peer.downloaded), + left: Some(peer.left), + event: Some(peer.event.into()), + compact: None, + numwant: None, + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip(), 8080)), + }; + + (announce_request, client_ip_sources) +} +#[must_use] +pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") +} + +mock! { + HttpStatsEventSender {} + impl torrust_tracker_events::sender::Sender for HttpStatsEventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } +} diff --git a/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs b/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs new file mode 100644 index 000000000..aa50ceeb9 --- /dev/null +++ b/packages/http-tracker-core/benches/http_tracker_core_benchmark.rs @@ -0,0 +1,23 @@ +mod helpers; + +use std::time::Duration; + +use criterion::{criterion_group, criterion_main, Criterion}; + +use crate::helpers::sync; + +fn announce_once(c: &mut Criterion) { + let _rt = tokio::runtime::Builder::new_multi_thread().worker_threads(4).build().unwrap(); + + let mut group = c.benchmark_group("http_tracker_handle_announce_once"); + + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_millis(1000)); + + group.bench_function("handle_announce_data", |b| { + b.iter(|| sync::return_announce_data_once(100)); + }); +} + +criterion_group!(benches, announce_once); +criterion_main!(benches); diff --git a/packages/http-tracker-core/src/container.rs b/packages/http-tracker-core/src/container.rs new file mode 100644 index 000000000..ed0aaf8b0 --- /dev/null +++ b/packages/http-tracker-core/src/container.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use bittorrent_tracker_core::container::TrackerCoreContainer; +use torrust_tracker_configuration::{Core, HttpTracker}; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +use crate::event::bus::EventBus; +use crate::event::sender::Broadcaster; +use crate::services::announce::AnnounceService; +use crate::services::scrape::ScrapeService; +use crate::statistics::repository::Repository; +use crate::{event, services, statistics}; + +pub struct HttpTrackerCoreContainer { + pub http_tracker_config: Arc, + + pub tracker_core_container: Arc, + + // `HttpTrackerCoreServices` + pub event_bus: Arc, + pub stats_event_sender: event::sender::Sender, + pub stats_repository: Arc, + pub announce_service: Arc, + pub scrape_service: Arc, +} + +impl HttpTrackerCoreContainer { + #[must_use] + pub fn initialize(core_config: &Arc, http_tracker_config: &Arc) -> Arc { + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + core_config, + &swarm_coordination_registry_container, + )); + + Self::initialize_from_tracker_core(&tracker_core_container, http_tracker_config) + } + + #[must_use] + pub fn initialize_from_tracker_core( + tracker_core_container: &Arc, + http_tracker_config: &Arc, + ) -> Arc { + let http_tracker_core_services = HttpTrackerCoreServices::initialize_from(tracker_core_container); + + Self::initialize_from_services(tracker_core_container, &http_tracker_core_services, http_tracker_config) + } + + #[must_use] + pub fn initialize_from_services( + tracker_core_container: &Arc, + http_tracker_core_services: &Arc, + http_tracker_config: &Arc, + ) -> Arc { + Arc::new(Self { + tracker_core_container: tracker_core_container.clone(), + http_tracker_config: http_tracker_config.clone(), + event_bus: http_tracker_core_services.event_bus.clone(), + stats_event_sender: http_tracker_core_services.stats_event_sender.clone(), + stats_repository: http_tracker_core_services.stats_repository.clone(), + announce_service: http_tracker_core_services.announce_service.clone(), + scrape_service: http_tracker_core_services.scrape_service.clone(), + }) + } +} + +pub struct HttpTrackerCoreServices { + pub event_bus: Arc, + pub stats_event_sender: event::sender::Sender, + pub stats_repository: Arc, + pub announce_service: Arc, + pub scrape_service: Arc, +} + +impl HttpTrackerCoreServices { + #[must_use] + pub fn initialize_from(tracker_core_container: &Arc) -> Arc { + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + tracker_core_container.core_config.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + let http_announce_service = Arc::new(AnnounceService::new( + tracker_core_container.core_config.clone(), + tracker_core_container.announce_handler.clone(), + tracker_core_container.authentication_service.clone(), + tracker_core_container.whitelist_authorization.clone(), + http_stats_event_sender.clone(), + )); + + let http_scrape_service = Arc::new(ScrapeService::new( + tracker_core_container.core_config.clone(), + tracker_core_container.scrape_handler.clone(), + tracker_core_container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + Arc::new(Self { + event_bus: http_stats_event_bus, + stats_event_sender: http_stats_event_sender, + stats_repository: http_stats_repository, + announce_service: http_announce_service, + scrape_service: http_scrape_service, + }) + } +} diff --git a/packages/http-tracker-core/src/event.rs b/packages/http-tracker-core/src/event.rs new file mode 100644 index 000000000..2a4734bfd --- /dev/null +++ b/packages/http-tracker-core/src/event.rs @@ -0,0 +1,206 @@ +use std::net::{IpAddr, SocketAddr}; + +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::RemoteClientAddr; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::label_name; +use torrust_tracker_primitives::peer::PeerAnnouncement; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +/// A HTTP core event. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Event { + TcpAnnounce { + connection: ConnectionContext, + info_hash: InfoHash, + announcement: PeerAnnouncement, + }, + TcpScrape { + connection: ConnectionContext, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ConnectionContext { + client: ClientConnectionContext, + server: ServerConnectionContext, +} + +impl ConnectionContext { + #[must_use] + pub fn new(remote_client_addr: RemoteClientAddr, server_service_binding: ServiceBinding) -> Self { + Self { + client: ClientConnectionContext { remote_client_addr }, + server: ServerConnectionContext { + service_binding: server_service_binding, + }, + } + } + + #[must_use] + pub fn client_ip_addr(&self) -> IpAddr { + self.client.ip_addr() + } + + #[must_use] + pub fn client_port(&self) -> Option { + self.client.port() + } + + #[must_use] + pub fn server_socket_addr(&self) -> SocketAddr { + self.server.service_binding.bind_address() + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ClientConnectionContext { + remote_client_addr: RemoteClientAddr, +} + +impl ClientConnectionContext { + #[must_use] + pub fn ip_addr(&self) -> IpAddr { + self.remote_client_addr.ip() + } + + #[must_use] + pub fn port(&self) -> Option { + self.remote_client_addr.port() + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ServerConnectionContext { + service_binding: ServiceBinding, +} + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + label_name!("server_binding_protocol"), + LabelValue::new(&connection_context.server.service_binding.protocol().to_string()), + ), + ( + label_name!("server_binding_ip"), + LabelValue::new(&connection_context.server.service_binding.bind_address().ip().to_string()), + ), + ( + label_name!("server_binding_address_ip_type"), + LabelValue::new(&connection_context.server.service_binding.bind_address_ip_type().to_string()), + ), + ( + label_name!("server_binding_address_ip_family"), + LabelValue::new(&connection_context.server.service_binding.bind_address_ip_family().to_string()), + ), + ( + label_name!("server_binding_port"), + LabelValue::new(&connection_context.server.service_binding.bind_address().port().to_string()), + ), + ]) + } +} + +pub mod sender { + use std::sync::Arc; + + use super::Event; + + pub type Sender = Option>>; + pub type Broadcaster = torrust_tracker_events::broadcaster::Broadcaster; +} + +pub mod receiver { + use super::Event; + + pub type Receiver = Box>; +} + +pub mod bus { + use crate::event::Event; + + pub type EventBus = torrust_tracker_events::bus::EventBus; +} + +#[cfg(test)] +pub mod test { + + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{RemoteClientAddr, ResolvedIp}; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::service_binding::Protocol; + + use super::Event; + use crate::tests::sample_info_hash; + + #[must_use] + pub fn announce_events_match(event: &Event, expected_event: &Event) -> bool { + match (event, expected_event) { + ( + Event::TcpAnnounce { + connection, + info_hash, + announcement, + }, + Event::TcpAnnounce { + connection: expected_connection, + info_hash: expected_info_hash, + announcement: expected_announcement, + }, + ) => { + *connection == *expected_connection + && *info_hash == *expected_info_hash + && announcement.peer_id == expected_announcement.peer_id + && announcement.peer_addr == expected_announcement.peer_addr + // Events can't be compared due to the `updated` field. + // The `announcement.uploaded` contains the current time + // when the test is executed. + // todo: mock time + //&& announcement.updated == expected_announcement.updated + && announcement.uploaded == expected_announcement.uploaded + && announcement.downloaded == expected_announcement.downloaded + && announcement.left == expected_announcement.left + && announcement.event == expected_announcement.event + } + _ => false, + } + } + + #[test] + fn events_should_be_comparable() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use torrust_tracker_primitives::service_binding::ServiceBinding; + + use crate::event::{ConnectionContext, Event}; + + let remote_client_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let info_hash = sample_info_hash(); + + let event1 = Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + info_hash, + announcement: Peer::default(), + }; + + let event2 = Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))), + Some(8080), + ), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + info_hash, + announcement: Peer::default(), + }; + + let event1_clone = event1.clone(); + + assert!(event1 == event1_clone); + assert!(event1 != event2); + } +} diff --git a/packages/http-tracker-core/src/lib.rs b/packages/http-tracker-core/src/lib.rs new file mode 100644 index 000000000..1692a68fa --- /dev/null +++ b/packages/http-tracker-core/src/lib.rs @@ -0,0 +1,63 @@ +pub mod container; +pub mod event; +pub mod services; +pub mod statistics; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + +pub const HTTP_TRACKER_LOG_TARGET: &str = "HTTP TRACKER"; + +#[cfg(test)] +pub(crate) mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + pub fn sample_peer_using_ipv4() -> peer::Peer { + sample_peer() + } + + pub fn sample_peer_using_ipv6() -> peer::Peer { + let mut peer = sample_peer(); + peer.peer_addr = SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), + 8080, + ); + peer + } + + pub fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Started, + } + } +} diff --git a/packages/http-tracker-core/src/services/announce.rs b/packages/http-tracker-core/src/services/announce.rs new file mode 100644 index 000000000..766f08c12 --- /dev/null +++ b/packages/http-tracker-core/src/services/announce.rs @@ -0,0 +1,557 @@ +//! The `announce` service. +//! +//! The service is responsible for handling the `announce` requests. +//! +//! It delegates the `announce` logic to the [`AnnounceHandler`] and it returns +//! the [`AnnounceData`]. +//! +//! It also sends an [`http_tracker_core::event::Event`] +//! because events are specific for the HTTP tracker. +use std::panic::Location; +use std::sync::Arc; + +use bittorrent_http_tracker_protocol::v1::requests::announce::{peer_from_request, Announce}; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{ + resolve_remote_client_addr, ClientIpSources, PeerIpResolutionError, RemoteClientAddr, +}; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::announce_handler::{AnnounceHandler, PeersWanted}; +use bittorrent_tracker_core::authentication::service::AuthenticationService; +use bittorrent_tracker_core::authentication::{self, Key}; +use bittorrent_tracker_core::error::{AnnounceError, TrackerCoreError, WhitelistError}; +use bittorrent_tracker_core::whitelist; +use torrust_tracker_configuration::Core; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::peer::PeerAnnouncement; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::event; +use crate::event::Event; + +/// The HTTP tracker `announce` service. +/// +/// The service sends an statistics event that increments: +/// +/// - The number of TCP `announce` requests handled by the HTTP tracker. +/// - The number of TCP `scrape` requests handled by the HTTP tracker. +pub struct AnnounceService { + core_config: Arc, + announce_handler: Arc, + authentication_service: Arc, + whitelist_authorization: Arc, + opt_http_stats_event_sender: event::sender::Sender, +} + +impl AnnounceService { + #[must_use] + pub fn new( + core_config: Arc, + announce_handler: Arc, + authentication_service: Arc, + whitelist_authorization: Arc, + opt_http_stats_event_sender: event::sender::Sender, + ) -> Self { + Self { + core_config, + announce_handler, + authentication_service, + whitelist_authorization, + opt_http_stats_event_sender, + } + } + + /// Handles an announce request. + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// - The tracker is running in `listed` mode and the torrent is not whitelisted. + /// - There is an error when resolving the client IP address. + pub async fn handle_announce( + &self, + announce_request: &Announce, + client_ip_sources: &ClientIpSources, + server_service_binding: &ServiceBinding, + maybe_key: Option, + ) -> Result { + self.authenticate(maybe_key).await?; + + self.authorize(announce_request.info_hash).await?; + + let remote_client_addr = resolve_remote_client_addr(&self.core_config.net.on_reverse_proxy.into(), client_ip_sources)?; + + let mut peer = peer_from_request(announce_request, &remote_client_addr.ip()); + + let peers_wanted = Self::peers_wanted(announce_request); + + let announce_data = self + .announce_handler + .handle_announcement( + &announce_request.info_hash, + &mut peer, + &remote_client_addr.ip(), + &peers_wanted, + ) + .await?; + + self.send_event( + announce_request.info_hash, + remote_client_addr, + server_service_binding.clone(), + peer, + ) + .await; + + Ok(announce_data) + } + + async fn authenticate(&self, maybe_key: Option) -> Result<(), authentication::key::Error> { + if self.core_config.private { + let key = maybe_key.ok_or(authentication::key::Error::MissingAuthKey { + location: Location::caller(), + })?; + + self.authentication_service.authenticate(&key).await?; + } + + Ok(()) + } + + async fn authorize(&self, info_hash: InfoHash) -> Result<(), WhitelistError> { + self.whitelist_authorization.authorize(&info_hash).await + } + + /// Determines how many peers the client wants in the response + fn peers_wanted(announce_request: &Announce) -> PeersWanted { + match announce_request.numwant { + Some(numwant) => PeersWanted::only(numwant), + None => PeersWanted::AsManyAsPossible, + } + } + + async fn send_event( + &self, + info_hash: InfoHash, + remote_client_addr: RemoteClientAddr, + server_service_binding: ServiceBinding, + announcement: PeerAnnouncement, + ) { + if let Some(http_stats_event_sender) = self.opt_http_stats_event_sender.as_deref() { + let event = Event::TcpAnnounce { + connection: event::ConnectionContext::new(remote_client_addr, server_service_binding), + info_hash, + announcement, + }; + + tracing::debug!("Sending TcpAnnounce event: {:?}", event); + + http_stats_event_sender.send(event).await; + } + } +} + +/// Errors related to announce requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum HttpAnnounceError { + #[error("Error resolving peer IP: {source}")] + PeerIpResolutionError { source: PeerIpResolutionError }, + + #[error("Tracker core error: {source}")] + TrackerCoreError { source: TrackerCoreError }, +} + +impl From for HttpAnnounceError { + fn from(peer_ip_resolution_error: PeerIpResolutionError) -> Self { + Self::PeerIpResolutionError { + source: peer_ip_resolution_error, + } + } +} + +impl From for HttpAnnounceError { + fn from(tracker_core_error: TrackerCoreError) -> Self { + Self::TrackerCoreError { + source: tracker_core_error, + } + } +} + +impl From for HttpAnnounceError { + fn from(announce_error: AnnounceError) -> Self { + Self::TrackerCoreError { + source: announce_error.into(), + } + } +} + +impl From for HttpAnnounceError { + fn from(whitelist_error: WhitelistError) -> Self { + Self::TrackerCoreError { + source: whitelist_error.into(), + } + } +} + +impl From for HttpAnnounceError { + fn from(whitelist_error: authentication::key::Error) -> Self { + Self::TrackerCoreError { + source: whitelist_error.into(), + } + } +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + use std::sync::Arc; + + use bittorrent_http_tracker_protocol::v1::requests::announce::Announce; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::ClientIpSources; + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::authentication::key::repository::in_memory::InMemoryKeyRepository; + use bittorrent_tracker_core::authentication::service::AuthenticationService; + use bittorrent_tracker_core::databases::setup::initialize_database; + use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use tokio_util::sync::CancellationToken; + use torrust_tracker_configuration::{Configuration, Core}; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_test_helpers::configuration; + + struct CoreTrackerServices { + pub core_config: Arc, + pub announce_handler: Arc, + pub authentication_service: Arc, + pub whitelist_authorization: Arc, + } + + struct CoreHttpTrackerServices { + pub http_stats_event_sender: crate::event::sender::Sender, + } + + fn initialize_core_tracker_services() -> (CoreTrackerServices, CoreHttpTrackerServices) { + initialize_core_tracker_services_with_config(&configuration::ephemeral_public()) + } + + fn initialize_core_tracker_services_with_config(config: &Configuration) -> (CoreTrackerServices, CoreHttpTrackerServices) { + let cancellation_token = CancellationToken::new(); + + let core_config = Arc::new(config.core.clone()); + let database = initialize_database(&config.core); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(&core_config, &in_memory_key_repository)); + + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + config.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + if config.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + ( + CoreTrackerServices { + core_config, + announce_handler, + authentication_service, + whitelist_authorization, + }, + CoreHttpTrackerServices { http_stats_event_sender }, + ) + } + + fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSources) { + let announce_request = Announce { + info_hash: sample_info_hash(), + peer_id: peer.peer_id, + port: peer.peer_addr.port(), + uploaded: Some(peer.uploaded), + downloaded: Some(peer.downloaded), + left: Some(peer.left), + event: Some(peer.event.into()), + compact: None, + numwant: None, + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip(), 8080)), + }; + + (announce_request, client_ip_sources) + } + + use futures::future::BoxFuture; + use mockall::mock; + use torrust_tracker_events::sender::SendError; + + use crate::event::bus::EventBus; + use crate::event::sender::Broadcaster; + use crate::event::Event; + use crate::statistics::event::listener::run_event_listener; + use crate::statistics::repository::Repository; + use crate::tests::sample_info_hash; + + mock! { + HttpStatsEventSender {} + impl torrust_tracker_events::sender::Sender for HttpStatsEventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } + } + + mod with_tracker_in_any_mode { + use std::future; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{RemoteClientAddr, ResolvedIp}; + use mockall::predicate::{self}; + use torrust_tracker_configuration::Configuration; + use torrust_tracker_primitives::core::AnnounceData; + use torrust_tracker_primitives::peer; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + use torrust_tracker_test_helpers::configuration; + + use crate::event::test::announce_events_match; + use crate::event::{ConnectionContext, Event}; + use crate::services::announce::tests::{ + initialize_core_tracker_services, initialize_core_tracker_services_with_config, sample_announce_request_for_peer, + MockHttpStatsEventSender, + }; + use crate::services::announce::AnnounceService; + use crate::tests::{sample_info_hash, sample_peer, sample_peer_using_ipv4, sample_peer_using_ipv6}; + + #[tokio::test] + async fn it_should_return_the_announce_data() { + let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services(); + + let peer = sample_peer(); + + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let announce_service = AnnounceService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_tracker_services.whitelist_authorization.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let announce_data = announce_service + .handle_announce(&announce_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + + let expected_announce_data = AnnounceData { + peers: vec![], + stats: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0, + }, + policy: core_tracker_services.core_config.announce_policy, + }; + + assert_eq!(announce_data, expected_announce_data); + } + + #[tokio::test] + async fn it_should_send_the_tcp_4_announce_event_when_the_peer_uses_ipv4() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + let peer = sample_peer_using_ipv4(); + let remote_client_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); + + let server_service_binding_clone = server_service_binding.clone(); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(predicate::function(move |event| { + let mut announcement = peer; + announcement.peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); + + let expected_event = Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + server_service_binding.clone(), + ), + info_hash: sample_info_hash(), + announcement, + }; + + announce_events_match(event, &expected_event) + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services(); + + core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; + + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + + let announce_service = AnnounceService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_tracker_services.whitelist_authorization.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let _announce_data = announce_service + .handle_announce(&announce_request, &client_ip_sources, &server_service_binding_clone, None) + .await + .unwrap(); + } + + fn tracker_with_an_ipv6_external_ip() -> Configuration { + let mut configuration = configuration::ephemeral(); + configuration.core.net.external_ip = Some(IpAddr::V6(Ipv6Addr::new( + 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, + ))); + configuration + } + + fn peer_with_the_ipv4_loopback_ip() -> peer::Peer { + let loopback_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut peer = sample_peer(); + peer.peer_addr = SocketAddr::new(loopback_ip, 8080); + peer + } + + #[tokio::test] + async fn it_should_send_the_tcp_4_announce_event_when_the_peer_uses_ipv4_even_if_the_tracker_changes_the_peer_ip_to_ipv6() + { + // Tracker changes the peer IP to the tracker external IP when the peer is using the loopback IP. + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + let peer = peer_with_the_ipv4_loopback_ip(); + let remote_client_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + + let server_service_binding_clone = server_service_binding.clone(); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(predicate::function(move |event| { + let mut peer_announcement = peer; + peer_announcement.peer_addr = SocketAddr::new( + IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), + 8080, + ); + + let expected_event = Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + server_service_binding.clone(), + ), + info_hash: sample_info_hash(), + announcement: peer_announcement, + }; + + announce_events_match(event, &expected_event) + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let (core_tracker_services, mut core_http_tracker_services) = + initialize_core_tracker_services_with_config(&tracker_with_an_ipv6_external_ip()); + + core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; + + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + + let announce_service = AnnounceService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_tracker_services.whitelist_authorization.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let _announce_data = announce_service + .handle_announce(&announce_request, &client_ip_sources, &server_service_binding_clone, None) + .await + .unwrap(); + } + + #[tokio::test] + async fn it_should_send_the_tcp_6_announce_event_when_the_peer_uses_ipv6_even_if_the_tracker_changes_the_peer_ip_to_ipv4() + { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + let peer = sample_peer_using_ipv6(); + let remote_client_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(predicate::function(move |event| { + let expected_event = Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + server_service_binding.clone(), + ), + info_hash: sample_info_hash(), + announcement: peer, + }; + announce_events_match(event, &expected_event) + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services(); + core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; + + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + + let announce_service = AnnounceService::new( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.authentication_service.clone(), + core_tracker_services.whitelist_authorization.clone(), + core_http_tracker_services.http_stats_event_sender.clone(), + ); + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let _announce_data = announce_service + .handle_announce(&announce_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + } +} diff --git a/src/servers/http/v1/services/mod.rs b/packages/http-tracker-core/src/services/mod.rs similarity index 92% rename from src/servers/http/v1/services/mod.rs rename to packages/http-tracker-core/src/services/mod.rs index 2e6285d1a..ce99c6856 100644 --- a/src/servers/http/v1/services/mod.rs +++ b/packages/http-tracker-core/src/services/mod.rs @@ -6,5 +6,4 @@ //! //! Refer to [`torrust_tracker`](crate) documentation. pub mod announce; -pub mod peer_ip_resolver; pub mod scrape; diff --git a/packages/http-tracker-core/src/services/scrape.rs b/packages/http-tracker-core/src/services/scrape.rs new file mode 100644 index 000000000..4587bc90a --- /dev/null +++ b/packages/http-tracker-core/src/services/scrape.rs @@ -0,0 +1,620 @@ +//! The `scrape` service. +//! +//! The service is responsible for handling the `scrape` requests. +//! +//! It delegates the `scrape` logic to the [`ScrapeHandler`] and it returns the +//! [`ScrapeData`]. +//! +//! It also sends an [`http_tracker_core::statistics::event::Event`] +//! because events are specific for the HTTP tracker. +use std::sync::Arc; + +use bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape; +use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{ + resolve_remote_client_addr, ClientIpSources, PeerIpResolutionError, RemoteClientAddr, +}; +use bittorrent_tracker_core::authentication::service::AuthenticationService; +use bittorrent_tracker_core::authentication::{self, Key}; +use bittorrent_tracker_core::error::{ScrapeError, TrackerCoreError, WhitelistError}; +use bittorrent_tracker_core::scrape_handler::ScrapeHandler; +use torrust_tracker_configuration::Core; +use torrust_tracker_primitives::core::ScrapeData; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::event::{ConnectionContext, Event}; + +/// The HTTP tracker `scrape` service. +/// +/// The service sends an statistics event that increments: +/// +/// - The number of TCP `announce` requests handled by the HTTP tracker. +/// - The number of TCP `scrape` requests handled by the HTTP tracker. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - There is an error when resolving the client IP address. +pub struct ScrapeService { + core_config: Arc, + scrape_handler: Arc, + authentication_service: Arc, + opt_http_stats_event_sender: crate::event::sender::Sender, +} + +impl ScrapeService { + #[must_use] + pub fn new( + core_config: Arc, + scrape_handler: Arc, + authentication_service: Arc, + opt_http_stats_event_sender: crate::event::sender::Sender, + ) -> Self { + Self { + core_config, + scrape_handler, + authentication_service, + opt_http_stats_event_sender, + } + } + + /// Handles a scrape request. + /// + /// When the peer is not authenticated and the tracker is running in `private` + /// mode, the tracker returns empty stats for all the torrents. + /// + /// # Errors + /// + /// This function will return an error if: + /// + /// - There is an error when resolving the client IP address. + pub async fn handle_scrape( + &self, + scrape_request: &Scrape, + client_ip_sources: &ClientIpSources, + server_service_binding: &ServiceBinding, + maybe_key: Option, + ) -> Result { + let scrape_data = if self.authentication_is_required() && !self.is_authenticated(maybe_key).await { + ScrapeData::zeroed(&scrape_request.info_hashes) + } else { + self.scrape_handler.handle_scrape(&scrape_request.info_hashes).await? + }; + + let remote_client_addr = resolve_remote_client_addr(&self.core_config.net.on_reverse_proxy.into(), client_ip_sources)?; + + self.send_event(remote_client_addr, server_service_binding.clone()).await; + + Ok(scrape_data) + } + + fn authentication_is_required(&self) -> bool { + self.core_config.private + } + + async fn is_authenticated(&self, maybe_key: Option) -> bool { + if let Some(key) = maybe_key { + return self.authentication_service.authenticate(&key).await.is_ok(); + } + + false + } + + async fn send_event(&self, remote_client_addr: RemoteClientAddr, server_service_binding: ServiceBinding) { + if let Some(http_stats_event_sender) = self.opt_http_stats_event_sender.as_deref() { + let event = Event::TcpScrape { + connection: ConnectionContext::new(remote_client_addr, server_service_binding), + }; + + tracing::debug!("Sending TcpScrape event: {:?}", event); + + http_stats_event_sender.send(event).await; + } + } +} + +/// Errors related to announce requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum HttpScrapeError { + #[error("Error resolving peer IP: {source}")] + PeerIpResolutionError { source: PeerIpResolutionError }, + + #[error("Tracker core error: {source}")] + TrackerCoreError { source: TrackerCoreError }, +} + +impl From for HttpScrapeError { + fn from(peer_ip_resolution_error: PeerIpResolutionError) -> Self { + Self::PeerIpResolutionError { + source: peer_ip_resolution_error, + } + } +} + +impl From for HttpScrapeError { + fn from(tracker_core_error: TrackerCoreError) -> Self { + Self::TrackerCoreError { + source: tracker_core_error, + } + } +} + +impl From for HttpScrapeError { + fn from(announce_error: ScrapeError) -> Self { + Self::TrackerCoreError { + source: announce_error.into(), + } + } +} + +impl From for HttpScrapeError { + fn from(whitelist_error: WhitelistError) -> Self { + Self::TrackerCoreError { + source: whitelist_error.into(), + } + } +} + +impl From for HttpScrapeError { + fn from(whitelist_error: authentication::key::Error) -> Self { + Self::TrackerCoreError { + source: whitelist_error.into(), + } + } +} + +#[cfg(test)] +mod tests { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use bittorrent_primitives::info_hash::InfoHash; + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::authentication::key::repository::in_memory::InMemoryKeyRepository; + use bittorrent_tracker_core::authentication::service::AuthenticationService; + use bittorrent_tracker_core::databases::setup::initialize_database; + use bittorrent_tracker_core::scrape_handler::ScrapeHandler; + use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use futures::future::BoxFuture; + use mockall::mock; + use torrust_tracker_configuration::Configuration; + use torrust_tracker_events::sender::SendError; + use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; + + use crate::event::Event; + use crate::tests::sample_info_hash; + + struct Container { + announce_handler: Arc, + scrape_handler: Arc, + authentication_service: Arc, + } + + fn initialize_services_with_configuration(config: &Configuration) -> Container { + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(&config.core, &in_memory_key_repository)); + + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + let scrape_handler = Arc::new(ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository)); + + Container { + announce_handler, + scrape_handler, + authentication_service, + } + } + + fn sample_info_hashes() -> Vec { + vec![sample_info_hash()] + } + + fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Started, + } + } + + mock! { + HttpStatsEventSender {} + impl torrust_tracker_events::sender::Sender for HttpStatsEventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } + } + + mod with_real_data { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{ClientIpSources, RemoteClientAddr, ResolvedIp}; + use bittorrent_tracker_core::announce_handler::PeersWanted; + use mockall::predicate::eq; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + use torrust_tracker_test_helpers::configuration; + + use crate::event::bus::EventBus; + use crate::event::sender::Broadcaster; + use crate::event::{ConnectionContext, Event}; + use crate::services::scrape::tests::{ + initialize_services_with_configuration, sample_info_hashes, sample_peer, MockHttpStatsEventSender, + }; + use crate::services::scrape::ScrapeService; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_return_the_scrape_data_for_a_torrent() { + let configuration = configuration::ephemeral_public(); + let core_config = Arc::new(configuration.core.clone()); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, http_core_broadcaster.clone())); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + let container = initialize_services_with_configuration(&configuration); + + let info_hash = sample_info_hash(); + let info_hashes = vec![info_hash]; + + // Announce a new peer to force scrape data to contain non zeroed data + let mut peer = sample_peer(); + let original_peer_ip = peer.ip(); + container + .announce_handler + .handle_announcement(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + let scrape_request = Scrape { + info_hashes: info_hashes.clone(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(original_peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + core_config.clone(), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + let scrape_data = scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + + let mut expected_scrape_data = ScrapeData::empty(); + expected_scrape_data.add_file( + &info_hash, + SwarmMetadata { + complete: 1, + downloaded: 0, + incomplete: 0, + }, + ); + + assert_eq!(scrape_data, expected_scrape_data); + } + + #[tokio::test] + async fn it_should_send_the_tcp_4_scrape_event_when_the_peer_uses_ipv4() { + let config = configuration::ephemeral(); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(eq(Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1))), + Some(8080), + ), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let container = initialize_services_with_configuration(&config); + + let peer_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); + + let scrape_request = Scrape { + info_hashes: sample_info_hashes(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + Arc::new(config.core), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + + #[tokio::test] + async fn it_should_send_the_tcp_6_scrape_event_when_the_peer_uses_ipv6() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let config = configuration::ephemeral(); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(eq(Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V6(Ipv6Addr::new( + 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, + ))), + Some(8080), + ), + server_service_binding, + ), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let container = initialize_services_with_configuration(&config); + + let peer_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); + + let scrape_request = Scrape { + info_hashes: sample_info_hashes(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + Arc::new(config.core), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + } + + mod with_zeroed_data { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use bittorrent_http_tracker_protocol::v1::requests::scrape::Scrape; + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{ClientIpSources, RemoteClientAddr, ResolvedIp}; + use bittorrent_tracker_core::announce_handler::PeersWanted; + use mockall::predicate::eq; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + use torrust_tracker_test_helpers::configuration; + + use crate::event::bus::EventBus; + use crate::event::sender::Broadcaster; + use crate::event::{ConnectionContext, Event}; + use crate::services::scrape::tests::{ + initialize_services_with_configuration, sample_info_hashes, sample_peer, MockHttpStatsEventSender, + }; + use crate::services::scrape::ScrapeService; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_return_the_zeroed_scrape_data_when_the_tracker_is_running_in_private_mode_and_the_peer_is_not_authenticated( + ) { + let config = configuration::ephemeral_private(); + + let container = initialize_services_with_configuration(&config); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, http_core_broadcaster.clone())); + + let http_stats_event_sender = http_stats_event_bus.sender(); + + let info_hash = sample_info_hash(); + let info_hashes = vec![info_hash]; + + // Announce a new peer to force scrape data to contain non zeroed data + let mut peer = sample_peer(); + let original_peer_ip = peer.ip(); + container + .announce_handler + .handle_announcement(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + let scrape_request = Scrape { + info_hashes: sample_info_hashes(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(original_peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + Arc::new(config.core), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + let scrape_data = scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + + let expected_scrape_data = ScrapeData::zeroed(&info_hashes); + + assert_eq!(scrape_data, expected_scrape_data); + } + + #[tokio::test] + async fn it_should_send_the_tcp_4_scrape_event_when_the_peer_uses_ipv4() { + let config = configuration::ephemeral(); + + let container = initialize_services_with_configuration(&config); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(eq(Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1))), + Some(8080), + ), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let peer_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); + + let scrape_request = Scrape { + info_hashes: sample_info_hashes(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + Arc::new(config.core), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + + #[tokio::test] + async fn it_should_send_the_tcp_6_scrape_event_when_the_peer_uses_ipv6() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let config = configuration::ephemeral(); + + let container = initialize_services_with_configuration(&config); + + let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); + http_stats_event_sender_mock + .expect_send() + .with(eq(Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V6(Ipv6Addr::new( + 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, + ))), + Some(8080), + ), + server_service_binding, + ), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let http_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(http_stats_event_sender_mock)); + + let peer_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); + + let scrape_request = Scrape { + info_hashes: sample_info_hashes(), + }; + + let client_ip_sources = ClientIpSources { + right_most_x_forwarded_for: None, + connection_info_socket_address: Some(SocketAddr::new(peer_ip, 8080)), + }; + + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + + let scrape_service = Arc::new(ScrapeService::new( + Arc::new(config.core), + container.scrape_handler.clone(), + container.authentication_service.clone(), + http_stats_event_sender.clone(), + )); + + scrape_service + .handle_scrape(&scrape_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + } + } +} diff --git a/packages/http-tracker-core/src/statistics/event/handler.rs b/packages/http-tracker-core/src/statistics/event/handler.rs new file mode 100644 index 000000000..37c7a26b5 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/event/handler.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::Event; +use crate::statistics::repository::Repository; +use crate::statistics::HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL; + +pub async fn handle_event(event: Event, stats_repository: &Arc, now: DurationSinceUnixEpoch) { + match event { + Event::TcpAnnounce { connection, .. } => { + let mut label_set = LabelSet::from(connection); + label_set.upsert(label_name!("request_kind"), LabelValue::new("announce")); + + match stats_repository + .increase_counter(&metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), &label_set, now) + .await + { + Ok(()) => { + tracing::debug!( + "Successfully increased the counter for HTTP announce requests received: {}", + label_set + ); + } + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + Event::TcpScrape { connection } => { + let mut label_set = LabelSet::from(connection); + label_set.upsert(label_name!("request_kind"), LabelValue::new("scrape")); + + match stats_repository + .increase_counter(&metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), &label_set, now) + .await + { + Ok(()) => { + tracing::debug!( + "Successfully increased the counter for HTTP scrape requests received: {}", + label_set + ); + } + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + } + + tracing::debug!("stats: {:?}", stats_repository.get_stats().await); +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use bittorrent_http_tracker_protocol::v1::services::peer_ip_resolver::{RemoteClientAddr, ResolvedIp}; + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::tests::{sample_info_hash, sample_peer_using_ipv4, sample_peer_using_ipv6}; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_tcp4_announces_counter_when_it_receives_a_tcp4_announce_event() { + let stats_repository = Arc::new(Repository::new()); + let peer = sample_peer_using_ipv4(); + let remote_client_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); + + handle_event( + Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + info_hash: sample_info_hash(), + announcement: peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.tcp4_announces_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_tcp4_scrapes_counter_when_it_receives_a_tcp4_scrape_event() { + let stats_repository = Arc::new(Repository::new()); + + handle_event( + Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))), + Some(8080), + ), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.tcp4_scrapes_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_tcp6_announces_counter_when_it_receives_a_tcp6_announce_event() { + let stats_repository = Arc::new(Repository::new()); + let peer = sample_peer_using_ipv6(); + let remote_client_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); + + handle_event( + Event::TcpAnnounce { + connection: ConnectionContext::new( + RemoteClientAddr::new(ResolvedIp::FromSocketAddr(remote_client_ip), Some(8080)), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 7070)).unwrap(), + ), + info_hash: sample_info_hash(), + announcement: peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.tcp6_announces_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_tcp6_scrapes_counter_when_it_receives_a_tcp6_scrape_event() { + let stats_repository = Arc::new(Repository::new()); + + handle_event( + Event::TcpScrape { + connection: ConnectionContext::new( + RemoteClientAddr::new( + ResolvedIp::FromSocketAddr(IpAddr::V6(Ipv6Addr::new( + 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, + ))), + Some(8080), + ), + ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 7070)).unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.tcp6_scrapes_handled(), 1); + } +} diff --git a/packages/http-tracker-core/src/statistics/event/listener.rs b/packages/http-tracker-core/src/statistics/event/listener.rs new file mode 100644 index 000000000..ff2937a59 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/event/listener.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; + +use super::handler::handle_event; +use crate::event::receiver::Receiver; +use crate::statistics::repository::Repository; +use crate::{CurrentClock, HTTP_TRACKER_LOG_TARGET}; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + repository: &Arc, +) -> JoinHandle<()> { + let stats_repository = repository.clone(); + + tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "Starting HTTP tracker core event listener"); + + tokio::spawn(async move { + dispatch_events(receiver, cancellation_token, stats_repository).await; + + tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "HTTP tracker core event listener finished"); + }) +} + +async fn dispatch_events(mut receiver: Receiver, cancellation_token: CancellationToken, stats_repository: Arc) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "Received cancellation request, shutting down HTTP tracker core event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: HTTP_TRACKER_LOG_TARGET, "Http tracker core statistics receiver closed."); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: HTTP_TRACKER_LOG_TARGET, "Http tracker core statistics receiver lagged by {} events.", n); + } + } + } + } + } + } + } +} diff --git a/packages/http-tracker-core/src/statistics/event/mod.rs b/packages/http-tracker-core/src/statistics/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/http-tracker-core/src/statistics/metrics.rs b/packages/http-tracker-core/src/statistics/metrics.rs new file mode 100644 index 000000000..00d09b803 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/metrics.rs @@ -0,0 +1,97 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::aggregate::sum::Sum; +use torrust_tracker_metrics::metric_collection::{Error, MetricCollection}; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::statistics::HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL; + +/// Metrics collected by the tracker. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct Metrics { + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increase_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_counter(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn set_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_gauge(metric_name, labels, value, now) + } +} + +impl Metrics { + /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn tcp4_announces_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn tcp4_scrapes_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of TCP (HTTP tracker) `announce` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn tcp6_announces_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn tcp6_scrapes_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } +} diff --git a/packages/http-tracker-core/src/statistics/mod.rs b/packages/http-tracker-core/src/statistics/mod.rs new file mode 100644 index 000000000..3ae355471 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/mod.rs @@ -0,0 +1,23 @@ +pub mod event; +pub mod metrics; +pub mod repository; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_metrics::unit::Unit; + +pub const HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL: &str = "http_tracker_core_requests_received_total"; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &metric_name!(HTTP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of HTTP requests received")), + ); + + metrics +} diff --git a/packages/http-tracker-core/src/statistics/repository.rs b/packages/http-tracker-core/src/statistics/repository.rs new file mode 100644 index 000000000..ea027f5c6 --- /dev/null +++ b/packages/http-tracker-core/src/statistics/repository.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::Error; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::describe_metrics; +use super::metrics::Metrics; + +/// A repository for the tracker metrics. +#[derive(Clone)] +pub struct Repository { + pub stats: Arc>, +} + +impl Default for Repository { + fn default() -> Self { + Self::new() + } +} + +impl Repository { + #[must_use] + pub fn new() -> Self { + let stats = Arc::new(RwLock::new(describe_metrics())); + + Self { stats } + } + + pub async fn get_stats(&self) -> RwLockReadGuard<'_, Metrics> { + self.stats.read().await + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increase the counter. + pub async fn increase_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increase_counter(metric_name, labels, now); + + drop(stats_lock); + + result + } +} diff --git a/packages/located-error/Cargo.toml b/packages/located-error/Cargo.toml index 637ea3055..29b0dfb2c 100644 --- a/packages/located-error/Cargo.toml +++ b/packages/located-error/Cargo.toml @@ -18,4 +18,4 @@ version.workspace = true tracing = "0" [dev-dependencies] -thiserror = "1" +thiserror = "2" diff --git a/packages/located-error/src/lib.rs b/packages/located-error/src/lib.rs index 3cba6042d..09bfbd185 100644 --- a/packages/located-error/src/lib.rs +++ b/packages/located-error/src/lib.rs @@ -23,7 +23,7 @@ //! let b: LocatedError = Located(e).into(); //! let l = get_caller_location(); //! -//! assert!(b.to_string().contains("Test, src/lib.rs")); +//! assert!(b.to_string().contains("src/lib.rs")); //! ``` //! //! # Credits @@ -50,7 +50,7 @@ where location: Box>, } -impl<'a, E> std::fmt::Display for LocatedError<'a, E> +impl std::fmt::Display for LocatedError<'_, E> where E: Error + ?Sized + Send + Sync, { @@ -59,7 +59,7 @@ where } } -impl<'a, E> Error for LocatedError<'a, E> +impl Error for LocatedError<'_, E> where E: Error + ?Sized + Send + Sync + 'static, { @@ -68,7 +68,7 @@ where } } -impl<'a, E> Clone for LocatedError<'a, E> +impl Clone for LocatedError<'_, E> where E: Error + ?Sized + Send + Sync, { diff --git a/packages/metrics/.gitignore b/packages/metrics/.gitignore new file mode 100644 index 000000000..6350e9868 --- /dev/null +++ b/packages/metrics/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/packages/metrics/Cargo.toml b/packages/metrics/Cargo.toml new file mode 100644 index 000000000..0597785f4 --- /dev/null +++ b/packages/metrics/Cargo.toml @@ -0,0 +1,30 @@ +[package] +description = "A library with the primitive types shared by the Torrust tracker packages." +keywords = ["api", "library", "metrics"] +name = "torrust-tracker-metrics" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +chrono = { version = "0", default-features = false, features = ["clock"] } +derive_more = { version = "2", features = ["constructor"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.140" +thiserror = "2" +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +tracing = "0.1.41" + +[dev-dependencies] +approx = "0.5.1" +formatjson = "0.3.1" +pretty_assertions = "1.4.1" +rstest = "0.25.0" diff --git a/packages/metrics/LICENSE b/packages/metrics/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/metrics/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/metrics/README.md b/packages/metrics/README.md new file mode 100644 index 000000000..3d1d94c5f --- /dev/null +++ b/packages/metrics/README.md @@ -0,0 +1,210 @@ +# Torrust Tracker Metrics + +A comprehensive metrics library providing type-safe metric collection, aggregation, and Prometheus export functionality for the [Torrust Tracker](https://github.com/torrust/torrust-tracker) ecosystem. + +## Overview + +This library offers a robust metrics system designed specifically for tracking and monitoring BitTorrent tracker performance. It provides type-safe metric collection with support for labels, time-series data, and multiple export formats including Prometheus. + +## Key Features + +- **Type-Safe Metrics**: Strongly typed `Counter` and `Gauge` metrics with compile-time guarantees +- **Label Support**: Rich labeling system for multi-dimensional metrics +- **Time-Series Data**: Built-in support for timestamped samples +- **Prometheus Export**: Native Prometheus format serialization +- **Aggregation Functions**: Sum operations with mathematically appropriate return types +- **JSON Serialization**: Full serde support for all metric types +- **Memory Efficient**: Optimized data structures for high-performance scenarios + +## Quick Start + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +torrust-tracker-metrics = "3.0.0" +``` + +### Basic Usage + +```rust +use torrust_tracker_metrics::{ + metric_collection::MetricCollection, + label::{LabelSet, LabelValue}, + metric_name, label_name, +}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +// Create a metric collection +let mut metrics = MetricCollection::default(); + +// Define labels +let labels: LabelSet = [ + (label_name!("server"), LabelValue::new("tracker-01")), + (label_name!("protocol"), LabelValue::new("http")), +].into(); + +// Record metrics +let time = DurationSinceUnixEpoch::from_secs(1234567890); +metrics.increment_counter( + &metric_name!("requests_total"), + &labels, + time, +)?; + +metrics.set_gauge( + &metric_name!("active_connections"), + &labels, + 42.0, + time, +)?; + +// Export to Prometheus format +let prometheus_output = metrics.to_prometheus(); +println!("{}", prometheus_output); +``` + +### Metric Aggregation + +```rust +use torrust_tracker_metrics::metric_collection::aggregate::{Sum, Avg}; + +// Sum all counter values matching specific labels +let total_requests = metrics.sum( + &metric_name!("requests_total"), + &[("server", "tracker-01")].into(), +); + +println!("Total requests: {:?}", total_requests); + +// Calculate average of gauge values matching specific labels +let avg_response_time = metrics.avg( + &metric_name!("response_time_seconds"), + &[("endpoint", "/announce")].into(), +); + +println!("Average response time: {:?}", avg_response_time); +``` + +## Architecture + +### Core Components + +- **`Counter`**: Monotonically increasing integer values (u64) +- **`Gauge`**: Arbitrary floating-point values that can increase or decrease (f64) +- **`Metric`**: Generic metric container with metadata (name, description, unit) +- **`MetricCollection`**: Type-safe collection managing both counters and gauges +- **`LabelSet`**: Key-value pairs for metric dimensionality +- **`Sample`**: Timestamped metric values with associated labels + +### Type System + +The library uses Rust's type system to ensure metric safety: + +```rust +// Counter operations return u64 +let counter_sum: Option = counter_collection.sum(&name, &labels); + +// Gauge operations return f64 +let gauge_sum: Option = gauge_collection.sum(&name, &labels); + +// Mixed collections convert to f64 for compatibility +let mixed_sum: Option = metric_collection.sum(&name, &labels); +``` + +### Module Structure + +```output +src/ +├── counter.rs # Counter metric type +├── gauge.rs # Gauge metric type +├── metric/ # Generic metric container +│ ├── mod.rs +│ ├── name.rs # Metric naming +│ ├── description.rs # Metric descriptions +│ └── aggregate/ # Metric-level aggregations +├── metric_collection/ # Collection management +│ ├── mod.rs +│ └── aggregate/ # Collection-level aggregations +├── label/ # Label system +│ ├── name.rs # Label names +│ ├── value.rs # Label values +│ └── set.rs # Label collections +├── sample.rs # Timestamped values +├── sample_collection.rs # Sample management +├── prometheus.rs # Prometheus export +└── unit.rs # Measurement units +``` + +## Documentation + +- [Crate documentation](https://docs.rs/torrust-tracker-metrics) +- [API Reference](https://docs.rs/torrust-tracker-metrics/latest/torrust_tracker_metrics/) + +## Development + +### Code Coverage + +Run basic coverage report: + +```console +cargo llvm-cov --package torrust-tracker-metrics +``` + +Generate LCOV report (for IDE integration): + +```console +mkdir -p ./.coverage +cargo llvm-cov --package torrust-tracker-metrics --lcov --output-path=./.coverage/lcov.info +``` + +Generate detailed HTML coverage report: + +Generate detailed HTML coverage report: + +```console +mkdir -p ./.coverage +cargo llvm-cov --package torrust-tracker-metrics --html --output-dir ./.coverage +``` + +Open the coverage report in your browser: + +```console +open ./.coverage/index.html # macOS +xdg-open ./.coverage/index.html # Linux +``` + +## Performance Considerations + +- **Memory Usage**: Metrics are stored in-memory with efficient HashMap-based collections +- **Label Cardinality**: Be mindful of label combinations as they create separate time series +- **Aggregation**: Sum operations are optimized for both single-type and mixed collections + +## Compatibility + +This library is designed to be compatible with the standard Rust [metrics](https://crates.io/crates/metrics) crate ecosystem where possible. + +## Contributing + +We welcome contributions! Please see the main [Torrust Tracker repository](https://github.com/torrust/torrust-tracker) for contribution guidelines. + +### Reporting Issues + +- [Bug Reports](https://github.com/torrust/torrust-tracker/issues/new?template=bug_report.md) +- [Feature Requests](https://github.com/torrust/torrust-tracker/issues/new?template=feature_request.md) + +## Acknowledgements + +This library draws inspiration from the Rust [metrics](https://crates.io/crates/metrics) crate, incorporating compatible APIs and naming conventions where possible. We may consider migrating to the standard metrics crate in future versions while maintaining our specialized functionality. + +Special thanks to the Rust metrics ecosystem contributors for establishing excellent patterns for metrics collection and export. + +## License + +This project is licensed under the [GNU AFFERO GENERAL PUBLIC LICENSE v3.0](./LICENSE). + +## Related Projects + +- [Torrust Tracker](https://github.com/torrust/torrust-tracker) - The main BitTorrent tracker +- [metrics](https://crates.io/crates/metrics) - Standard Rust metrics facade +- [prometheus](https://crates.io/crates/prometheus) - Prometheus client library diff --git a/packages/metrics/cSpell.json b/packages/metrics/cSpell.json new file mode 100644 index 000000000..f04cce9e3 --- /dev/null +++ b/packages/metrics/cSpell.json @@ -0,0 +1,21 @@ +{ + "words": [ + "cloneable", + "formatjson", + "Gibibytes", + "Kibibytes", + "Mebibytes", + "ñaca", + "println", + "rstest", + "serde", + "subsec", + "Tebibytes", + "thiserror" + ], + "enableFiletypes": [ + "dockerfile", + "shellscript", + "toml" + ] +} diff --git a/packages/metrics/src/counter.rs b/packages/metrics/src/counter.rs new file mode 100644 index 000000000..0e2002181 --- /dev/null +++ b/packages/metrics/src/counter.rs @@ -0,0 +1,266 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Counter(u64); + +impl Counter { + #[must_use] + pub fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub fn value(&self) -> u64 { + self.0 + } + + #[must_use] + pub fn primitive(&self) -> u64 { + self.value() + } + + pub fn increment(&mut self, value: u64) { + self.0 += value; + } + + pub fn absolute(&mut self, value: u64) { + self.0 = value; + } +} + +impl From for Counter { + fn from(value: u32) -> Self { + Self(u64::from(value)) + } +} + +impl From for Counter { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for Counter { + fn from(value: i32) -> Self { + #[allow(clippy::cast_sign_loss)] + Self(value as u64) + } +} + +impl From for u64 { + fn from(counter: Counter) -> Self { + counter.value() + } +} + +impl PrometheusSerializable for Counter { + fn to_prometheus(&self) -> String { + format!("{}", self.value()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_be_created_from_integer_values() { + let counter = Counter::new(0); + assert_eq!(counter.value(), 0); + } + + #[test] + fn it_could_be_converted_from_u64() { + let counter: Counter = 42.into(); + assert_eq!(counter.value(), 42); + } + + #[test] + fn it_could_be_converted_into_u64() { + let counter = Counter::new(42); + let value: u64 = counter.into(); + assert_eq!(value, 42); + } + + #[test] + fn it_could_be_incremented() { + let mut counter = Counter::new(0); + counter.increment(1); + assert_eq!(counter.value(), 1); + + counter.increment(2); + assert_eq!(counter.value(), 3); + } + + #[test] + fn it_could_set_to_an_absolute_value() { + let mut counter = Counter::new(0); + counter.absolute(1); + assert_eq!(counter.value(), 1); + } + + #[test] + fn it_serializes_to_prometheus() { + let counter = Counter::new(42); + assert_eq!(counter.to_prometheus(), "42"); + } + + #[test] + fn it_could_be_converted_from_u32() { + let counter: Counter = 42u32.into(); + assert_eq!(counter.value(), 42); + } + + #[test] + fn it_could_be_converted_from_i32() { + let counter: Counter = 42i32.into(); + assert_eq!(counter.value(), 42); + } + + #[test] + fn it_should_return_primitive_value() { + let counter = Counter::new(123); + assert_eq!(counter.primitive(), 123); + } + + #[test] + fn it_should_handle_zero_value() { + let counter = Counter::new(0); + assert_eq!(counter.value(), 0); + assert_eq!(counter.primitive(), 0); + } + + #[test] + fn it_should_handle_large_values() { + let counter = Counter::new(u64::MAX); + assert_eq!(counter.value(), u64::MAX); + } + + #[test] + fn it_should_handle_u32_max_conversion() { + let counter: Counter = u32::MAX.into(); + assert_eq!(counter.value(), u64::from(u32::MAX)); + } + + #[test] + fn it_should_handle_i32_max_conversion() { + let counter: Counter = i32::MAX.into(); + assert_eq!(counter.value(), i32::MAX as u64); + } + + #[test] + fn it_should_handle_negative_i32_conversion() { + let counter: Counter = (-42i32).into(); + #[allow(clippy::cast_sign_loss)] + let expected = (-42i32) as u64; + assert_eq!(counter.value(), expected); + } + + #[test] + fn it_should_handle_i32_min_conversion() { + let counter: Counter = i32::MIN.into(); + #[allow(clippy::cast_sign_loss)] + let expected = i32::MIN as u64; + assert_eq!(counter.value(), expected); + } + + #[test] + fn it_should_handle_large_increments() { + let mut counter = Counter::new(100); + counter.increment(1000); + assert_eq!(counter.value(), 1100); + + counter.increment(u64::MAX - 1100); + assert_eq!(counter.value(), u64::MAX); + } + + #[test] + fn it_should_support_multiple_absolute_operations() { + let mut counter = Counter::new(0); + + counter.absolute(100); + assert_eq!(counter.value(), 100); + + counter.absolute(50); + assert_eq!(counter.value(), 50); + + counter.absolute(0); + assert_eq!(counter.value(), 0); + } + + #[test] + fn it_should_be_displayable() { + let counter = Counter::new(42); + assert_eq!(counter.to_string(), "42"); + + let counter = Counter::new(0); + assert_eq!(counter.to_string(), "0"); + } + + #[test] + fn it_should_be_debuggable() { + let counter = Counter::new(42); + let debug_string = format!("{counter:?}"); + assert_eq!(debug_string, "Counter(42)"); + } + + #[test] + fn it_should_be_cloneable() { + let counter = Counter::new(42); + let cloned_counter = counter.clone(); + assert_eq!(counter, cloned_counter); + assert_eq!(counter.value(), cloned_counter.value()); + } + + #[test] + fn it_should_support_equality_comparison() { + let counter1 = Counter::new(42); + let counter2 = Counter::new(42); + let counter3 = Counter::new(43); + + assert_eq!(counter1, counter2); + assert_ne!(counter1, counter3); + } + + #[test] + fn it_should_have_default_value() { + let counter = Counter::default(); + assert_eq!(counter.value(), 0); + } + + #[test] + fn it_should_handle_conversion_roundtrip() { + let original_value = 12345u64; + let counter = Counter::from(original_value); + let converted_back: u64 = counter.into(); + assert_eq!(original_value, converted_back); + } + + #[test] + fn it_should_handle_u32_conversion_roundtrip() { + let original_value = 12345u32; + let counter = Counter::from(original_value); + assert_eq!(counter.value(), u64::from(original_value)); + } + + #[test] + fn it_should_handle_i32_conversion_roundtrip() { + let original_value = 12345i32; + let counter = Counter::from(original_value); + #[allow(clippy::cast_sign_loss)] + let expected = original_value as u64; + assert_eq!(counter.value(), expected); + } + + #[test] + fn it_should_serialize_large_values_to_prometheus() { + let counter = Counter::new(u64::MAX); + assert_eq!(counter.to_prometheus(), u64::MAX.to_string()); + + let counter = Counter::new(0); + assert_eq!(counter.to_prometheus(), "0"); + } +} diff --git a/packages/metrics/src/gauge.rs b/packages/metrics/src/gauge.rs new file mode 100644 index 000000000..d0883715b --- /dev/null +++ b/packages/metrics/src/gauge.rs @@ -0,0 +1,240 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Gauge(f64); + +impl Gauge { + #[must_use] + pub fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub fn value(&self) -> f64 { + self.0 + } + + #[must_use] + pub fn primitive(&self) -> f64 { + self.value() + } + + pub fn set(&mut self, value: f64) { + self.0 = value; + } + + pub fn increment(&mut self, value: f64) { + self.0 += value; + } + + pub fn decrement(&mut self, value: f64) { + self.0 -= value; + } +} + +impl From for Gauge { + fn from(value: f32) -> Self { + Self(f64::from(value)) + } +} + +impl From for Gauge { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for f64 { + fn from(counter: Gauge) -> Self { + counter.value() + } +} + +impl PrometheusSerializable for Gauge { + fn to_prometheus(&self) -> String { + format!("{}", self.value()) + } +} + +#[cfg(test)] +mod tests { + use approx::assert_relative_eq; + + use super::*; + + #[test] + fn it_should_be_created_from_integer_values() { + let gauge = Gauge::new(0.0); + assert_relative_eq!(gauge.value(), 0.0); + } + + #[test] + fn it_could_be_converted_from_u64() { + let gauge: Gauge = 42.0.into(); + assert_relative_eq!(gauge.value(), 42.0); + } + + #[test] + fn it_could_be_converted_into_i64() { + let gauge = Gauge::new(42.0); + let value: f64 = gauge.into(); + assert_relative_eq!(value, 42.0); + } + + #[test] + fn it_could_be_set() { + let mut gauge = Gauge::new(0.0); + gauge.set(1.0); + assert_relative_eq!(gauge.value(), 1.0); + } + + #[test] + fn it_could_be_incremented() { + let mut gauge = Gauge::new(0.0); + gauge.increment(1.0); + assert_relative_eq!(gauge.value(), 1.0); + } + + #[test] + fn it_could_be_decremented() { + let mut gauge = Gauge::new(1.0); + gauge.decrement(1.0); + assert_relative_eq!(gauge.value(), 0.0); + } + + #[test] + fn it_serializes_to_prometheus() { + let counter = Gauge::new(42.0); + assert_eq!(counter.to_prometheus(), "42"); + + let counter = Gauge::new(42.1); + assert_eq!(counter.to_prometheus(), "42.1"); + } + + #[test] + fn it_could_be_converted_from_f32() { + let gauge: Gauge = 42.5f32.into(); + assert_relative_eq!(gauge.value(), 42.5); + } + + #[test] + fn it_should_return_primitive_value() { + let gauge = Gauge::new(123.456); + assert_relative_eq!(gauge.primitive(), 123.456); + } + + #[test] + fn it_should_handle_zero_value() { + let gauge = Gauge::new(0.0); + assert_relative_eq!(gauge.value(), 0.0); + assert_relative_eq!(gauge.primitive(), 0.0); + } + + #[test] + fn it_should_handle_negative_values() { + let gauge = Gauge::new(-42.5); + assert_relative_eq!(gauge.value(), -42.5); + } + + #[test] + fn it_should_handle_large_values() { + let gauge = Gauge::new(f64::MAX); + assert_relative_eq!(gauge.value(), f64::MAX); + } + + #[test] + fn it_should_handle_infinity() { + let gauge = Gauge::new(f64::INFINITY); + assert_relative_eq!(gauge.value(), f64::INFINITY); + } + + #[test] + fn it_should_handle_nan() { + let gauge = Gauge::new(f64::NAN); + assert!(gauge.value().is_nan()); + } + + #[test] + fn it_should_be_displayable() { + let gauge = Gauge::new(42.5); + assert_eq!(gauge.to_string(), "42.5"); + + let gauge = Gauge::new(0.0); + assert_eq!(gauge.to_string(), "0"); + } + + #[test] + fn it_should_be_debuggable() { + let gauge = Gauge::new(42.5); + let debug_string = format!("{gauge:?}"); + assert_eq!(debug_string, "Gauge(42.5)"); + } + + #[test] + fn it_should_be_cloneable() { + let gauge = Gauge::new(42.5); + let cloned_gauge = gauge.clone(); + assert_eq!(gauge, cloned_gauge); + assert_relative_eq!(gauge.value(), cloned_gauge.value()); + } + + #[test] + fn it_should_support_equality_comparison() { + let gauge1 = Gauge::new(42.5); + let gauge2 = Gauge::new(42.5); + let gauge3 = Gauge::new(43.0); + + assert_eq!(gauge1, gauge2); + assert_ne!(gauge1, gauge3); + } + + #[test] + fn it_should_have_default_value() { + let gauge = Gauge::default(); + assert_relative_eq!(gauge.value(), 0.0); + } + + #[test] + fn it_should_handle_conversion_roundtrip() { + let original_value = 12345.678; + let gauge = Gauge::from(original_value); + let converted_back: f64 = gauge.into(); + assert_relative_eq!(original_value, converted_back); + } + + #[test] + fn it_should_handle_f32_conversion_roundtrip() { + let original_value = 12345.5f32; + let gauge = Gauge::from(original_value); + assert_relative_eq!(gauge.value(), f64::from(original_value)); + } + + #[test] + fn it_should_handle_multiple_operations() { + let mut gauge = Gauge::new(100.0); + + gauge.increment(50.0); + assert_relative_eq!(gauge.value(), 150.0); + + gauge.decrement(25.0); + assert_relative_eq!(gauge.value(), 125.0); + + gauge.set(200.0); + assert_relative_eq!(gauge.value(), 200.0); + } + + #[test] + fn it_should_serialize_special_values_to_prometheus() { + let gauge = Gauge::new(f64::INFINITY); + assert_eq!(gauge.to_prometheus(), "inf"); + + let gauge = Gauge::new(f64::NEG_INFINITY); + assert_eq!(gauge.to_prometheus(), "-inf"); + + let gauge = Gauge::new(f64::NAN); + assert_eq!(gauge.to_prometheus(), "NaN"); + } +} diff --git a/packages/metrics/src/label/mod.rs b/packages/metrics/src/label/mod.rs new file mode 100644 index 000000000..880fdbbb1 --- /dev/null +++ b/packages/metrics/src/label/mod.rs @@ -0,0 +1,9 @@ +pub mod name; +mod pair; +mod set; +pub mod value; + +pub type LabelName = name::LabelName; +pub type LabelValue = value::LabelValue; +pub type LabelPair = pair::LabelPair; +pub type LabelSet = set::LabelSet; diff --git a/packages/metrics/src/label/name.rs b/packages/metrics/src/label/name.rs new file mode 100644 index 000000000..194aeb2b3 --- /dev/null +++ b/packages/metrics/src/label/name.rs @@ -0,0 +1,126 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct LabelName(String); + +impl LabelName { + /// Creates a new `LabelName` instance. + /// + /// # Panics + /// + /// Panics if the provided name is empty. + #[must_use] + pub fn new(name: &str) -> Self { + assert!(!name.is_empty(), "Label name cannot be empty."); + Self(name.to_owned()) + } +} + +impl PrometheusSerializable for LabelName { + /// In Prometheus: + /// + /// - Labels may contain ASCII letters, numbers, as well as underscores. + /// They must match the regex [a-zA-Z_][a-zA-Z0-9_]*. + /// - Label names beginning with __ (two "_") are reserved for internal + /// use. + /// - Label values may contain any Unicode characters. + /// - Labels with an empty label value are considered equivalent to + /// labels that do not exist. + /// + /// The label name is changed: + /// + /// - If a label name starts with, or contains, an invalid character: + /// replace character with underscore. + /// - If th label name starts with two underscores: + /// add additional underscore (three underscores total) + fn to_prometheus(&self) -> String { + // Replace invalid characters with underscore + let processed: String = self + .0 + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + if c.is_ascii_alphabetic() || c == '_' { + c + } else { + '_' + } + } else if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect(); + + // If the label name starts with two underscores, add an additional + if processed.starts_with("__") && !processed.starts_with("___") { + format!("_{processed}") + } else { + processed + } + } +} + +#[macro_export] +macro_rules! label_name { + ("") => { + compile_error!("Label name cannot be empty"); + }; + ($name:literal) => { + $crate::label::name::LabelName::new($name) + }; + ($name:ident) => { + $crate::label::name::LabelName::new($name) + }; +} +#[cfg(test)] +mod tests { + mod serialization_of_label_name_to_prometheus { + use rstest::rstest; + + use crate::label::LabelName; + use crate::prometheus::PrometheusSerializable; + + #[rstest] + #[case("1 valid name", "valid_name", "valid_name")] + #[case("2 leading underscore", "_leading_underscore", "_leading_underscore")] + #[case("3 leading lowercase", "v123", "v123")] + #[case("4 leading uppercase", "V123", "V123")] + fn valid_names_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[rstest] + #[case("1 invalid start 1", "9invalid_start", "_invalid_start")] + #[case("2 invalid start 2", "@test", "_test")] + #[case("3 invalid dash", "invalid-char", "invalid_char")] + #[case("4 invalid spaces", "spaces are bad", "spaces_are_bad")] + #[case("5 invalid special chars", "a!b@c#d$e%f^g&h*i(j)", "a_b_c_d_e_f_g_h_i_j_")] + #[case("6 invalid colon", "my:metric/version", "my_metric_version")] + #[case("7 all invalid characters", "!@#$%^&*()", "__________")] + #[case("8 non_ascii_characters", "ñaca©", "_aca_")] + fn names_that_need_changes_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[rstest] + #[case("1 double underscore start", "__private", "___private")] + #[case("2 double underscore only", "__", "___")] + #[case("3 processed to double underscore", "^^name", "___name")] + #[case("4 processed to double underscore after first char", "0__name", "___name")] + fn names_starting_with_double_underscore(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[test] + #[should_panic(expected = "Label name cannot be empty.")] + fn empty_name() { + let _name = LabelName::new(""); + } + } +} diff --git a/packages/metrics/src/label/pair.rs b/packages/metrics/src/label/pair.rs new file mode 100644 index 000000000..858902451 --- /dev/null +++ b/packages/metrics/src/label/pair.rs @@ -0,0 +1,29 @@ +use super::{LabelName, LabelValue}; +use crate::prometheus::PrometheusSerializable; + +pub type LabelPair = (LabelName, LabelValue); + +// Generic implementation for any tuple (A, B) where A and B implement PrometheusSerializable +impl PrometheusSerializable for (A, B) { + fn to_prometheus(&self) -> String { + format!("{}=\"{}\"", self.0.to_prometheus(), self.1.to_prometheus()) + } +} + +#[cfg(test)] +mod tests { + mod serialization_of_label_pair_to_prometheus { + use crate::label::LabelValue; + use crate::label_name; + use crate::prometheus::PrometheusSerializable; + + #[test] + fn test_label_pair_serialization_to_prometheus() { + let label_pair = (label_name!("label_name"), LabelValue::new("value")); + assert_eq!(label_pair.to_prometheus(), r#"label_name="value""#); + + let label_pair = (&label_name!("label_name"), &LabelValue::new("value")); + assert_eq!(label_pair.to_prometheus(), r#"label_name="value""#); + } + } +} diff --git a/packages/metrics/src/label/set.rs b/packages/metrics/src/label/set.rs new file mode 100644 index 000000000..46256e4d5 --- /dev/null +++ b/packages/metrics/src/label/set.rs @@ -0,0 +1,584 @@ +use std::collections::btree_map::Iter; +use std::collections::BTreeMap; +use std::fmt::Display; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::{LabelName, LabelPair, LabelValue}; +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd, Hash)] +pub struct LabelSet { + items: BTreeMap, +} + +impl LabelSet { + #[must_use] + pub fn empty() -> Self { + Self { items: BTreeMap::new() } + } + + /// Insert a new label pair or update the value of an existing label. + pub fn upsert(&mut self, name: LabelName, value: LabelValue) { + self.items.insert(name, value); + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn contains_pair(&self, name: &LabelName, value: &LabelValue) -> bool { + match self.items.get(name) { + Some(existing_value) => existing_value == value, + None => false, + } + } + + pub fn matches(&self, criteria: &LabelSet) -> bool { + criteria.iter().all(|(name, value)| self.contains_pair(name, value)) + } + + pub fn iter(&self) -> Iter<'_, LabelName, LabelValue> { + self.items.iter() + } +} + +impl Display for LabelSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let items = self + .items + .iter() + .map(|(name, value)| format!("{name}=\"{value}\"")) + .collect::>() + .join(","); + + write!(f, "{{{items}}}") + } +} + +impl From> for LabelSet { + fn from(values: BTreeMap) -> Self { + Self { items: values } + } +} + +impl From> for LabelSet { + fn from(vec: Vec<(&str, &str)>) -> Self { + let mut items = BTreeMap::new(); + + for (name, value) in vec { + items.insert(LabelName::new(name), LabelValue::new(value)); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec<(String, String)>) -> Self { + let mut items = BTreeMap::new(); + + for (name, value) in vec { + items.insert(LabelName::new(&name), LabelValue::new(&value)); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec) -> Self { + let mut items = BTreeMap::new(); + + for (name, value) in vec { + items.insert(name, value); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec) -> Self { + let mut items = BTreeMap::new(); + + for serialized_label in vec { + items.insert(serialized_label.name, serialized_label.value); + } + + Self { items } + } +} + +impl From<[LabelPair; N]> for LabelSet { + fn from(arr: [LabelPair; N]) -> Self { + let values = BTreeMap::from(arr); + Self { items: values } + } +} + +impl From<[(String, String); N]> for LabelSet { + fn from(arr: [(String, String); N]) -> Self { + let values = arr + .iter() + .map(|(name, value)| (LabelName::new(name), LabelValue::new(value))) + .collect::>(); + Self { items: values } + } +} + +impl From<[(&str, &str); N]> for LabelSet { + fn from(arr: [(&str, &str); N]) -> Self { + let values = arr + .iter() + .map(|(name, value)| (LabelName::new(name), LabelValue::new(value))) + .collect::>(); + Self { items: values } + } +} + +impl From for LabelSet { + fn from(label_pair: LabelPair) -> Self { + let mut set = BTreeMap::new(); + + set.insert(label_pair.0, label_pair.1); + + Self { items: set } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Default, Deserialize, Serialize)] +struct SerializedLabel { + name: LabelName, + value: LabelValue, +} + +impl Serialize for LabelSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.items + .iter() + .map(|(name, value)| SerializedLabel { + name: name.clone(), + value: value.clone(), + }) + .collect::>() + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LabelSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let serialized_labels = Vec::::deserialize(deserializer)?; + + Ok(LabelSet::from(serialized_labels)) + } +} + +impl PrometheusSerializable for LabelSet { + fn to_prometheus(&self) -> String { + if self.is_empty() { + return String::new(); + } + + let items = self.items.iter().fold(String::new(), |mut output, label_pair| { + if !output.is_empty() { + output.push(','); + } + + output.push_str(&label_pair.to_prometheus()); + + output + }); + + format!("{{{items}}}") + } +} + +#[cfg(test)] +mod tests { + + use std::collections::BTreeMap; + use std::hash::{DefaultHasher, Hash}; + + use pretty_assertions::assert_eq; + + use super::{LabelName, LabelValue}; + use crate::label::LabelSet; + use crate::label_name; + use crate::prometheus::PrometheusSerializable; + + fn sample_vec_of_label_pairs() -> Vec<(LabelName, LabelValue)> { + sample_array_of_label_pairs().into() + } + + fn sample_array_of_label_pairs() -> [(LabelName, LabelValue); 3] { + [ + (label_name!("server_service_binding_protocol"), LabelValue::new("http")), + (label_name!("server_service_binding_ip"), LabelValue::new("0.0.0.0")), + (label_name!("server_service_binding_port"), LabelValue::new("7070")), + ] + } + + #[test] + fn it_should_allow_inserting_a_new_label_pair() { + let mut label_set = LabelSet::default(); + + label_set.upsert(label_name!("label_name"), LabelValue::new("value")); + + assert_eq!( + label_set.items.get(&label_name!("label_name")).unwrap(), + &LabelValue::new("value") + ); + } + + #[test] + fn it_should_allow_updating_a_label_value() { + let mut label_set = LabelSet::default(); + + label_set.upsert(label_name!("label_name"), LabelValue::new("old value")); + label_set.upsert(label_name!("label_name"), LabelValue::new("new value")); + + assert_eq!( + label_set.items.get(&label_name!("label_name")).unwrap(), + &LabelValue::new("new value") + ); + } + + #[test] + fn it_should_allow_serializing_to_json_as_an_array_of_label_objects() { + let label_set = LabelSet::from((label_name!("label_name"), LabelValue::new("label value"))); + + let json = serde_json::to_string(&label_set).unwrap(); + + assert_eq!( + formatjson::format_json(&json).unwrap(), + formatjson::format_json( + r#" + [ + { + "name": "label_name", + "value": "label value" + } + ] + "# + ) + .unwrap() + ); + } + + #[test] + fn it_should_allow_deserializing_from_json_as_an_array_of_label_objects() { + let json = formatjson::format_json( + r#" + [ + { + "name": "label_name", + "value": "label value" + } + ] + "#, + ) + .unwrap(); + + let label_set: LabelSet = serde_json::from_str(&json).unwrap(); + + assert_eq!( + label_set, + LabelSet::from((label_name!("label_name"), LabelValue::new("label value"))) + ); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format() { + let label_set = LabelSet::from((label_name!("label_name"), LabelValue::new("label value"))); + assert_eq!(label_set.to_prometheus(), r#"{label_name="label value"}"#); + } + + #[test] + fn it_should_handle_prometheus_format_with_special_characters() { + let label_set: LabelSet = vec![("label_with_underscores", "value_with_underscores")].into(); + assert_eq!( + label_set.to_prometheus(), + r#"{label_with_underscores="value_with_underscores"}"# + ); + } + + #[test] + fn it_should_alphabetically_order_labels_in_prometheus_format() { + let label_set = LabelSet::from([ + (label_name!("b_label_name"), LabelValue::new("b label value")), + (label_name!("a_label_name"), LabelValue::new("a label value")), + ]); + + assert_eq!( + label_set.to_prometheus(), + r#"{a_label_name="a label value",b_label_name="b label value"}"# + ); + } + + #[test] + fn it_should_allow_displaying() { + let label_set = LabelSet::from((label_name!("label_name"), LabelValue::new("label value"))); + + assert_eq!(label_set.to_string(), r#"{label_name="label value"}"#); + } + + #[test] + fn it_should_allow_instantiation_from_an_array_of_label_pairs() { + let label_set: LabelSet = sample_array_of_label_pairs().into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_vec_of_label_pairs() { + let label_set: LabelSet = sample_vec_of_label_pairs().into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_b_tree_map() { + let label_set: LabelSet = BTreeMap::from(sample_array_of_label_pairs()).into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_label_pair() { + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from([(label_name!("label_name"), LabelValue::new("value"))]) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_vec_of_str_tuples() { + let label_set: LabelSet = vec![("foo", "bar"), ("baz", "qux")].into(); + + let mut expected = BTreeMap::new(); + expected.insert(LabelName::new("foo"), LabelValue::new("bar")); + expected.insert(LabelName::new("baz"), LabelValue::new("qux")); + + assert_eq!(label_set, LabelSet { items: expected }); + } + + #[test] + fn it_should_allow_instantiation_from_vec_of_string_tuples() { + let label_set: LabelSet = vec![("foo".to_string(), "bar".to_string()), ("baz".to_string(), "qux".to_string())].into(); + + let mut expected = BTreeMap::new(); + expected.insert(LabelName::new("foo"), LabelValue::new("bar")); + expected.insert(LabelName::new("baz"), LabelValue::new("qux")); + + assert_eq!(label_set, LabelSet { items: expected }); + } + + #[test] + fn it_should_allow_instantiation_from_vec_of_serialized_label() { + use super::SerializedLabel; + let label_set: LabelSet = vec![ + SerializedLabel { + name: LabelName::new("foo"), + value: LabelValue::new("bar"), + }, + SerializedLabel { + name: LabelName::new("baz"), + value: LabelValue::new("qux"), + }, + ] + .into(); + + let mut expected = BTreeMap::new(); + expected.insert(LabelName::new("foo"), LabelValue::new("bar")); + expected.insert(LabelName::new("baz"), LabelValue::new("qux")); + + assert_eq!(label_set, LabelSet { items: expected }); + } + + #[test] + fn it_should_allow_instantiation_from_array_of_string_tuples() { + let arr: [(String, String); 2] = [("foo".to_string(), "bar".to_string()), ("baz".to_string(), "qux".to_string())]; + let label_set: LabelSet = arr.into(); + + let mut expected = BTreeMap::new(); + + expected.insert(LabelName::new("foo"), LabelValue::new("bar")); + expected.insert(LabelName::new("baz"), LabelValue::new("qux")); + + assert_eq!(label_set, LabelSet { items: expected }); + } + + #[test] + fn it_should_allow_instantiation_from_array_of_str_tuples() { + let arr: [(&str, &str); 2] = [("foo", "bar"), ("baz", "qux")]; + let label_set: LabelSet = arr.into(); + + let mut expected = BTreeMap::new(); + + expected.insert(LabelName::new("foo"), LabelValue::new("bar")); + expected.insert(LabelName::new("baz"), LabelValue::new("qux")); + + assert_eq!(label_set, LabelSet { items: expected }); + } + + #[test] + fn it_should_be_comparable() { + let a: LabelSet = (label_name!("x"), LabelValue::new("1")).into(); + let b: LabelSet = (label_name!("x"), LabelValue::new("1")).into(); + let c: LabelSet = (label_name!("y"), LabelValue::new("2")).into(); + + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn it_should_be_allow_ordering() { + let a: LabelSet = (label_name!("x"), LabelValue::new("1")).into(); + let b: LabelSet = (label_name!("y"), LabelValue::new("2")).into(); + + assert!(a < b); + } + + #[test] + fn it_should_be_hashable() { + let a: LabelSet = (label_name!("x"), LabelValue::new("1")).into(); + + let mut hasher = DefaultHasher::new(); + + a.hash(&mut hasher); + } + + #[test] + fn it_should_implement_clone() { + let a: LabelSet = (label_name!("x"), LabelValue::new("1")).into(); + let _unused = a.clone(); + } + + #[test] + fn it_should_check_if_empty() { + let empty_set = LabelSet::empty(); + assert!(empty_set.is_empty()); + } + + #[test] + fn it_should_check_if_non_empty() { + let non_empty_set: LabelSet = (label_name!("label"), LabelValue::new("value")).into(); + assert!(!non_empty_set.is_empty()); + } + + #[test] + fn it_should_create_an_empty_label_set() { + let empty_set = LabelSet::empty(); + assert!(empty_set.is_empty()); + } + + #[test] + fn it_should_check_if_contains_specific_label_pair() { + let label_set: LabelSet = vec![("service", "tracker"), ("protocol", "http")].into(); + + // Test existing pair + assert!(label_set.contains_pair(&LabelName::new("service"), &LabelValue::new("tracker"))); + assert!(label_set.contains_pair(&LabelName::new("protocol"), &LabelValue::new("http"))); + + // Test non-existing name + assert!(!label_set.contains_pair(&LabelName::new("missing"), &LabelValue::new("value"))); + + // Test existing name with wrong value + assert!(!label_set.contains_pair(&LabelName::new("service"), &LabelValue::new("wrong"))); + } + + #[test] + fn it_should_match_against_criteria() { + let label_set: LabelSet = vec![("service", "tracker"), ("protocol", "http"), ("version", "v1")].into(); + + // Empty criteria should match any label set + assert!(label_set.matches(&LabelSet::empty())); + + // Single matching criterion + let single_criteria: LabelSet = vec![("service", "tracker")].into(); + assert!(label_set.matches(&single_criteria)); + + // Multiple matching criteria + let multiple_criteria: LabelSet = vec![("service", "tracker"), ("protocol", "http")].into(); + assert!(label_set.matches(&multiple_criteria)); + + // Non-matching criterion + let non_matching: LabelSet = vec![("service", "wrong")].into(); + assert!(!label_set.matches(&non_matching)); + + // Partially matching criteria (one matches, one doesn't) + let partial_matching: LabelSet = vec![("service", "tracker"), ("missing", "value")].into(); + assert!(!label_set.matches(&partial_matching)); + + // Criteria with label not in original set + let missing_label: LabelSet = vec![("missing_label", "value")].into(); + assert!(!label_set.matches(&missing_label)); + } + + #[test] + fn it_should_allow_iteration_over_label_pairs() { + let label_set: LabelSet = vec![("service", "tracker"), ("protocol", "http")].into(); + + let mut count = 0; + + for (name, value) in label_set.iter() { + count += 1; + // Verify we can access name and value + assert!(!name.to_string().is_empty()); + assert!(!value.to_string().is_empty()); + } + + assert_eq!(count, 2); + } + + #[test] + fn it_should_display_empty_label_set() { + let empty_set = LabelSet::empty(); + assert_eq!(empty_set.to_string(), "{}"); + } + + #[test] + fn it_should_serialize_empty_label_set_to_prometheus_format() { + let empty_set = LabelSet::empty(); + assert_eq!(empty_set.to_prometheus(), ""); + } + + #[test] + fn it_should_maintain_order_in_iteration() { + let label_set: LabelSet = vec![("z_label", "z_value"), ("a_label", "a_value"), ("m_label", "m_value")].into(); + + let mut labels: Vec = vec![]; + for (name, _) in label_set.iter() { + labels.push(name.to_string()); + } + + // Should be in alphabetical order + assert_eq!(labels, vec!["a_label", "m_label", "z_label"]); + } +} diff --git a/packages/metrics/src/label/value.rs b/packages/metrics/src/label/value.rs new file mode 100644 index 000000000..4f25844a8 --- /dev/null +++ b/packages/metrics/src/label/value.rs @@ -0,0 +1,103 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct LabelValue(String); + +impl LabelValue { + #[must_use] + pub fn new(value: &str) -> Self { + Self(value.to_owned()) + } + + /// Empty label values are ignored in Prometheus. + #[must_use] + pub fn ignore() -> Self { + Self(String::default()) + } +} + +impl PrometheusSerializable for LabelValue { + fn to_prometheus(&self) -> String { + self.0.clone() + } +} + +impl From for LabelValue { + fn from(value: String) -> Self { + Self(value) + } +} + +#[cfg(test)] +mod tests { + use std::collections::hash_map::DefaultHasher; + use std::hash::Hash; + + use crate::label::value::LabelValue; + use crate::prometheus::PrometheusSerializable; + + #[test] + fn it_serializes_to_prometheus() { + let label_value = LabelValue::new("value"); + assert_eq!(label_value.to_prometheus(), "value"); + } + + #[test] + fn it_could_be_initialized_from_str() { + let lv = LabelValue::new("abc"); + assert_eq!(lv.0, "abc"); + } + + #[test] + fn it_should_allow_to_create_an_ignored_label_value() { + let lv = LabelValue::ignore(); + assert_eq!(lv.0, ""); + } + + #[test] + fn it_should_be_converted_from_string() { + let s = String::from("foo"); + let lv: LabelValue = s.clone().into(); + assert_eq!(lv.0, s); + } + + #[test] + fn it_should_be_comparable() { + let a = LabelValue::new("x"); + let b = LabelValue::new("x"); + let c = LabelValue::new("y"); + + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn it_should_be_allow_ordering() { + let a = LabelValue::new("x"); + let b = LabelValue::new("y"); + + assert!(a < b); + } + + #[test] + fn it_should_be_hashable() { + let a = LabelValue::new("x"); + let mut hasher = DefaultHasher::new(); + a.hash(&mut hasher); + } + + #[test] + fn it_should_implement_clone() { + let a = LabelValue::new("x"); + let _unused = a.clone(); + } + + #[test] + fn it_should_implement_display() { + let a = LabelValue::new("x"); + assert_eq!(format!("{a}"), "x"); + } +} diff --git a/packages/metrics/src/lib.rs b/packages/metrics/src/lib.rs new file mode 100644 index 000000000..997cd3c8c --- /dev/null +++ b/packages/metrics/src/lib.rs @@ -0,0 +1,30 @@ +pub mod counter; +pub mod gauge; +pub mod label; +pub mod metric; +pub mod metric_collection; +pub mod prometheus; +pub mod sample; +pub mod sample_collection; +pub mod unit; + +pub const METRICS_TARGET: &str = "METRICS"; + +#[cfg(test)] +mod tests { + /// It removes leading and trailing whitespace from each line. + pub fn format_prometheus_output(output: &str) -> String { + output + .lines() + .map(str::trim_start) + .map(str::trim_end) + .collect::>() + .join("\n") + } + + pub fn sort_lines(s: &str) -> String { + let mut lines: Vec<&str> = s.split('\n').collect(); + lines.sort_unstable(); + lines.join("\n") + } +} diff --git a/packages/metrics/src/metric/aggregate/avg.rs b/packages/metrics/src/metric/aggregate/avg.rs new file mode 100644 index 000000000..95628450b --- /dev/null +++ b/packages/metrics/src/metric/aggregate/avg.rs @@ -0,0 +1,294 @@ +use crate::counter::Counter; +use crate::gauge::Gauge; +use crate::label::LabelSet; +use crate::metric::aggregate::sum::Sum; +use crate::metric::Metric; + +pub trait Avg { + type Output; + fn avg(&self, label_set_criteria: &LabelSet) -> Self::Output; +} + +impl Avg for Metric { + type Output = f64; + + fn avg(&self, label_set_criteria: &LabelSet) -> Self::Output { + let matching_samples = self.collect_matching_samples(label_set_criteria); + + if matching_samples.is_empty() { + return 0.0; + } + + let sum = self.sum(label_set_criteria); + + #[allow(clippy::cast_precision_loss)] + (sum as f64 / matching_samples.len() as f64) + } +} + +impl Avg for Metric { + type Output = f64; + + fn avg(&self, label_set_criteria: &LabelSet) -> Self::Output { + let matching_samples = self.collect_matching_samples(label_set_criteria); + + if matching_samples.is_empty() { + return 0.0; + } + + let sum = self.sum(label_set_criteria); + + #[allow(clippy::cast_precision_loss)] + (sum / matching_samples.len() as f64) + } +} + +#[cfg(test)] +mod tests { + + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::counter::Counter; + use crate::gauge::Gauge; + use crate::label::LabelSet; + use crate::metric::aggregate::avg::Avg; + use crate::metric::{Metric, MetricName}; + use crate::metric_name; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + + struct MetricBuilder { + sample_time: DurationSinceUnixEpoch, + name: MetricName, + samples: Vec>, + } + + impl Default for MetricBuilder { + fn default() -> Self { + Self { + sample_time: DurationSinceUnixEpoch::from_secs(1_743_552_000), + name: metric_name!("test_metric"), + samples: vec![], + } + } + } + + impl MetricBuilder { + fn with_sample(mut self, value: T, label_set: &LabelSet) -> Self { + let sample = Sample::new(value, self.sample_time, label_set.clone()); + self.samples.push(sample); + self + } + + fn build(self) -> Metric { + Metric::new( + self.name, + None, + None, + SampleCollection::new(self.samples).expect("invalid samples"), + ) + } + } + + fn counter_cases() -> Vec<(Metric, LabelSet, f64)> { + // (metric, label set criteria, expected_average_value) + vec![ + // Metric with one sample without label set + ( + MetricBuilder::default().with_sample(1.into(), &LabelSet::empty()).build(), + LabelSet::empty(), + 1.0, + ), + // Metric with one sample with a label set + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with two samples, different label sets, average all + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .with_sample(3.into(), &[("l2", "l2_value")].into()) + .build(), + LabelSet::empty(), + 2.0, // (1 + 3) / 2 = 2.0 + ), + // Metric with two samples, different label sets, average one + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .with_sample(2.into(), &[("l2", "l2_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with three samples, same label key, different label values, average by key + ( + MetricBuilder::default() + .with_sample(2.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(4.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .with_sample(6.into(), &[("l1", "l1_value"), ("lc", "lc_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 4.0, // (2 + 4 + 6) / 3 = 4.0 + ), + // Metric with two samples, different label values, average by subkey + ( + MetricBuilder::default() + .with_sample(5.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(7.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("la", "la_value")].into(), + 5.0, + ), + // Edge: Metric with no samples at all + (MetricBuilder::default().build(), LabelSet::empty(), 0.0), + // Edge: Metric with samples but no matching labels + ( + MetricBuilder::default() + .with_sample(5.into(), &[("foo", "bar")].into()) + .build(), + [("not", "present")].into(), + 0.0, + ), + // Edge: Metric with zero value + ( + MetricBuilder::default() + .with_sample(0.into(), &[("l3", "l3_value")].into()) + .build(), + [("l3", "l3_value")].into(), + 0.0, + ), + // Edge: Metric with a very large value + ( + MetricBuilder::default() + .with_sample((u64::MAX / 2).into(), &[("edge", "large1")].into()) + .with_sample((u64::MAX / 2).into(), &[("edge", "large2")].into()) + .build(), + LabelSet::empty(), + #[allow(clippy::cast_precision_loss)] + (u64::MAX as f64 / 2.0), // Average of (max/2) and (max/2) + ), + ] + } + + fn gauge_cases() -> Vec<(Metric, LabelSet, f64)> { + // (metric, label set criteria, expected_average_value) + vec![ + // Metric with one sample without label set + ( + MetricBuilder::default().with_sample(1.0.into(), &LabelSet::empty()).build(), + LabelSet::empty(), + 1.0, + ), + // Metric with one sample with a label set + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with two samples, different label sets, average all + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .with_sample(3.0.into(), &[("l2", "l2_value")].into()) + .build(), + LabelSet::empty(), + 2.0, // (1.0 + 3.0) / 2 = 2.0 + ), + // Metric with two samples, different label sets, average one + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .with_sample(2.0.into(), &[("l2", "l2_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with three samples, same label key, different label values, average by key + ( + MetricBuilder::default() + .with_sample(2.0.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(4.0.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .with_sample(6.0.into(), &[("l1", "l1_value"), ("lc", "lc_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 4.0, // (2.0 + 4.0 + 6.0) / 3 = 4.0 + ), + // Metric with two samples, different label values, average by subkey + ( + MetricBuilder::default() + .with_sample(5.0.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(7.0.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("la", "la_value")].into(), + 5.0, + ), + // Edge: Metric with no samples at all + (MetricBuilder::default().build(), LabelSet::empty(), 0.0), + // Edge: Metric with samples but no matching labels + ( + MetricBuilder::default() + .with_sample(5.0.into(), &[("foo", "bar")].into()) + .build(), + [("not", "present")].into(), + 0.0, + ), + // Edge: Metric with zero value + ( + MetricBuilder::default() + .with_sample(0.0.into(), &[("l3", "l3_value")].into()) + .build(), + [("l3", "l3_value")].into(), + 0.0, + ), + // Edge: Metric with negative values + ( + MetricBuilder::default() + .with_sample((-2.0).into(), &[("l4", "l4_value")].into()) + .with_sample(4.0.into(), &[("l5", "l5_value")].into()) + .build(), + LabelSet::empty(), + 1.0, // (-2.0 + 4.0) / 2 = 1.0 + ), + // Edge: Metric with decimal values + ( + MetricBuilder::default() + .with_sample(1.5.into(), &[("l6", "l6_value")].into()) + .with_sample(2.5.into(), &[("l7", "l7_value")].into()) + .build(), + LabelSet::empty(), + 2.0, // (1.5 + 2.5) / 2 = 2.0 + ), + ] + } + + #[test] + fn test_counter_cases() { + for (idx, (metric, criteria, expected_value)) in counter_cases().iter().enumerate() { + let avg = metric.avg(criteria); + + assert!( + (avg - expected_value).abs() <= f64::EPSILON, + "at case {idx}, expected avg to be {expected_value}, got {avg}" + ); + } + } + + #[test] + fn test_gauge_cases() { + for (idx, (metric, criteria, expected_value)) in gauge_cases().iter().enumerate() { + let avg = metric.avg(criteria); + + assert!( + (avg - expected_value).abs() <= f64::EPSILON, + "at case {idx}, expected avg to be {expected_value}, got {avg}" + ); + } + } +} diff --git a/packages/metrics/src/metric/aggregate/mod.rs b/packages/metrics/src/metric/aggregate/mod.rs new file mode 100644 index 000000000..1224a1f52 --- /dev/null +++ b/packages/metrics/src/metric/aggregate/mod.rs @@ -0,0 +1,2 @@ +pub mod avg; +pub mod sum; diff --git a/packages/metrics/src/metric/aggregate/sum.rs b/packages/metrics/src/metric/aggregate/sum.rs new file mode 100644 index 000000000..30c2819b7 --- /dev/null +++ b/packages/metrics/src/metric/aggregate/sum.rs @@ -0,0 +1,278 @@ +use crate::counter::Counter; +use crate::gauge::Gauge; +use crate::label::LabelSet; +use crate::metric::Metric; + +pub trait Sum { + type Output; + fn sum(&self, label_set_criteria: &LabelSet) -> Self::Output; +} + +impl Sum for Metric { + type Output = u64; + + fn sum(&self, label_set_criteria: &LabelSet) -> Self::Output { + self.sample_collection + .iter() + .filter(|(label_set, _measurement)| label_set.matches(label_set_criteria)) + .map(|(_label_set, measurement)| measurement.value().primitive()) + .sum() + } +} + +impl Sum for Metric { + type Output = f64; + + fn sum(&self, label_set_criteria: &LabelSet) -> Self::Output { + self.sample_collection + .iter() + .filter(|(label_set, _measurement)| label_set.matches(label_set_criteria)) + .map(|(_label_set, measurement)| measurement.value().primitive()) + .sum() + } +} + +#[cfg(test)] +mod tests { + + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::counter::Counter; + use crate::gauge::Gauge; + use crate::label::LabelSet; + use crate::metric::aggregate::sum::Sum; + use crate::metric::{Metric, MetricName}; + use crate::metric_name; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + + struct MetricBuilder { + sample_time: DurationSinceUnixEpoch, + name: MetricName, + samples: Vec>, + } + + impl Default for MetricBuilder { + fn default() -> Self { + Self { + sample_time: DurationSinceUnixEpoch::from_secs(1_743_552_000), + name: metric_name!("test_metric"), + samples: vec![], + } + } + } + + impl MetricBuilder { + fn with_sample(mut self, value: T, label_set: &LabelSet) -> Self { + let sample = Sample::new(value, self.sample_time, label_set.clone()); + self.samples.push(sample); + self + } + + fn build(self) -> Metric { + Metric::new( + self.name, + None, + None, + SampleCollection::new(self.samples).expect("invalid samples"), + ) + } + } + + fn counter_cases() -> Vec<(Metric, LabelSet, u64)> { + // (metric, label set criteria, expected_aggregate_value) + vec![ + // Metric with one sample without label set + ( + MetricBuilder::default().with_sample(1.into(), &LabelSet::empty()).build(), + LabelSet::empty(), + 1, + ), + // Metric with one sample with a label set + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1, + ), + // Metric with two samples, different label sets, sum all + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .with_sample(2.into(), &[("l2", "l2_value")].into()) + .build(), + LabelSet::empty(), + 3, + ), + // Metric with two samples, different label sets, sum one + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value")].into()) + .with_sample(2.into(), &[("l2", "l2_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1, + ), + // Metric with two samples, same label key, different label values, sum by key + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(2.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 3, + ), + // Metric with two samples, different label values, sum by subkey + ( + MetricBuilder::default() + .with_sample(1.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(2.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("la", "la_value")].into(), + 1, + ), + // Edge: Metric with no samples at all + (MetricBuilder::default().build(), LabelSet::empty(), 0), + // Edge: Metric with samples but no matching labels + ( + MetricBuilder::default() + .with_sample(5.into(), &[("foo", "bar")].into()) + .build(), + [("not", "present")].into(), + 0, + ), + // Edge: Metric with zero value + ( + MetricBuilder::default() + .with_sample(0.into(), &[("l3", "l3_value")].into()) + .build(), + [("l3", "l3_value")].into(), + 0, + ), + // Edge: Metric with a very large value + ( + MetricBuilder::default() + .with_sample(u64::MAX.into(), &LabelSet::empty()) + .build(), + LabelSet::empty(), + u64::MAX, + ), + ] + } + + fn gauge_cases() -> Vec<(Metric, LabelSet, f64)> { + // (metric, label set criteria, expected_aggregate_value) + vec![ + // Metric with one sample without label set + ( + MetricBuilder::default().with_sample(1.0.into(), &LabelSet::empty()).build(), + LabelSet::empty(), + 1.0, + ), + // Metric with one sample with a label set + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with two samples, different label sets, sum all + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .with_sample(2.0.into(), &[("l2", "l2_value")].into()) + .build(), + LabelSet::empty(), + 3.0, + ), + // Metric with two samples, different label sets, sum one + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value")].into()) + .with_sample(2.0.into(), &[("l2", "l2_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 1.0, + ), + // Metric with two samples, same label key, different label values, sum by key + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(2.0.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("l1", "l1_value")].into(), + 3.0, + ), + // Metric with two samples, different label values, sum by subkey + ( + MetricBuilder::default() + .with_sample(1.0.into(), &[("l1", "l1_value"), ("la", "la_value")].into()) + .with_sample(2.0.into(), &[("l1", "l1_value"), ("lb", "lb_value")].into()) + .build(), + [("la", "la_value")].into(), + 1.0, + ), + // Edge: Metric with no samples at all + (MetricBuilder::default().build(), LabelSet::empty(), 0.0), + // Edge: Metric with samples but no matching labels + ( + MetricBuilder::default() + .with_sample(5.0.into(), &[("foo", "bar")].into()) + .build(), + [("not", "present")].into(), + 0.0, + ), + // Edge: Metric with zero value + ( + MetricBuilder::default() + .with_sample(0.0.into(), &[("l3", "l3_value")].into()) + .build(), + [("l3", "l3_value")].into(), + 0.0, + ), + // Edge: Metric with negative values + ( + MetricBuilder::default() + .with_sample((-2.0).into(), &[("l4", "l4_value")].into()) + .with_sample(3.0.into(), &[("l5", "l5_value")].into()) + .build(), + LabelSet::empty(), + 1.0, + ), + // Edge: Metric with a very large value + ( + MetricBuilder::default() + .with_sample(f64::MAX.into(), &LabelSet::empty()) + .build(), + LabelSet::empty(), + f64::MAX, + ), + ] + } + + #[test] + fn test_counter_cases() { + for (idx, (metric, criteria, expected_value)) in counter_cases().iter().enumerate() { + let sum = metric.sum(criteria); + + assert_eq!( + sum, *expected_value, + "at case {idx}, expected sum to be {expected_value}, got {sum}" + ); + } + } + + #[test] + fn test_gauge_cases() { + for (idx, (metric, criteria, expected_value)) in gauge_cases().iter().enumerate() { + let sum = metric.sum(criteria); + + assert!( + (sum - expected_value).abs() <= f64::EPSILON, + "at case {idx}, expected sum to be {expected_value}, got {sum}" + ); + } + } +} diff --git a/packages/metrics/src/metric/description.rs b/packages/metrics/src/metric/description.rs new file mode 100644 index 000000000..6a0ca3432 --- /dev/null +++ b/packages/metrics/src/metric/description.rs @@ -0,0 +1,42 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct MetricDescription(String); + +impl MetricDescription { + #[must_use] + pub fn new(name: &str) -> Self { + Self(name.to_owned()) + } +} + +impl PrometheusSerializable for MetricDescription { + fn to_prometheus(&self) -> String { + self.0.clone() + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_be_created_from_a_string_reference() { + let metric = MetricDescription::new("Metric description"); + assert_eq!(metric.0, "Metric description"); + } + + #[test] + fn it_serializes_to_prometheus() { + let label_value = MetricDescription::new("name"); + assert_eq!(label_value.to_prometheus(), "name"); + } + + #[test] + fn it_should_be_displayed() { + let metric = MetricDescription::new("Metric description"); + assert_eq!(metric.to_string(), "Metric description"); + } +} diff --git a/packages/metrics/src/metric/mod.rs b/packages/metrics/src/metric/mod.rs new file mode 100644 index 000000000..6bc1a6075 --- /dev/null +++ b/packages/metrics/src/metric/mod.rs @@ -0,0 +1,365 @@ +pub mod aggregate; +pub mod description; +pub mod name; + +use serde::{Deserialize, Serialize}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; +use super::sample_collection::SampleCollection; +use crate::gauge::Gauge; +use crate::metric::description::MetricDescription; +use crate::sample::Measurement; +use crate::unit::Unit; + +pub type MetricName = name::MetricName; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Metric { + name: MetricName, + + #[serde(rename = "unit")] + opt_unit: Option, + + #[serde(rename = "description")] + opt_description: Option, + + #[serde(rename = "samples")] + sample_collection: SampleCollection, +} + +impl Metric { + #[must_use] + pub fn new( + name: MetricName, + opt_unit: Option, + opt_description: Option, + samples: SampleCollection, + ) -> Self { + Self { + name, + opt_unit, + opt_description, + sample_collection: samples, + } + } + + /// # Panics + /// + /// This function will panic if the empty sample collection cannot be created. + #[must_use] + pub fn new_empty_with_name(name: MetricName) -> Self { + Self { + name, + opt_unit: None, + opt_description: None, + sample_collection: SampleCollection::new(vec![]).expect("Empty sample collection creation should not fail"), + } + } + + #[must_use] + pub fn name(&self) -> &MetricName { + &self.name + } + + #[must_use] + pub fn get_sample_data(&self, label_set: &LabelSet) -> Option<&Measurement> { + self.sample_collection.get(label_set) + } + + #[must_use] + pub fn number_of_samples(&self) -> usize { + self.sample_collection.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.sample_collection.is_empty() + } + + #[must_use] + pub fn collect_matching_samples( + &self, + label_set_criteria: &LabelSet, + ) -> Vec<(&crate::label::LabelSet, &crate::sample::Measurement)> { + self.sample_collection + .iter() + .filter(|(label_set, _measurement)| label_set.matches(label_set_criteria)) + .collect() + } +} + +impl Metric { + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + self.sample_collection.increment(label_set, time); + } + + pub fn absolute(&mut self, label_set: &LabelSet, value: u64, time: DurationSinceUnixEpoch) { + self.sample_collection.absolute(label_set, value, time); + } +} + +impl Metric { + pub fn set(&mut self, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + self.sample_collection.set(label_set, value, time); + } + + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + self.sample_collection.increment(label_set, time); + } + + pub fn decrement(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + self.sample_collection.decrement(label_set, time); + } +} + +enum PrometheusType { + Counter, + Gauge, +} + +impl PrometheusSerializable for PrometheusType { + fn to_prometheus(&self) -> String { + match self { + PrometheusType::Counter => "counter".to_string(), + PrometheusType::Gauge => "gauge".to_string(), + } + } +} + +impl Metric { + #[must_use] + fn prometheus_help_line(&self) -> String { + if let Some(description) = &self.opt_description { + format!("# HELP {} {}", self.name.to_prometheus(), description.to_prometheus()) + } else { + String::new() + } + } + + #[must_use] + fn prometheus_type_line(&self, prometheus_type: &PrometheusType) -> String { + format!("# TYPE {} {}", self.name.to_prometheus(), prometheus_type.to_prometheus()) + } + + #[must_use] + fn prometheus_sample_line(&self, label_set: &LabelSet, measurement: &Measurement) -> String { + format!( + "{}{} {}", + self.name.to_prometheus(), + label_set.to_prometheus(), + measurement.to_prometheus() + ) + } + + #[must_use] + fn prometheus_samples(&self) -> String { + self.sample_collection + .iter() + .map(|(label_set, measurement)| self.prometheus_sample_line(label_set, measurement)) + .collect::>() + .join("\n") + } + + fn to_prometheus(&self, prometheus_type: &PrometheusType) -> String { + let help_line = self.prometheus_help_line(); + let type_line = self.prometheus_type_line(prometheus_type); + let samples = self.prometheus_samples(); + + format!("{help_line}\n{type_line}\n{samples}") + } +} + +impl PrometheusSerializable for Metric { + fn to_prometheus(&self) -> String { + self.to_prometheus(&PrometheusType::Counter) + } +} + +impl PrometheusSerializable for Metric { + fn to_prometheus(&self) -> String { + self.to_prometheus(&PrometheusType::Gauge) + } +} + +#[cfg(test)] +mod tests { + mod for_generic_metrics { + use super::super::*; + use crate::gauge::Gauge; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::{label_name, metric_name}; + + #[test] + fn it_should_be_empty_when_it_does_not_have_any_sample() { + let name = metric_name!("test_metric"); + + let samples = SampleCollection::::default(); + + let metric = Metric::::new(name.clone(), None, None, samples); + + assert!(metric.is_empty()); + } + + fn counter_metric_with_one_sample() -> Metric { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let name = metric_name!("test_metric"); + + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + + let samples = SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set.clone())]).unwrap(); + + Metric::::new(name.clone(), None, None, samples) + } + + #[test] + fn it_should_return_the_number_of_samples() { + assert_eq!(counter_metric_with_one_sample().number_of_samples(), 1); + } + + #[test] + fn it_should_return_zero_number_of_samples_for_an_empty_metric() { + let name = metric_name!("test_metric"); + + let samples = SampleCollection::::default(); + + let metric = Metric::::new(name.clone(), None, None, samples); + + assert_eq!(metric.number_of_samples(), 0); + } + } + + mod for_counter_metrics { + use super::super::*; + use crate::counter::Counter; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::{label_name, metric_name}; + + #[test] + fn it_should_be_created_from_its_name_and_a_collection_of_samples() { + let name = metric_name!("test_metric"); + + let samples = SampleCollection::::default(); + + let _metric = Metric::::new(name, None, None, samples); + } + + #[test] + fn it_should_allow_incrementing_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let name = metric_name!("test_metric"); + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + let samples = SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]).unwrap(); + let mut metric = Metric::::new(name.clone(), None, None, samples); + + metric.increment(&label_set, time); + + assert_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1); + } + + #[test] + fn it_should_allow_setting_to_an_absolute_value() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let name = metric_name!("test_metric"); + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + let samples = SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]).unwrap(); + let mut metric = Metric::::new(name.clone(), None, None, samples); + + metric.absolute(&label_set, 1, time); + + assert_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1); + } + } + + mod for_gauge_metrics { + use approx::assert_relative_eq; + + use super::super::*; + use crate::gauge::Gauge; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::{label_name, metric_name}; + + #[test] + fn it_should_be_created_from_its_name_and_a_collection_of_samples() { + let name = metric_name!("test_metric"); + + let samples = SampleCollection::::default(); + + let _metric = Metric::::new(name, None, None, samples); + } + + #[test] + fn it_should_allow_incrementing_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let name = metric_name!("test_metric"); + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + let samples = SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]).unwrap(); + let mut metric = Metric::::new(name.clone(), None, None, samples); + + metric.increment(&label_set, time); + + assert_relative_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1.0); + } + + #[test] + fn it_should_allow_decrement_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let name = metric_name!("test_metric"); + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + let samples = SampleCollection::new(vec![Sample::new(Gauge::new(1.0), time, label_set.clone())]).unwrap(); + let mut metric = Metric::::new(name.clone(), None, None, samples); + + metric.decrement(&label_set, time); + + assert_relative_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 0.0); + } + + #[test] + fn it_should_allow_setting_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let name = metric_name!("test_metric"); + let label_set: LabelSet = [(label_name!("server_binding_protocol"), LabelValue::new("http"))].into(); + let samples = SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]).unwrap(); + let mut metric = Metric::::new(name.clone(), None, None, samples); + + metric.set(&label_set, 1.0, time); + + assert_relative_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1.0); + } + } + + mod for_prometheus_serialization { + use super::super::*; + use crate::counter::Counter; + use crate::metric_name; + + #[test] + fn it_should_return_empty_string_for_prometheus_help_line_when_description_is_none() { + let name = metric_name!("test_metric"); + let samples = SampleCollection::::default(); + let metric = Metric::::new(name, None, None, samples); + + let help_line = metric.prometheus_help_line(); + + assert_eq!(help_line, String::new()); + } + + #[test] + fn it_should_return_formatted_help_line_for_prometheus_when_description_is_some() { + let name = metric_name!("test_metric"); + let description = MetricDescription::new("This is a test metric description"); + let samples = SampleCollection::::default(); + let metric = Metric::::new(name, None, Some(description), samples); + + let help_line = metric.prometheus_help_line(); + + assert_eq!(help_line, "# HELP test_metric This is a test metric description"); + } + } +} diff --git a/packages/metrics/src/metric/name.rs b/packages/metrics/src/metric/name.rs new file mode 100644 index 000000000..09c8c9e6d --- /dev/null +++ b/packages/metrics/src/metric/name.rs @@ -0,0 +1,97 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct MetricName(String); + +impl MetricName { + /// Creates a new `MetricName` instance. + /// + /// # Panics + /// + /// Panics if the provided name is empty. + #[must_use] + pub fn new(name: &str) -> Self { + assert!(!name.is_empty(), "Metric name cannot be empty."); + Self(name.to_owned()) + } +} + +impl PrometheusSerializable for MetricName { + fn to_prometheus(&self) -> String { + // Metric names may contain ASCII letters, digits, underscores, and + // colons. It must match the regex [a-zA-Z_:][a-zA-Z0-9_:]*. + // If the metric name starts with, or contains, an invalid character: + // replace character with underscore. + + self.0 + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + if c.is_ascii_alphabetic() || c == '_' || c == ':' { + c + } else { + '_' + } + } else if c.is_ascii_alphanumeric() || c == '_' || c == ':' { + c + } else { + '_' + } + }) + .collect() + } +} + +#[macro_export] +macro_rules! metric_name { + ("") => { + compile_error!("Metric name cannot be empty"); + }; + ($name:literal) => { + $crate::metric::name::MetricName::new($name) + }; + ($name:ident) => { + $crate::metric::name::MetricName::new($name) + }; +} + +#[cfg(test)] +mod tests { + + mod serialization_of_metric_name_to_prometheus { + + use crate::metric::name::MetricName; + use crate::prometheus::PrometheusSerializable; + + #[test] + fn valid_names_in_prometheus() { + assert_eq!(metric_name!("valid_name").to_prometheus(), "valid_name"); + assert_eq!(metric_name!("_leading_underscore").to_prometheus(), "_leading_underscore"); + assert_eq!(metric_name!(":leading_colon").to_prometheus(), ":leading_colon"); + assert_eq!(metric_name!("v123").to_prometheus(), "v123"); // leading lowercase + assert_eq!(metric_name!("V123").to_prometheus(), "V123"); // leading lowercase + } + + #[test] + fn names_that_need_changes_in_prometheus() { + assert_eq!(metric_name!("9invalid_start").to_prometheus(), "_invalid_start"); + assert_eq!(metric_name!("@test").to_prometheus(), "_test"); + assert_eq!(metric_name!("invalid-char").to_prometheus(), "invalid_char"); + assert_eq!(metric_name!("spaces are bad").to_prometheus(), "spaces_are_bad"); + assert_eq!(metric_name!("a!b@c#d$e%f^g&h*i(j)").to_prometheus(), "a_b_c_d_e_f_g_h_i_j_"); + assert_eq!(metric_name!("my:metric/version").to_prometheus(), "my:metric_version"); + assert_eq!(metric_name!("!@#$%^&*()").to_prometheus(), "__________"); + assert_eq!(metric_name!("ñaca©").to_prometheus(), "_aca_"); + } + + #[test] + #[should_panic(expected = "Metric name cannot be empty.")] + fn empty_name() { + let _name = MetricName::new(""); + } + } +} diff --git a/packages/metrics/src/metric_collection/aggregate/avg.rs b/packages/metrics/src/metric_collection/aggregate/avg.rs new file mode 100644 index 000000000..0aef4e325 --- /dev/null +++ b/packages/metrics/src/metric_collection/aggregate/avg.rs @@ -0,0 +1,212 @@ +use crate::counter::Counter; +use crate::gauge::Gauge; +use crate::label::LabelSet; +use crate::metric::aggregate::avg::Avg as MetricAvgTrait; +use crate::metric::MetricName; +use crate::metric_collection::{MetricCollection, MetricKindCollection}; + +pub trait Avg { + fn avg(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option; +} + +impl Avg for MetricCollection { + fn avg(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + if let Some(value) = self.counters.avg(metric_name, label_set_criteria) { + return Some(value); + } + + if let Some(value) = self.gauges.avg(metric_name, label_set_criteria) { + return Some(value); + } + + None + } +} + +impl Avg for MetricKindCollection { + fn avg(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + self.metrics.get(metric_name).map(|metric| metric.avg(label_set_criteria)) + } +} + +impl Avg for MetricKindCollection { + fn avg(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + self.metrics.get(metric_name).map(|metric| metric.avg(label_set_criteria)) + } +} + +#[cfg(test)] +mod tests { + + mod it_should_allow_averaging_all_metric_samples_containing_some_given_labels { + + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelValue; + use crate::label_name; + use crate::metric_collection::aggregate::avg::Avg; + + #[test] + fn type_counter_with_two_samples() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_counter"); + + let mut collection = MetricCollection::default(); + + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + // Two samples with value 1 each, average should be 1.0 + assert_eq!(collection.avg(&metric_name, &LabelSet::empty()), Some(1.0)); + assert_eq!( + collection.avg(&metric_name, &(label_name!("label_1"), LabelValue::new("value_1")).into()), + Some(1.0) + ); + } + + #[test] + fn type_counter_with_different_values() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_counter"); + + let mut collection = MetricCollection::default(); + + // First increment: value goes from 0 to 1 + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + // Second increment on the same label: value goes from 1 to 2 + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + DurationSinceUnixEpoch::from_secs(2), + ) + .unwrap(); + + // Create another counter with a different value + collection + .set_counter( + &metric_name!("test_counter"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + 4, + DurationSinceUnixEpoch::from_secs(3), + ) + .unwrap(); + + // Average of 2 and 4 should be 3.0 + assert_eq!(collection.avg(&metric_name, &LabelSet::empty()), Some(3.0)); + assert_eq!( + collection.avg(&metric_name, &(label_name!("label_1"), LabelValue::new("value_1")).into()), + Some(2.0) + ); + assert_eq!( + collection.avg(&metric_name, &(label_name!("label_2"), LabelValue::new("value_2")).into()), + Some(4.0) + ); + } + + #[test] + fn type_gauge_with_two_samples() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_gauge"); + + let mut collection = MetricCollection::default(); + + collection + .set_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + 2.0, + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + collection + .set_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + 4.0, + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + // Average of 2.0 and 4.0 should be 3.0 + assert_eq!(collection.avg(&metric_name, &LabelSet::empty()), Some(3.0)); + assert_eq!( + collection.avg(&metric_name, &(label_name!("label_1"), LabelValue::new("value_1")).into()), + Some(2.0) + ); + } + + #[test] + fn type_gauge_with_negative_values() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_gauge"); + + let mut collection = MetricCollection::default(); + + collection + .set_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + -2.0, + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + collection + .set_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + 6.0, + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + // Average of -2.0 and 6.0 should be 2.0 + assert_eq!(collection.avg(&metric_name, &LabelSet::empty()), Some(2.0)); + } + + #[test] + fn nonexistent_metric() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let collection = MetricCollection::default(); + + assert_eq!(collection.avg(&metric_name!("nonexistent"), &LabelSet::empty()), None); + } + } +} diff --git a/packages/metrics/src/metric_collection/aggregate/mod.rs b/packages/metrics/src/metric_collection/aggregate/mod.rs new file mode 100644 index 000000000..1224a1f52 --- /dev/null +++ b/packages/metrics/src/metric_collection/aggregate/mod.rs @@ -0,0 +1,2 @@ +pub mod avg; +pub mod sum; diff --git a/packages/metrics/src/metric_collection/aggregate/sum.rs b/packages/metrics/src/metric_collection/aggregate/sum.rs new file mode 100644 index 000000000..3285fa8f1 --- /dev/null +++ b/packages/metrics/src/metric_collection/aggregate/sum.rs @@ -0,0 +1,118 @@ +use crate::counter::Counter; +use crate::gauge::Gauge; +use crate::label::LabelSet; +use crate::metric::aggregate::sum::Sum as MetricSumTrait; +use crate::metric::MetricName; +use crate::metric_collection::{MetricCollection, MetricKindCollection}; + +pub trait Sum { + fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option; +} + +impl Sum for MetricCollection { + fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + if let Some(value) = self.counters.sum(metric_name, label_set_criteria) { + return Some(value); + } + + if let Some(value) = self.gauges.sum(metric_name, label_set_criteria) { + return Some(value); + } + + None + } +} + +impl Sum for MetricKindCollection { + fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + #[allow(clippy::cast_precision_loss)] + self.metrics + .get(metric_name) + .map(|metric| metric.sum(label_set_criteria) as f64) + } +} + +impl Sum for MetricKindCollection { + fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option { + self.metrics.get(metric_name).map(|metric| metric.sum(label_set_criteria)) + } +} + +#[cfg(test)] +mod tests { + + mod it_should_allow_summing_all_metric_samples_containing_some_given_labels { + + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelValue; + use crate::label_name; + use crate::metric_collection::aggregate::sum::Sum; + + #[test] + fn type_counter_with_two_samples() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_counter"); + + let mut collection = MetricCollection::default(); + + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + collection + .increment_counter( + &metric_name!("test_counter"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + assert_eq!(collection.sum(&metric_name, &LabelSet::empty()), Some(2.0)); + assert_eq!( + collection.sum(&metric_name, &(label_name!("label_1"), LabelValue::new("value_1")).into()), + Some(1.0) + ); + } + + #[test] + fn type_gauge_with_two_samples() { + use crate::label::LabelSet; + use crate::metric_collection::MetricCollection; + use crate::metric_name; + + let metric_name = metric_name!("test_gauge"); + + let mut collection = MetricCollection::default(); + + collection + .increment_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_1"), LabelValue::new("value_1")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + collection + .increment_gauge( + &metric_name!("test_gauge"), + &(label_name!("label_2"), LabelValue::new("value_2")).into(), + DurationSinceUnixEpoch::from_secs(1), + ) + .unwrap(); + + assert_eq!(collection.sum(&metric_name, &LabelSet::empty()), Some(2.0)); + assert_eq!( + collection.sum(&metric_name, &(label_name!("label_1"), LabelValue::new("value_1")).into()), + Some(1.0) + ); + } + } +} diff --git a/packages/metrics/src/metric_collection/mod.rs b/packages/metrics/src/metric_collection/mod.rs new file mode 100644 index 000000000..e183236aa --- /dev/null +++ b/packages/metrics/src/metric_collection/mod.rs @@ -0,0 +1,1196 @@ +pub mod aggregate; + +use std::collections::{HashMap, HashSet}; + +use serde::ser::{SerializeSeq, Serializer}; +use serde::{Deserialize, Deserializer, Serialize}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::metric::{Metric, MetricName}; +use super::prometheus::PrometheusSerializable; +use crate::metric::description::MetricDescription; +use crate::sample_collection::SampleCollection; +use crate::unit::Unit; +use crate::METRICS_TARGET; + +// code-review: serialize in a deterministic order? For example: +// - First the counter metrics ordered by name. +// - Then the gauge metrics ordered by name. + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MetricCollection { + counters: MetricKindCollection, + gauges: MetricKindCollection, +} + +impl MetricCollection { + /// # Errors + /// + /// Returns an error if there are duplicate metric names across counters and + /// gauges. + pub fn new(counters: MetricKindCollection, gauges: MetricKindCollection) -> Result { + // Check for name collisions across metric types + let counter_names: HashSet<_> = counters.names().collect(); + let gauge_names: HashSet<_> = gauges.names().collect(); + + if !counter_names.is_disjoint(&gauge_names) { + return Err(Error::MetricNameCollisionInConstructor { + counter_names: counter_names.iter().map(std::string::ToString::to_string).collect(), + gauge_names: gauge_names.iter().map(std::string::ToString::to_string).collect(), + }); + } + + Ok(Self { counters, gauges }) + } + + /// Merges another `MetricCollection` into this one. + /// + /// # Errors + /// + /// Returns an error if a metric name already exists in the current collection. + pub fn merge(&mut self, other: &Self) -> Result<(), Error> { + self.check_cross_type_collision(other)?; + self.counters.merge(&other.counters)?; + self.gauges.merge(&other.gauges)?; + Ok(()) + } + + /// Returns a set of all metric names in this collection. + fn collect_names(&self) -> HashSet { + self.counters.names().chain(self.gauges.names()).cloned().collect() + } + + /// Checks for name collisions between this collection and another one. + fn check_cross_type_collision(&self, other: &Self) -> Result<(), Error> { + let self_names: HashSet<_> = self.collect_names(); + let other_names: HashSet<_> = other.collect_names(); + + let cross_type_collisions = self_names.intersection(&other_names).next(); + + if let Some(name) = cross_type_collisions { + return Err(Error::MetricNameCollisionInMerge { + metric_name: (*name).clone(), + }); + } + + Ok(()) + } + + // Counter-specific methods + + pub fn describe_counter(&mut self, name: &MetricName, opt_unit: Option, opt_description: Option) { + tracing::info!(target: METRICS_TARGET, type = "counter", name = name.to_string(), unit = ?opt_unit, description = ?opt_description); + + let metric = Metric::::new(name.clone(), opt_unit, opt_description, SampleCollection::default()); + + self.counters.insert(metric); + } + + #[must_use] + pub fn contains_counter(&self, name: &MetricName) -> bool { + self.counters.metrics.contains_key(name) + } + + #[must_use] + pub fn get_counter_value(&self, name: &MetricName, label_set: &LabelSet) -> Option { + self.counters.get_value(name, label_set) + } + + /// Increases the counter for the given metric name and labels. + /// + /// # Errors + /// + /// Return an error if a metrics of a different type with the same name + /// already exists. + pub fn increment_counter( + &mut self, + name: &MetricName, + label_set: &LabelSet, + time: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + if self.gauges.metrics.contains_key(name) { + return Err(Error::MetricNameCollisionAdding { + metric_name: name.clone(), + }); + } + + self.counters.increment(name, label_set, time); + + Ok(()) + } + + /// Sets the counter for the given metric name and labels. + /// + /// # Errors + /// + /// Return an error if a metrics of a different type with the same name + /// already exists. + pub fn set_counter( + &mut self, + name: &MetricName, + label_set: &LabelSet, + value: u64, + time: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + if self.gauges.metrics.contains_key(name) { + return Err(Error::MetricNameCollisionAdding { + metric_name: name.clone(), + }); + } + + self.counters.absolute(name, label_set, value, time); + + Ok(()) + } + + // Gauge-specific methods + + pub fn describe_gauge(&mut self, name: &MetricName, opt_unit: Option, opt_description: Option) { + tracing::info!(target: METRICS_TARGET, type = "gauge", name = name.to_string(), unit = ?opt_unit, description = ?opt_description); + + let metric = Metric::::new(name.clone(), opt_unit, opt_description, SampleCollection::default()); + + self.gauges.insert(metric); + } + + #[must_use] + pub fn contains_gauge(&self, name: &MetricName) -> bool { + self.gauges.metrics.contains_key(name) + } + + #[must_use] + pub fn get_gauge_value(&self, name: &MetricName, label_set: &LabelSet) -> Option { + self.gauges.get_value(name, label_set) + } + + /// # Errors + /// + /// Return an error if a metrics of a different type with the same name + /// already exists. + pub fn set_gauge( + &mut self, + name: &MetricName, + label_set: &LabelSet, + value: f64, + time: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + if self.counters.metrics.contains_key(name) { + return Err(Error::MetricNameCollisionAdding { + metric_name: name.clone(), + }); + } + + self.gauges.set(name, label_set, value, time); + + Ok(()) + } + + /// # Errors + /// + /// Return an error if a metrics of a different type with the same name + /// already exists. + pub fn increment_gauge( + &mut self, + name: &MetricName, + label_set: &LabelSet, + time: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + if self.counters.metrics.contains_key(name) { + return Err(Error::MetricNameCollisionAdding { + metric_name: name.clone(), + }); + } + + self.gauges.increment(name, label_set, time); + + Ok(()) + } + + /// # Errors + /// + /// Return an error if a metrics of a different type with the same name + /// already exists. + pub fn decrement_gauge( + &mut self, + name: &MetricName, + label_set: &LabelSet, + time: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + if self.counters.metrics.contains_key(name) { + return Err(Error::MetricNameCollisionAdding { + metric_name: name.clone(), + }); + } + + self.gauges.decrement(name, label_set, time); + + Ok(()) + } +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("Metric names must be unique across all metrics types.")] + MetricNameCollisionInConstructor { + counter_names: Vec, + gauge_names: Vec, + }, + + #[error("Found duplicate metric name in list. Metric names must be unique across all metrics types.")] + DuplicateMetricNameInList { metric_name: MetricName }, + + #[error("Cannot merge metric '{metric_name}': it already exists in the current collection")] + MetricNameCollisionInMerge { metric_name: MetricName }, + + #[error("Cannot create metric with name '{metric_name}': another metric with this name already exists")] + MetricNameCollisionAdding { metric_name: MetricName }, +} + +/// Implements serialization for `MetricCollection`. +impl Serialize for MetricCollection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + #[serde(tag = "type", rename_all = "lowercase")] + enum SerializableMetric<'a> { + Counter(&'a Metric), + Gauge(&'a Metric), + } + + let mut seq = serializer.serialize_seq(Some(self.counters.metrics.len() + self.gauges.metrics.len()))?; + + for metric in self.counters.metrics.values() { + seq.serialize_element(&SerializableMetric::Counter(metric))?; + } + + for metric in self.gauges.metrics.values() { + seq.serialize_element(&SerializableMetric::Gauge(metric))?; + } + + seq.end() + } +} + +impl<'de> Deserialize<'de> for MetricCollection { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(tag = "type", rename_all = "lowercase")] + enum MetricPayload { + Counter(Metric), + Gauge(Metric), + } + + let payload = Vec::::deserialize(deserializer)?; + + let mut counters = Vec::new(); + let mut gauges = Vec::new(); + + for metric in payload { + match metric { + MetricPayload::Counter(counter) => counters.push(counter), + MetricPayload::Gauge(gauge) => gauges.push(gauge), + } + } + + let counters = MetricKindCollection::new(counters).map_err(serde::de::Error::custom)?; + let gauges = MetricKindCollection::new(gauges).map_err(serde::de::Error::custom)?; + + let metric_collection = MetricCollection::new(counters, gauges).map_err(serde::de::Error::custom)?; + + Ok(metric_collection) + } +} + +impl PrometheusSerializable for MetricCollection { + fn to_prometheus(&self) -> String { + self.counters + .metrics + .values() + .filter(|metric| !metric.is_empty()) + .map(Metric::::to_prometheus) + .chain( + self.gauges + .metrics + .values() + .filter(|metric| !metric.is_empty()) + .map(Metric::::to_prometheus), + ) + .collect::>() + .join("\n\n") + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MetricKindCollection { + metrics: HashMap>, +} + +impl MetricKindCollection { + /// Creates a new `MetricKindCollection` from a vector of metrics + /// + /// # Errors + /// + /// Returns an error if duplicate metric names are passed. + pub fn new(metrics: Vec>) -> Result { + let mut map = HashMap::with_capacity(metrics.len()); + + for metric in metrics { + let metric_name = metric.name().clone(); + + if let Some(_old_metric) = map.insert(metric.name().clone(), metric) { + return Err(Error::DuplicateMetricNameInList { metric_name }); + } + } + + Ok(Self { metrics: map }) + } + + /// Returns an iterator over all metric names in this collection. + pub fn names(&self) -> impl Iterator { + self.metrics.keys() + } + + pub fn insert_if_absent(&mut self, metric: Metric) { + if !self.metrics.contains_key(metric.name()) { + self.insert(metric); + } + } + + pub fn insert(&mut self, metric: Metric) { + self.metrics.insert(metric.name().clone(), metric); + } +} + +impl MetricKindCollection { + /// Merges another `MetricKindCollection` into this one. + /// + /// # Errors + /// + /// Returns an error if a metric name already exists in the current collection. + pub fn merge(&mut self, other: &Self) -> Result<(), Error> { + self.check_for_name_collision(other)?; + + for (metric_name, metric) in &other.metrics { + self.metrics.insert(metric_name.clone(), metric.clone()); + } + + Ok(()) + } + + fn check_for_name_collision(&self, other: &Self) -> Result<(), Error> { + for metric_name in other.metrics.keys() { + if self.metrics.contains_key(metric_name) { + return Err(Error::MetricNameCollisionInMerge { + metric_name: metric_name.clone(), + }); + } + } + + Ok(()) + } +} + +impl MetricKindCollection { + /// Increments the counter for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist. + pub fn increment(&mut self, name: &MetricName, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let metric = Metric::::new_empty_with_name(name.clone()); + + self.insert_if_absent(metric); + + let metric = self.metrics.get_mut(name).expect("Counter metric should exist"); + + metric.increment(label_set, time); + } + + /// Sets the counter to an absolute value for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist. + pub fn absolute(&mut self, name: &MetricName, label_set: &LabelSet, value: u64, time: DurationSinceUnixEpoch) { + let metric = Metric::::new_empty_with_name(name.clone()); + + self.insert_if_absent(metric); + + let metric = self.metrics.get_mut(name).expect("Counter metric should exist"); + + metric.absolute(label_set, value, time); + } + + #[must_use] + pub fn get_value(&self, name: &MetricName, label_set: &LabelSet) -> Option { + self.metrics + .get(name) + .and_then(|metric| metric.get_sample_data(label_set)) + .map(|sample| sample.value().clone()) + } +} + +impl MetricKindCollection { + /// Sets the gauge for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist and it could not be created. + pub fn set(&mut self, name: &MetricName, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + let metric = Metric::::new_empty_with_name(name.clone()); + + self.insert_if_absent(metric); + + let metric = self.metrics.get_mut(name).expect("Gauge metric should exist"); + + metric.set(label_set, value, time); + } + + /// Increments the gauge for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist and it could not be created. + pub fn increment(&mut self, name: &MetricName, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let metric = Metric::::new_empty_with_name(name.clone()); + + self.insert_if_absent(metric); + + let metric = self.metrics.get_mut(name).expect("Gauge metric should exist"); + + metric.increment(label_set, time); + } + + /// Decrements the gauge for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist and it could not be created. + pub fn decrement(&mut self, name: &MetricName, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let metric = Metric::::new_empty_with_name(name.clone()); + + self.insert_if_absent(metric); + + let metric = self.metrics.get_mut(name).expect("Gauge metric should exist"); + + metric.decrement(label_set, time); + } + + #[must_use] + pub fn get_value(&self, name: &MetricName, label_set: &LabelSet) -> Option { + self.metrics + .get(name) + .and_then(|metric| metric.get_sample_data(label_set)) + .map(|sample| sample.value().clone()) + } +} + +#[cfg(test)] +mod tests { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + use crate::tests::{format_prometheus_output, sort_lines}; + use crate::{label_name, metric_name}; + + /// Fixture for testing serialization and deserialization of `MetricCollection`. + /// + /// It contains a default `MetricCollection` object, its JSON representation, + /// and its Prometheus format representation. + struct MetricCollectionFixture { + pub object: MetricCollection, + pub json: String, + pub prometheus: String, + } + + impl Default for MetricCollectionFixture { + fn default() -> Self { + Self { + object: Self::object(), + json: Self::json(), + prometheus: Self::prometheus(), + } + } + } + + impl MetricCollectionFixture { + fn deconstruct(&self) -> (MetricCollection, String, String) { + (self.object.clone(), self.json.clone(), self.prometheus.clone()) + } + + fn object() -> MetricCollection { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let label_set_1: LabelSet = [ + (label_name!("server_binding_protocol"), LabelValue::new("http")), + (label_name!("server_binding_ip"), LabelValue::new("0.0.0.0")), + (label_name!("server_binding_port"), LabelValue::new("7070")), + ] + .into(); + + MetricCollection::new( + MetricKindCollection::new(vec![Metric::new( + metric_name!("http_tracker_core_announce_requests_received_total"), + None, + Some(MetricDescription::new("The number of announce requests received.")), + SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set_1.clone())]).unwrap(), + )]) + .unwrap(), + MetricKindCollection::new(vec![Metric::new( + metric_name!("udp_tracker_server_performance_avg_announce_processing_time_ns"), + None, + Some(MetricDescription::new("The average announce processing time in nanoseconds.")), + SampleCollection::new(vec![Sample::new(Gauge::new(1.0), time, label_set_1.clone())]).unwrap(), + )]) + .unwrap(), + ) + .unwrap() + } + + fn json() -> String { + r#" + [ + { + "type":"counter", + "name":"http_tracker_core_announce_requests_received_total", + "unit": null, + "description": "The number of announce requests received.", + "samples":[ + { + "value":1, + "recorded_at":"2025-04-02T00:00:00+00:00", + "labels":[ + { + "name":"server_binding_ip", + "value":"0.0.0.0" + }, + { + "name":"server_binding_port", + "value":"7070" + }, + { + "name":"server_binding_protocol", + "value":"http" + } + ] + } + ] + }, + { + "type":"gauge", + "name":"udp_tracker_server_performance_avg_announce_processing_time_ns", + "unit": null, + "description": "The average announce processing time in nanoseconds.", + "samples":[ + { + "value":1.0, + "recorded_at":"2025-04-02T00:00:00+00:00", + "labels":[ + { + "name":"server_binding_ip", + "value":"0.0.0.0" + }, + { + "name":"server_binding_port", + "value":"7070" + }, + { + "name":"server_binding_protocol", + "value":"http" + } + ] + } + ] + } + ] + "# + .to_owned() + } + + fn prometheus() -> String { + format_prometheus_output( + r#"# HELP http_tracker_core_announce_requests_received_total The number of announce requests received. +# TYPE http_tracker_core_announce_requests_received_total counter +http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 + +# HELP udp_tracker_server_performance_avg_announce_processing_time_ns The average announce processing time in nanoseconds. +# TYPE udp_tracker_server_performance_avg_announce_processing_time_ns gauge +udp_tracker_server_performance_avg_announce_processing_time_ns{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 +"#, + ) + } + } + + #[test] + fn it_should_not_allow_duplicate_names_across_types() { + let counters = MetricKindCollection::new(vec![Metric::new( + metric_name!("test_metric"), + None, + None, + SampleCollection::default(), + )]) + .unwrap(); + let gauges = MetricKindCollection::new(vec![Metric::new( + metric_name!("test_metric"), + None, + None, + SampleCollection::default(), + )]) + .unwrap(); + + assert!(MetricCollection::new(counters, gauges).is_err()); + } + + #[test] + fn it_should_not_allow_creating_a_gauge_with_the_same_name_as_a_counter() { + let mut collection = MetricCollection::default(); + let label_set = LabelSet::default(); + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + // First create a counter + collection + .increment_counter(&metric_name!("test_metric"), &label_set, time) + .unwrap(); + + // Then try to create a gauge with the same name + let result = collection.set_gauge(&metric_name!("test_metric"), &label_set, 1.0, time); + + assert!(result.is_err()); + } + + #[test] + fn it_should_not_allow_creating_a_counter_with_the_same_name_as_a_gauge() { + let mut collection = MetricCollection::default(); + let label_set = LabelSet::default(); + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + // First set the gauge + collection + .set_gauge(&metric_name!("test_metric"), &label_set, 1.0, time) + .unwrap(); + + // Then try to create a counter with the same name + let result = collection.increment_counter(&metric_name!("test_metric"), &label_set, time); + + assert!(result.is_err()); + } + + #[test] + fn it_should_allow_serializing_to_json() { + // todo: this test does work with metric with multiple samples because + // samples are not serialized in the same order as they are created. + let (metric_collection, expected_json, _expected_prometheus) = MetricCollectionFixture::default().deconstruct(); + + let json = serde_json::to_string_pretty(&metric_collection).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::from_str::(&expected_json).unwrap() + ); + } + + #[test] + fn it_should_allow_deserializing_from_json() { + let (expected_metric_collection, metric_collection_json, _expected_prometheus) = + MetricCollectionFixture::default().deconstruct(); + + let metric_collection: MetricCollection = serde_json::from_str(&metric_collection_json).unwrap(); + + assert_eq!(metric_collection, expected_metric_collection); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format() { + let (metric_collection, _expected_json, expected_prometheus) = MetricCollectionFixture::default().deconstruct(); + + let prometheus_output = metric_collection.to_prometheus(); + + assert_eq!(prometheus_output, expected_prometheus); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format_with_multiple_samples_per_metric() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let label_set_1: LabelSet = [ + (label_name!("server_binding_protocol"), LabelValue::new("http")), + (label_name!("server_binding_ip"), LabelValue::new("0.0.0.0")), + (label_name!("server_binding_port"), LabelValue::new("7070")), + ] + .into(); + + let label_set_2: LabelSet = [ + (label_name!("server_binding_protocol"), LabelValue::new("http")), + (label_name!("server_binding_ip"), LabelValue::new("0.0.0.0")), + (label_name!("server_binding_port"), LabelValue::new("7171")), + ] + .into(); + + let metric_collection = MetricCollection::new( + MetricKindCollection::new(vec![Metric::new( + metric_name!("http_tracker_core_announce_requests_received_total"), + None, + Some(MetricDescription::new("The number of announce requests received.")), + SampleCollection::new(vec![ + Sample::new(Counter::new(1), time, label_set_1.clone()), + Sample::new(Counter::new(2), time, label_set_2.clone()), + ]) + .unwrap(), + )]) + .unwrap(), + MetricKindCollection::default(), + ) + .unwrap(); + + let prometheus_output = metric_collection.to_prometheus(); + + let expected_prometheus_output = format_prometheus_output( + r#"# HELP http_tracker_core_announce_requests_received_total The number of announce requests received. +# TYPE http_tracker_core_announce_requests_received_total counter +http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 +http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7171",server_binding_protocol="http"} 2 +"#, + ); + + // code-review: samples are not serialized in the same order as they are created. + // Should we use a deterministic order? + + assert_eq!(sort_lines(&prometheus_output), sort_lines(&expected_prometheus_output)); + } + + #[test] + fn it_should_exclude_metrics_without_samples_from_prometheus_format() { + let mut counters = MetricKindCollection::default(); + let mut gauges = MetricKindCollection::default(); + + let counter = Metric::::new_empty_with_name(metric_name!("test_counter")); + counters.insert_if_absent(counter); + + let gauge = Metric::::new_empty_with_name(metric_name!("test_gauge")); + gauges.insert_if_absent(gauge); + + let metric_collection = MetricCollection::new(counters, gauges).unwrap(); + + let prometheus_output = metric_collection.to_prometheus(); + + assert_eq!(prometheus_output, ""); + } + + #[test] + fn it_should_allow_merging_metric_collections() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection1 = MetricCollection::default(); + collection1 + .increment_counter(&metric_name!("test_counter"), &label_set, time) + .unwrap(); + + let mut collection2 = MetricCollection::default(); + collection2 + .set_gauge(&metric_name!("test_gauge"), &label_set, 1.0, time) + .unwrap(); + + collection1.merge(&collection2).unwrap(); + + assert!(collection1.contains_counter(&metric_name!("test_counter"))); + assert!(collection1.contains_gauge(&metric_name!("test_gauge"))); + } + + #[test] + fn it_should_not_allow_merging_metric_collections_with_name_collisions_for_the_same_metric_types() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection1 = MetricCollection::default(); + collection1 + .increment_counter(&metric_name!("test_metric"), &label_set, time) + .unwrap(); + + let mut collection2 = MetricCollection::default(); + collection2 + .increment_counter(&metric_name!("test_metric"), &label_set, time) + .unwrap(); + let result = collection1.merge(&collection2); + + assert!(result.is_err()); + } + + #[test] + fn it_should_not_allow_merging_metric_collections_with_name_collisions_for_different_metric_types() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection1 = MetricCollection::default(); + collection1 + .increment_counter(&metric_name!("test_metric"), &label_set, time) + .unwrap(); + + let mut collection2 = MetricCollection::default(); + collection2 + .set_gauge(&metric_name!("test_metric"), &label_set, 1.0, time) + .unwrap(); + + let result = collection1.merge(&collection2); + + assert!(result.is_err()); + } + + fn collection_with_one_counter(metric_name: &MetricName, label_set: &LabelSet, counter: Counter) -> MetricCollection { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + MetricCollection::new( + MetricKindCollection::new(vec![Metric::new( + metric_name.clone(), + None, + None, + SampleCollection::new(vec![Sample::new(counter, time, label_set.clone())]).unwrap(), + )]) + .unwrap(), + MetricKindCollection::default(), + ) + .unwrap() + } + + fn collection_with_one_gauge(metric_name: &MetricName, label_set: &LabelSet, gauge: Gauge) -> MetricCollection { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + MetricCollection::new( + MetricKindCollection::default(), + MetricKindCollection::new(vec![Metric::new( + metric_name.clone(), + None, + None, + SampleCollection::new(vec![Sample::new(gauge, time, label_set.clone())]).unwrap(), + )]) + .unwrap(), + ) + .unwrap() + } + + mod for_counters { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + + #[test] + fn it_should_allow_setting_to_an_absolute_value() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_counter"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_counter(&metric_name, &label_set, Counter::new(0)); + + collection + .set_counter(&metric_name!("test_counter"), &label_set, 1, time) + .unwrap(); + + assert_eq!( + collection.get_counter_value(&metric_name!("test_counter"), &label_set), + Some(Counter::new(1)) + ); + } + + #[test] + fn it_should_fail_setting_to_an_absolute_value_if_a_gauge_with_the_same_name_exists() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_counter"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_gauge(&metric_name, &label_set, Gauge::new(0.0)); + + let result = collection.set_counter(&metric_name!("test_counter"), &label_set, 1, time); + + assert!( + result.is_err() + && matches!(result, Err(Error::MetricNameCollisionAdding { metric_name }) if metric_name == metric_name!("test_counter")) + ); + } + + #[test] + fn it_should_increase_a_preexistent_counter() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_counter"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_counter(&metric_name, &label_set, Counter::new(0)); + + collection + .increment_counter(&metric_name!("test_counter"), &label_set, time) + .unwrap(); + + assert_eq!( + collection.get_counter_value(&metric_name!("test_counter"), &label_set), + Some(Counter::new(1)) + ); + } + + #[test] + fn it_should_automatically_create_a_counter_when_increasing_if_it_does_not_exist() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::default(), MetricKindCollection::default()).unwrap(); + + metric_collection + .increment_counter(&metric_name!("test_counter"), &label_set, time) + .unwrap(); + metric_collection + .increment_counter(&metric_name!("test_counter"), &label_set, time) + .unwrap(); + + assert_eq!( + metric_collection.get_counter_value(&metric_name!("test_counter"), &label_set), + Some(Counter::new(2)) + ); + } + + #[test] + fn it_should_allow_describing_a_counter_before_using_it() { + let mut metric_collection = + MetricCollection::new(MetricKindCollection::default(), MetricKindCollection::default()).unwrap(); + + metric_collection.describe_counter(&metric_name!("test_counter"), None, None); + + assert!(metric_collection.contains_counter(&metric_name!("test_counter"))); + } + + #[test] + fn it_should_not_allow_duplicate_metric_names_when_instantiating() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let result = MetricKindCollection::new(vec![ + Metric::new( + metric_name!("test_counter"), + None, + None, + SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]).unwrap(), + ), + Metric::new( + metric_name!("test_counter"), + None, + None, + SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]).unwrap(), + ), + ]); + + assert!(result.is_err()); + } + } + + mod for_gauges { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::LabelValue; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + + #[test] + fn it_should_set_a_preexistent_gauge() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_gauge"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_gauge(&metric_name, &label_set, Gauge::new(0.0)); + + collection + .set_gauge(&metric_name!("test_gauge"), &label_set, 1.0, time) + .unwrap(); + + assert_eq!( + collection.get_gauge_value(&metric_name!("test_gauge"), &label_set), + Some(Gauge::new(1.0)) + ); + } + + #[test] + fn it_should_allow_incrementing_a_gauge() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_gauge"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_gauge(&metric_name, &label_set, Gauge::new(0.0)); + + collection + .increment_gauge(&metric_name!("test_gauge"), &label_set, time) + .unwrap(); + + assert_eq!( + collection.get_gauge_value(&metric_name!("test_gauge"), &label_set), + Some(Gauge::new(1.0)) + ); + } + + #[test] + fn it_should_fail_incrementing_a_gauge_if_it_exists_a_counter_with_the_same_name() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_gauge"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_counter(&metric_name, &label_set, Counter::new(0)); + + let result = collection.increment_gauge(&metric_name!("test_gauge"), &label_set, time); + + assert!( + result.is_err() + && matches!(result, Err(Error::MetricNameCollisionAdding { metric_name }) if metric_name == metric_name!("test_gauge")) + ); + } + + #[test] + fn it_should_allow_decrementing_a_gauge() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_gauge"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_gauge(&metric_name, &label_set, Gauge::new(1.0)); + + collection + .decrement_gauge(&metric_name!("test_gauge"), &label_set, time) + .unwrap(); + + assert_eq!( + collection.get_gauge_value(&metric_name!("test_gauge"), &label_set), + Some(Gauge::new(0.0)) + ); + } + + #[test] + fn it_should_fail_decrementing_a_gauge_if_it_exists_a_counter_with_the_same_name() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let metric_name = metric_name!("test_gauge"); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut collection = collection_with_one_counter(&metric_name, &label_set, Counter::new(0)); + + let result = collection.decrement_gauge(&metric_name!("test_gauge"), &label_set, time); + + assert!( + result.is_err() + && matches!(result, Err(Error::MetricNameCollisionAdding { metric_name }) if metric_name == metric_name!("test_gauge")) + ); + } + + #[test] + fn it_should_automatically_create_a_gauge_when_setting_if_it_does_not_exist() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::default(), MetricKindCollection::default()).unwrap(); + + metric_collection + .set_gauge(&metric_name!("test_gauge"), &label_set, 1.0, time) + .unwrap(); + + assert_eq!( + metric_collection.get_gauge_value(&metric_name!("test_gauge"), &label_set), + Some(Gauge::new(1.0)) + ); + } + + #[test] + fn it_should_allow_describing_a_gauge_before_using_it() { + let mut metric_collection = + MetricCollection::new(MetricKindCollection::default(), MetricKindCollection::default()).unwrap(); + + metric_collection.describe_gauge(&metric_name!("test_gauge"), None, None); + + assert!(metric_collection.contains_gauge(&metric_name!("test_gauge"))); + } + + #[test] + fn it_should_not_allow_duplicate_metric_names_when_instantiating() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (label_name!("label_name"), LabelValue::new("value")).into(); + + let result = MetricKindCollection::new(vec![ + Metric::new( + metric_name!("test_gauge"), + None, + None, + SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]).unwrap(), + ), + Metric::new( + metric_name!("test_gauge"), + None, + None, + SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]).unwrap(), + ), + ]); + + assert!(result.is_err()); + } + } + + mod metric_kind_collection { + + use crate::counter::Counter; + use crate::gauge::Gauge; + use crate::metric::Metric; + use crate::metric_collection::{Error, MetricKindCollection}; + use crate::metric_name; + + #[test] + fn it_should_not_allow_merging_counter_metric_collections_with_name_collisions() { + let mut collection1 = MetricKindCollection::::default(); + collection1.insert(Metric::::new_empty_with_name(metric_name!("test_metric"))); + + let mut collection2 = MetricKindCollection::::default(); + collection2.insert(Metric::::new_empty_with_name(metric_name!("test_metric"))); + + let result = collection1.merge(&collection2); + + assert!( + result.is_err() + && matches!(result, Err(Error::MetricNameCollisionInMerge { metric_name }) if metric_name == metric_name!("test_metric")) + ); + } + + #[test] + fn it_should_not_allow_merging_gauge_metric_collections_with_name_collisions() { + let mut collection1 = MetricKindCollection::::default(); + collection1.insert(Metric::::new_empty_with_name(metric_name!("test_metric"))); + + let mut collection2 = MetricKindCollection::::default(); + collection2.insert(Metric::::new_empty_with_name(metric_name!("test_metric"))); + + let result = collection1.merge(&collection2); + + assert!( + result.is_err() + && matches!(result, Err(Error::MetricNameCollisionInMerge { metric_name }) if metric_name == metric_name!("test_metric")) + ); + } + } +} diff --git a/packages/metrics/src/prometheus.rs b/packages/metrics/src/prometheus.rs new file mode 100644 index 000000000..bf058e442 --- /dev/null +++ b/packages/metrics/src/prometheus.rs @@ -0,0 +1,15 @@ +pub trait PrometheusSerializable { + /// Convert the implementing type into a Prometheus exposition format string. + /// + /// # Returns + /// + /// A `String` containing the serialized representation. + fn to_prometheus(&self) -> String; +} + +// Blanket implementation for references +impl PrometheusSerializable for &T { + fn to_prometheus(&self) -> String { + (*self).to_prometheus() + } +} diff --git a/packages/metrics/src/sample.rs b/packages/metrics/src/sample.rs new file mode 100644 index 000000000..63f46b9b8 --- /dev/null +++ b/packages/metrics/src/sample.rs @@ -0,0 +1,469 @@ +use chrono::{DateTime, Utc}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Sample { + #[serde(flatten)] + measurement: Measurement, + + #[serde(rename = "labels")] + label_set: LabelSet, +} + +impl Sample { + #[must_use] + pub fn new(value: T, recorded_at: DurationSinceUnixEpoch, label_set: LabelSet) -> Self { + let data = Measurement { value, recorded_at }; + + Self { + measurement: data, + label_set, + } + } + + #[must_use] + pub fn measurement(&self) -> &Measurement { + &self.measurement + } + + #[must_use] + pub fn value(&self) -> &T { + &self.measurement.value + } + + #[must_use] + pub fn recorded_at(&self) -> DurationSinceUnixEpoch { + self.measurement.recorded_at + } + + #[must_use] + pub fn labels(&self) -> &LabelSet { + &self.label_set + } +} + +impl PrometheusSerializable for Sample { + fn to_prometheus(&self) -> String { + if self.label_set.is_empty() { + format!(" {}", self.measurement.to_prometheus()) + } else { + format!("{} {}", self.label_set.to_prometheus(), self.measurement.to_prometheus()) + } + } +} + +impl Sample { + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.measurement.increment(time); + } +} + +impl Sample { + pub fn set(&mut self, value: f64, time: DurationSinceUnixEpoch) { + self.measurement.set(value, time); + } + + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.measurement.increment(time); + } + + pub fn decrement(&mut self, time: DurationSinceUnixEpoch) { + self.measurement.decrement(time); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Measurement { + /// The value of the sample. + value: T, + + /// The time when the sample was last updated. + #[serde(serialize_with = "serialize_duration", deserialize_with = "deserialize_duration")] + recorded_at: DurationSinceUnixEpoch, +} + +impl Measurement { + #[must_use] + pub fn new(value: T, recorded_at: DurationSinceUnixEpoch) -> Self { + Self { value, recorded_at } + } + + #[must_use] + pub fn value(&self) -> &T { + &self.value + } + + #[must_use] + pub fn recorded_at(&self) -> DurationSinceUnixEpoch { + self.recorded_at + } + + fn set_recorded_at(&mut self, time: DurationSinceUnixEpoch) { + self.recorded_at = time; + } +} + +impl From> for (LabelSet, Measurement) { + fn from(sample: Sample) -> Self { + (sample.label_set, sample.measurement) + } +} + +impl PrometheusSerializable for Measurement { + fn to_prometheus(&self) -> String { + self.value.to_prometheus() + } +} + +impl Measurement { + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.value.increment(1); + self.set_recorded_at(time); + } + + pub fn absolute(&mut self, value: u64, time: DurationSinceUnixEpoch) { + self.value.absolute(value); + self.set_recorded_at(time); + } +} + +impl Measurement { + pub fn set(&mut self, value: f64, time: DurationSinceUnixEpoch) { + self.value.set(value); + self.set_recorded_at(time); + } + + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.value.increment(1.0); + self.set_recorded_at(time); + } + + pub fn decrement(&mut self, time: DurationSinceUnixEpoch) { + self.value.decrement(1.0); + self.set_recorded_at(time); + } +} + +/// Serializes the `recorded_at` field as a string in ISO 8601 format (RFC 3339). +/// +/// # Errors +/// +/// Returns an error if: +/// - The conversion from `u64` to `i64` fails. +/// - The timestamp is invalid. +fn serialize_duration(duration: &DurationSinceUnixEpoch, serializer: S) -> Result +where + S: Serializer, +{ + let secs = i64::try_from(duration.as_secs()).map_err(|_| serde::ser::Error::custom("Timestamp too large"))?; + let nanos = duration.subsec_nanos(); + + let datetime = DateTime::from_timestamp(secs, nanos).ok_or_else(|| serde::ser::Error::custom("Invalid timestamp"))?; + + serializer.serialize_str(&datetime.to_rfc3339()) // Serializes as ISO 8601 (RFC 3339) +} + +fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // Deserialize theISO 8601 (RFC 3339) formatted string + let datetime_str = String::deserialize(deserializer)?; + + let datetime = + DateTime::parse_from_rfc3339(&datetime_str).map_err(|e| de::Error::custom(format!("Invalid datetime format: {e}")))?; + + let datetime_utc = datetime.with_timezone(&Utc); + + let secs = u64::try_from(datetime_utc.timestamp()).map_err(|_| de::Error::custom("Timestamp out of range"))?; + + Ok(DurationSinceUnixEpoch::new(secs, datetime_utc.timestamp_subsec_nanos())) +} + +#[cfg(test)] +mod tests { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use super::*; + + // Helper function to create a sample update time. + fn updated_at_time() -> DurationSinceUnixEpoch { + DurationSinceUnixEpoch::from_secs(1_743_552_000) + } + + #[test] + fn it_should_have_a_value() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.value(), &42); + } + + #[test] + fn it_should_record_the_latest_update_time() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.recorded_at(), updated_at_time()); + } + + #[test] + fn it_should_include_a_label_set() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.labels(), &LabelSet::from(vec![("test", "label")])); + } + + mod for_counter_type_sample { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::tests::updated_at_time; + use crate::sample::{Counter, Sample}; + + #[test] + fn it_should_allow_a_counter_type_value() { + let sample = Sample::new( + Counter::new(42), + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("label_name", "label vale")]), + ); + + assert_eq!(sample.value(), &Counter::new(42)); + } + + #[test] + fn it_should_allow_incrementing_the_counter() { + let mut sample = Sample::new(Counter::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.increment(updated_at_time()); + + assert_eq!(sample.value(), &Counter::new(1)); + } + + #[test] + fn it_should_record_the_latest_update_time_when_the_counter_is_incremented() { + let mut sample = Sample::new(Counter::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + let time = updated_at_time(); + + sample.increment(time); + + assert_eq!(sample.recorded_at(), time); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format() { + let counter = Counter::new(42); + + let labels = LabelSet::from(vec![("label_name", "label_value"), ("method", "GET")]); + + let sample = Sample::new(counter, DurationSinceUnixEpoch::default(), labels); + + assert_eq!(sample.to_prometheus(), r#"{label_name="label_value",method="GET"} 42"#); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format_with_empty_label_set() { + let counter = Counter::new(42); + + let sample = Sample::new(counter, DurationSinceUnixEpoch::default(), LabelSet::default()); + + assert_eq!(sample.to_prometheus(), " 42"); + } + } + mod for_gauge_type_sample { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::tests::updated_at_time; + use crate::sample::{Gauge, Sample}; + + #[test] + fn it_should_allow_a_counter_type_value() { + let sample = Sample::new( + Gauge::new(42.0), + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("label_name", "label vale")]), + ); + + assert_eq!(sample.value(), &Gauge::new(42.0)); + } + + #[test] + fn it_should_allow_setting_a_value() { + let mut sample = Sample::new(Gauge::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.set(1.0, updated_at_time()); + + assert_eq!(sample.value(), &Gauge::new(1.0)); + } + + #[test] + fn it_should_allow_incrementing_the_value() { + let mut sample = Sample::new(Gauge::new(0.0), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.increment(updated_at_time()); + + assert_eq!(sample.value(), &Gauge::new(1.0)); + } + + #[test] + fn it_should_allow_decrementing_the_value() { + let mut sample = Sample::new(Gauge::new(1.0), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.decrement(updated_at_time()); + + assert_eq!(sample.value(), &Gauge::new(0.0)); + } + + #[test] + fn it_should_record_the_latest_update_time_when_the_counter_is_incremented() { + let mut sample = Sample::new(Gauge::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + let time = updated_at_time(); + + sample.set(1.0, time); + + assert_eq!(sample.recorded_at(), time); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format() { + let counter = Gauge::new(42.0); + + let labels = LabelSet::from(vec![("label_name", "label_value"), ("method", "GET")]); + + let sample = Sample::new(counter, DurationSinceUnixEpoch::default(), labels); + + assert_eq!(sample.to_prometheus(), r#"{label_name="label_value",method="GET"} 42"#); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format_with_empty_label_set() { + let gauge = Gauge::new(42.0); + + let sample = Sample::new(gauge, DurationSinceUnixEpoch::default(), LabelSet::default()); + + assert_eq!(sample.to_prometheus(), " 42"); + } + } + + mod serialization_to_json { + use pretty_assertions::assert_eq; + use serde_json::json; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::sample::tests::updated_at_time; + use crate::sample::Sample; + + #[test] + fn test_serialization_round_trip() { + let original = Sample::new(42, updated_at_time(), LabelSet::from(vec![("test", "serialization")])); + + let json = serde_json::to_string(&original).unwrap(); + let deserialized: Sample = serde_json::from_str(&json).unwrap(); + + assert_eq!(original.measurement.value, deserialized.measurement.value); + assert_eq!(original.measurement.recorded_at, deserialized.measurement.recorded_at); + assert_eq!(original.label_set, deserialized.label_set); + } + + #[test] + fn test_rfc3339_serialization_format_for_update_time() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::new(1_743_552_000, 100), + LabelSet::from(vec![("label_name", "label value")]), + ); + + let json = serde_json::to_string(&sample).unwrap(); + + let expected_json = r#" + { + "value": 42, + "recorded_at": "2025-04-02T00:00:00.000000100+00:00", + "labels": [ + { + "name": "label_name", + "value": "label value" + } + ] + } + "#; + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::from_str::(expected_json).unwrap() + ); + } + + #[test] + fn test_invalid_update_timestamp_serialization() { + let timestamp_too_large = DurationSinceUnixEpoch::new(i64::MAX as u64 + 1, 0); + + let sample = Sample::new(42, timestamp_too_large, LabelSet::from(vec![("label_name", "label value")])); + + let result = serde_json::to_string(&sample); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Timestamp too large")); + } + + #[test] + fn test_invalid_update_datetime_deserialization() { + let invalid_json = json!( + r#" + { + "value": 42, + "recorded_at": "1-1-2023T25:00:00Z", + "labels": [ + { + "name": "label_name", + "value": "label value" + } + ] + } + "# + ); + + let result: Result = serde_json::from_value(invalid_json); + + assert!(result.unwrap_err().to_string().contains("invalid type")); + } + + #[test] + fn test_update_datetime_high_precision_nanoseconds() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::new(1_743_552_000, 100), + LabelSet::from(vec![("label_name", "label value")]), + ); + + let json = serde_json::to_string(&sample).unwrap(); + + let deserialized: Sample = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized, sample); + } + } +} diff --git a/packages/metrics/src/sample_collection.rs b/packages/metrics/src/sample_collection.rs new file mode 100644 index 000000000..e520d7310 --- /dev/null +++ b/packages/metrics/src/sample_collection.rs @@ -0,0 +1,543 @@ +use std::collections::hash_map::Iter; +use std::collections::HashMap; +use std::fmt::Write as _; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; +use super::sample::Sample; +use crate::sample::Measurement; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SampleCollection { + samples: HashMap>, +} + +impl SampleCollection { + /// Creates a new `MetricKindCollection` from a vector of metrics + /// + /// # Errors + /// + /// Returns an error if there are duplicate `LabelSets` in the provided + /// samples. + pub fn new(samples: Vec>) -> Result { + let mut map: HashMap> = HashMap::with_capacity(samples.len()); + + for sample in samples { + let (label_set, sample_data): (LabelSet, Measurement) = sample.into(); + + let label_set_clone = label_set.clone(); + + if let Some(_old_measurement) = map.insert(label_set, sample_data) { + return Err(Error::DuplicateLabelSetInList { + label_set: label_set_clone, + }); + } + } + + Ok(Self { samples: map }) + } + + #[must_use] + pub fn get(&self, label: &LabelSet) -> Option<&Measurement> { + self.samples.get(label) + } + + #[must_use] + pub fn len(&self) -> usize { + self.samples.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + #[must_use] + #[allow(clippy::iter_without_into_iter)] + pub fn iter(&self) -> Iter<'_, LabelSet, Measurement> { + self.samples.iter() + } +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("Found duplicate label set in list. Label set must be unique in a SampleCollection.")] + DuplicateLabelSetInList { label_set: LabelSet }, +} + +impl SampleCollection { + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Counter::default(), time)); + + sample.increment(time); + } + + pub fn absolute(&mut self, label_set: &LabelSet, value: u64, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Counter::default(), time)); + + sample.absolute(value, time); + } +} + +impl SampleCollection { + pub fn set(&mut self, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Gauge::default(), time)); + + sample.set(value, time); + } + + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Gauge::default(), time)); + + sample.increment(time); + } + + pub fn decrement(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Gauge::default(), time)); + + sample.decrement(time); + } +} + +impl Serialize for SampleCollection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut samples: Vec> = vec![]; + + for (label_set, sample_data) in &self.samples { + samples.push(Sample::new(sample_data.value(), sample_data.recorded_at(), label_set.clone())); + } + + samples.serialize(serializer) + } +} + +impl<'de, T> Deserialize<'de> for SampleCollection +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let samples = Vec::>::deserialize(deserializer)?; + + let sample_collection = SampleCollection::new(samples).map_err(serde::de::Error::custom)?; + + Ok(sample_collection) + } +} + +impl PrometheusSerializable for SampleCollection { + fn to_prometheus(&self) -> String { + let mut output = String::new(); + + for (label_set, sample_data) in &self.samples { + if label_set.is_empty() { + let _ = write!(output, "{}", sample_data.to_prometheus()); + } else { + let _ = write!(output, "{} {}", label_set.to_prometheus(), sample_data.to_prometheus()); + } + } + + output + } +} + +#[cfg(test)] +mod tests { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::counter::Counter; + use crate::label::LabelSet; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + + fn sample_update_time() -> DurationSinceUnixEpoch { + DurationSinceUnixEpoch::from_secs(1_743_552_000) + } + + #[test] + fn it_should_fail_trying_to_create_a_sample_collection_with_duplicate_label_sets() { + let samples = vec![ + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + ]; + + let result = SampleCollection::new(samples); + + assert!(result.is_err()); + } + + #[test] + fn it_should_return_a_sample_searching_by_label_set_with_one_empty_label_set() { + let label_set = LabelSet::default(); + + let sample = Sample::new(Counter::default(), sample_update_time(), label_set.clone()); + + let collection = SampleCollection::new(vec![sample.clone()]).unwrap(); + + let retrieved = collection.get(&label_set); + + assert_eq!(retrieved.unwrap(), sample.measurement()); + } + + #[test] + fn it_should_return_a_sample_searching_by_label_set_with_two_label_sets() { + let label_set_1 = LabelSet::from(vec![("label_name_1", "label value 1")]); + let label_set_2 = LabelSet::from(vec![("label_name_2", "label value 2")]); + + let sample_1 = Sample::new(Counter::new(1), sample_update_time(), label_set_1.clone()); + let sample_2 = Sample::new(Counter::new(2), sample_update_time(), label_set_2.clone()); + + let collection = SampleCollection::new(vec![sample_1.clone(), sample_2.clone()]).unwrap(); + + let retrieved = collection.get(&label_set_1); + assert_eq!(retrieved.unwrap(), sample_1.measurement()); + + let retrieved = collection.get(&label_set_2); + assert_eq!(retrieved.unwrap(), sample_2.measurement()); + } + + #[test] + fn it_should_return_the_number_of_samples_in_the_collection() { + let samples = vec![Sample::new(Counter::default(), sample_update_time(), LabelSet::default())]; + let collection = SampleCollection::new(samples).unwrap(); + assert_eq!(collection.len(), 1); + } + + #[test] + fn it_should_return_zero_number_of_samples_when_empty() { + let empty = SampleCollection::::default(); + assert_eq!(empty.len(), 0); + } + + #[test] + fn it_should_indicate_is_it_is_empty() { + let empty = SampleCollection::::default(); + assert!(empty.is_empty()); + + let samples = vec![Sample::new(Counter::default(), sample_update_time(), LabelSet::default())]; + let collection = SampleCollection::new(samples).unwrap(); + assert!(!collection.is_empty()); + } + + mod json_serialization { + use crate::counter::Counter; + use crate::label::LabelSet; + use crate::sample::Sample; + use crate::sample_collection::tests::sample_update_time; + use crate::sample_collection::SampleCollection; + + #[test] + fn it_should_be_serializable_and_deserializable_for_json_format() { + let sample = Sample::new(Counter::default(), sample_update_time(), LabelSet::default()); + let collection = SampleCollection::new(vec![sample]).unwrap(); + + let serialized = serde_json::to_string(&collection).unwrap(); + let deserialized: SampleCollection = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized, collection); + } + + #[test] + fn it_should_fail_deserializing_from_json_with_duplicate_label_sets() { + let samples = vec![ + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + ]; + + let serialized = serde_json::to_string(&samples).unwrap(); + + let result: Result, _> = serde_json::from_str(&serialized); + + assert!(result.is_err()); + } + } + + mod prometheus_serialization { + use crate::counter::Counter; + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::Sample; + use crate::sample_collection::tests::sample_update_time; + use crate::sample_collection::SampleCollection; + use crate::tests::format_prometheus_output; + + #[test] + fn it_should_be_exportable_to_prometheus_format_when_empty() { + let sample = Sample::new(Counter::default(), sample_update_time(), LabelSet::default()); + let collection = SampleCollection::new(vec![sample]).unwrap(); + + let prometheus_output = collection.to_prometheus(); + + assert!(!prometheus_output.is_empty()); + } + + #[test] + fn it_should_be_exportable_to_prometheus_format() { + let sample = Sample::new( + Counter::new(1), + sample_update_time(), + LabelSet::from(vec![("labe_name_1", "label value value 1")]), + ); + + let collection = SampleCollection::new(vec![sample]).unwrap(); + + let prometheus_output = collection.to_prometheus(); + + let expected_prometheus_output = format_prometheus_output("{labe_name_1=\"label value value 1\"} 1"); + + assert_eq!(prometheus_output, expected_prometheus_output); + } + } + + #[cfg(test)] + mod for_counters { + + use std::ops::Add; + + use super::super::LabelSet; + use super::*; + + #[test] + fn it_should_increment_the_counter_for_a_preexisting_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Initialize the sample + collection.increment(&label_set, sample_update_time()); + + // Verify initial state + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.value(), &Counter::new(1)); + + // Increment again + collection.increment(&label_set, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(2)); + } + + #[test] + fn it_should_allow_increment_the_counter_for_a_non_existent_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Increment a non-existent label + collection.increment(&label_set, sample_update_time()); + + // Verify the label exists + assert!(collection.get(&label_set).is_some()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(1)); + } + + #[test] + fn it_should_update_the_latest_update_time_when_incremented() { + let label_set = LabelSet::default(); + let initial_time = sample_update_time(); + + let mut collection = SampleCollection::::default(); + collection.increment(&label_set, initial_time); + + // Increment with a new time + let new_time = initial_time.add(DurationSinceUnixEpoch::from_secs(1)); + collection.increment(&label_set, new_time); + + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.recorded_at(), new_time); + assert_eq!(*sample.value(), Counter::new(2)); + } + + #[test] + fn it_should_increment_the_counter_for_multiple_labels() { + let label1 = LabelSet::from([("name", "value1")]); + let label2 = LabelSet::from([("name", "value2")]); + let now = sample_update_time(); + + let mut collection = SampleCollection::::default(); + + collection.increment(&label1, now); + collection.increment(&label2, now); + + assert_eq!(collection.get(&label1).unwrap().value(), &Counter::new(1)); + assert_eq!(collection.get(&label2).unwrap().value(), &Counter::new(1)); + assert_eq!(collection.len(), 2); + } + + #[test] + fn it_should_allow_setting_absolute_value_for_a_counter() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Set absolute value for a non-existent label + collection.absolute(&label_set, 42, sample_update_time()); + + // Verify the label exists and has the absolute value + assert!(collection.get(&label_set).is_some()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(42)); + } + + #[test] + fn it_should_allow_setting_absolute_value_for_existing_counter() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Initialize the sample with increment + collection.increment(&label_set, sample_update_time()); + + // Verify initial state + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.value(), &Counter::new(1)); + + // Set absolute value + collection.absolute(&label_set, 100, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(100)); + } + + #[test] + fn it_should_update_time_when_setting_absolute_value() { + let label_set = LabelSet::default(); + let initial_time = sample_update_time(); + let mut collection = SampleCollection::::default(); + + // Set absolute value with initial time + collection.absolute(&label_set, 50, initial_time); + + // Set absolute value with a new time + let new_time = initial_time.add(DurationSinceUnixEpoch::from_secs(1)); + collection.absolute(&label_set, 75, new_time); + + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.recorded_at(), new_time); + assert_eq!(*sample.value(), Counter::new(75)); + } + } + + #[cfg(test)] + mod for_gauges { + + use std::ops::Add; + + use super::super::LabelSet; + use super::*; + use crate::gauge::Gauge; + + #[test] + fn it_should_allow_setting_the_gauge_for_a_preexisting_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Initialize the sample + collection.set(&label_set, 1.0, sample_update_time()); + + // Verify initial state + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.value(), &Gauge::new(1.0)); + + // Set again + collection.set(&label_set, 2.0, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(2.0)); + } + + #[test] + fn it_should_allow_setting_the_gauge_for_a_non_existent_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Set a non-existent label + collection.set(&label_set, 1.0, sample_update_time()); + + // Verify the label exists + assert!(collection.get(&label_set).is_some()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(1.0)); + } + + #[test] + fn it_should_update_the_latest_update_time_when_setting() { + let label_set = LabelSet::default(); + let initial_time = sample_update_time(); + + let mut collection = SampleCollection::::default(); + collection.set(&label_set, 1.0, initial_time); + + // Set with a new time + let new_time = initial_time.add(DurationSinceUnixEpoch::from_secs(1)); + collection.set(&label_set, 2.0, new_time); + + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.recorded_at(), new_time); + assert_eq!(*sample.value(), Gauge::new(2.0)); + } + + #[test] + fn it_should_allow_setting_the_gauge_for_multiple_labels() { + let label1 = LabelSet::from([("name", "value1")]); + let label2 = LabelSet::from([("name", "value2")]); + let now = sample_update_time(); + + let mut collection = SampleCollection::::default(); + + collection.set(&label1, 1.0, now); + collection.set(&label2, 2.0, now); + + assert_eq!(collection.get(&label1).unwrap().value(), &Gauge::new(1.0)); + assert_eq!(collection.get(&label2).unwrap().value(), &Gauge::new(2.0)); + assert_eq!(collection.len(), 2); + } + + #[test] + fn it_should_allow_incrementing_the_gauge() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Initialize the sample + collection.set(&label_set, 1.0, sample_update_time()); + + // Increment + collection.increment(&label_set, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(2.0)); + } + + #[test] + fn it_should_allow_decrementing_the_gauge() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::::default(); + + // Initialize the sample + collection.set(&label_set, 1.0, sample_update_time()); + + // Increment + collection.decrement(&label_set, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(0.0)); + } + } +} diff --git a/packages/metrics/src/unit.rs b/packages/metrics/src/unit.rs new file mode 100644 index 000000000..43b42bf79 --- /dev/null +++ b/packages/metrics/src/unit.rs @@ -0,0 +1,30 @@ +//! This module defines the `Unit` enum, which represents various units of +//! measurement. +//! +//! The `Unit` enum is used to specify the unit of measurement for metrics. +//! +//! They were copied from the `metrics` crate, to allow future compatibility. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Unit { + Count, + Percent, + Seconds, + Milliseconds, + Microseconds, + Nanoseconds, + Tebibytes, + Gibibytes, + Mebibytes, + Kibibytes, + Bytes, + TerabitsPerSecond, + GigabitsPerSecond, + MegabitsPerSecond, + KilobitsPerSecond, + BitsPerSecond, + CountPerSecond, +} diff --git a/packages/primitives/Cargo.toml b/packages/primitives/Cargo.toml index 02a53e3b7..21fab09bf 100644 --- a/packages/primitives/Cargo.toml +++ b/packages/primitives/Cargo.toml @@ -17,9 +17,15 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" binascii = "0" -derive_more = { version = "1", features = ["constructor"] } +bittorrent-primitives = "0.1.0" +derive_more = { version = "2", features = ["constructor"] } serde = { version = "1", features = ["derive"] } tdyne-peer-id = "1" tdyne-peer-id-registry = "0" -thiserror = "1" -zerocopy = "0" +thiserror = "2" +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +url = "2.5.4" +zerocopy = "0.7" + +[dev-dependencies] +rstest = "0.25.0" diff --git a/packages/primitives/src/core.rs b/packages/primitives/src/core.rs new file mode 100644 index 000000000..aa2fe6926 --- /dev/null +++ b/packages/primitives/src/core.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use derive_more::derive::Constructor; +use torrust_tracker_configuration::AnnouncePolicy; + +use crate::peer; +use crate::swarm_metadata::SwarmMetadata; + +/// Structure that holds the data returned by the `announce` request. +#[derive(Clone, Debug, PartialEq, Constructor, Default)] +pub struct AnnounceData { + /// The list of peers that are downloading the same torrent. + /// It excludes the peer that made the request. + pub peers: Vec>, + /// Swarm statistics + pub stats: SwarmMetadata, + pub policy: AnnouncePolicy, +} + +/// Structure that holds the data returned by the `scrape` request. +#[derive(Debug, PartialEq, Default)] +pub struct ScrapeData { + /// A map of infohashes and swarm metadata for each torrent. + pub files: HashMap, +} + +impl ScrapeData { + /// Creates a new empty `ScrapeData` with no files (torrents). + #[must_use] + pub fn empty() -> Self { + let files: HashMap = HashMap::new(); + Self { files } + } + + /// Creates a new `ScrapeData` with zeroed metadata for each torrent. + #[must_use] + pub fn zeroed(info_hashes: &Vec) -> Self { + let mut scrape_data = Self::empty(); + + for info_hash in info_hashes { + scrape_data.add_file(info_hash, SwarmMetadata::zeroed()); + } + + scrape_data + } + + /// Adds a torrent to the `ScrapeData`. + pub fn add_file(&mut self, info_hash: &InfoHash, swarm_metadata: SwarmMetadata) { + self.files.insert(*info_hash, swarm_metadata); + } + + /// Adds a torrent to the `ScrapeData` with zeroed metadata. + pub fn add_file_with_zeroed_metadata(&mut self, info_hash: &InfoHash) { + self.files.insert(*info_hash, SwarmMetadata::zeroed()); + } +} + +#[cfg(test)] +mod tests { + + use bittorrent_primitives::info_hash::InfoHash; + + use crate::core::ScrapeData; + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + #[test] + fn it_should_be_able_to_build_a_zeroed_scrape_data_for_a_list_of_info_hashes() { + // Zeroed scrape data is used when the authentication for the scrape request fails. + + let sample_info_hash = sample_info_hash(); + + let mut expected_scrape_data = ScrapeData::empty(); + expected_scrape_data.add_file_with_zeroed_metadata(&sample_info_hash); + + assert_eq!(ScrapeData::zeroed(&vec![sample_info_hash]), expected_scrape_data); + } +} diff --git a/packages/primitives/src/info_hash.rs b/packages/primitives/src/info_hash.rs deleted file mode 100644 index 61b40a746..000000000 --- a/packages/primitives/src/info_hash.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::ops::{Deref, DerefMut}; -use std::panic::Location; - -use thiserror::Error; -use zerocopy::FromBytes; - -/// `BitTorrent` Info Hash v1 -#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] -pub struct InfoHash { - data: aquatic_udp_protocol::InfoHash, -} - -pub const INFO_HASH_BYTES_LEN: usize = 20; - -impl InfoHash { - /// Create a new `InfoHash` from a byte slice. - /// - /// # Panics - /// - /// Will panic if byte slice does not contains the exact amount of bytes need for the `InfoHash`. - #[must_use] - pub fn from_bytes(bytes: &[u8]) -> Self { - let data = aquatic_udp_protocol::InfoHash::read_from(bytes).expect("it should have the exact amount of bytes"); - - Self { data } - } - - /// Returns the `InfoHash` internal byte array. - #[must_use] - pub fn bytes(&self) -> [u8; 20] { - self.0 - } - - /// Returns the `InfoHash` as a hex string. - #[must_use] - pub fn to_hex_string(&self) -> String { - self.to_string() - } -} - -impl Default for InfoHash { - fn default() -> Self { - Self { - data: aquatic_udp_protocol::InfoHash(Default::default()), - } - } -} - -impl From for InfoHash { - fn from(data: aquatic_udp_protocol::InfoHash) -> Self { - Self { data } - } -} - -impl Deref for InfoHash { - type Target = aquatic_udp_protocol::InfoHash; - - fn deref(&self) -> &Self::Target { - &self.data - } -} - -impl DerefMut for InfoHash { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.data - } -} - -impl Ord for InfoHash { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.0.cmp(&other.0) - } -} - -impl PartialOrd for InfoHash { - fn partial_cmp(&self, other: &InfoHash) -> Option { - Some(self.cmp(other)) - } -} - -impl std::fmt::Display for InfoHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut chars = [0u8; 40]; - binascii::bin2hex(&self.0, &mut chars).expect("failed to hexlify"); - write!(f, "{}", std::str::from_utf8(&chars).unwrap()) - } -} - -impl std::str::FromStr for InfoHash { - type Err = binascii::ConvertError; - - fn from_str(s: &str) -> Result { - let mut i = Self::default(); - if s.len() != 40 { - return Err(binascii::ConvertError::InvalidInputLength); - } - binascii::hex2bin(s.as_bytes(), &mut i.0)?; - Ok(i) - } -} - -impl std::convert::From<&[u8]> for InfoHash { - fn from(data: &[u8]) -> InfoHash { - assert_eq!(data.len(), 20); - let mut ret = Self::default(); - ret.0.clone_from_slice(data); - ret - } -} - -/// for testing -impl std::convert::From<&DefaultHasher> for InfoHash { - fn from(data: &DefaultHasher) -> InfoHash { - let n = data.finish().to_le_bytes(); - let bytes = [ - n[0], n[1], n[2], n[3], n[4], n[5], n[6], n[7], n[0], n[1], n[2], n[3], n[4], n[5], n[6], n[7], n[0], n[1], n[2], - n[3], - ]; - let data = aquatic_udp_protocol::InfoHash(bytes); - Self { data } - } -} - -impl std::convert::From<&i32> for InfoHash { - fn from(n: &i32) -> InfoHash { - let n = n.to_le_bytes(); - let bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, n[0], n[1], n[2], n[3]]; - let data = aquatic_udp_protocol::InfoHash(bytes); - Self { data } - } -} - -impl std::convert::From<[u8; 20]> for InfoHash { - fn from(bytes: [u8; 20]) -> Self { - let data = aquatic_udp_protocol::InfoHash(bytes); - Self { data } - } -} - -/// Errors that can occur when converting from a `Vec` to an `InfoHash`. -#[derive(Error, Debug)] -pub enum ConversionError { - /// Not enough bytes for infohash. An infohash is 20 bytes. - #[error("not enough bytes for infohash: {message} {location}")] - NotEnoughBytes { - location: &'static Location<'static>, - message: String, - }, - /// Too many bytes for infohash. An infohash is 20 bytes. - #[error("too many bytes for infohash: {message} {location}")] - TooManyBytes { - location: &'static Location<'static>, - message: String, - }, -} - -impl TryFrom> for InfoHash { - type Error = ConversionError; - - fn try_from(bytes: Vec) -> Result { - if bytes.len() < INFO_HASH_BYTES_LEN { - return Err(ConversionError::NotEnoughBytes { - location: Location::caller(), - message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, - }); - } - if bytes.len() > INFO_HASH_BYTES_LEN { - return Err(ConversionError::TooManyBytes { - location: Location::caller(), - message: format! {"got {} bytes, expected {}", bytes.len(), INFO_HASH_BYTES_LEN}, - }); - } - Ok(Self::from_bytes(&bytes)) - } -} - -impl serde::ser::Serialize for InfoHash { - fn serialize(&self, serializer: S) -> Result { - let mut buffer = [0u8; 40]; - let bytes_out = binascii::bin2hex(&self.0, &mut buffer).ok().unwrap(); - let str_out = std::str::from_utf8(bytes_out).unwrap(); - serializer.serialize_str(str_out) - } -} - -impl<'de> serde::de::Deserialize<'de> for InfoHash { - fn deserialize>(des: D) -> Result { - des.deserialize_str(InfoHashVisitor) - } -} - -struct InfoHashVisitor; - -impl<'v> serde::de::Visitor<'v> for InfoHashVisitor { - type Value = InfoHash; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "a 40 character long hash") - } - - fn visit_str(self, v: &str) -> Result { - if v.len() != 40 { - return Err(serde::de::Error::invalid_value( - serde::de::Unexpected::Str(v), - &"a 40 character long string", - )); - } - - let mut res = InfoHash::default(); - - if binascii::hex2bin(v.as_bytes(), &mut res.0).is_err() { - return Err(serde::de::Error::invalid_value( - serde::de::Unexpected::Str(v), - &"a hexadecimal string", - )); - }; - Ok(res) - } -} diff --git a/packages/primitives/src/lib.rs b/packages/primitives/src/lib.rs index 08fc58976..ec2edda97 100644 --- a/packages/primitives/src/lib.rs +++ b/packages/primitives/src/lib.rs @@ -4,18 +4,19 @@ //! which is a `BitTorrent` tracker server. These structures are used not only //! by the tracker server crate, but also by other crates in the Torrust //! ecosystem. -use std::collections::BTreeMap; -use std::time::Duration; - -use info_hash::InfoHash; - -pub mod info_hash; +pub mod core; pub mod pagination; pub mod peer; +pub mod service_binding; pub mod swarm_metadata; -pub mod torrent_metrics; + +use std::collections::BTreeMap; +use std::time::Duration; + +use bittorrent_primitives::info_hash::InfoHash; /// Duration since the Unix Epoch. pub type DurationSinceUnixEpoch = Duration; -pub type PersistentTorrents = BTreeMap; +pub type NumberOfDownloads = u32; +pub type NumberOfDownloadsBTreeMap = BTreeMap; diff --git a/packages/primitives/src/peer.rs b/packages/primitives/src/peer.rs index c8ff1791d..ef47f28f8 100644 --- a/packages/primitives/src/peer.rs +++ b/packages/primitives/src/peer.rs @@ -22,8 +22,10 @@ //! }; //! ``` +use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::ops::{Deref, DerefMut}; +use std::str::FromStr; use std::sync::Arc; use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; @@ -32,6 +34,59 @@ use zerocopy::FromBytes as _; use crate::DurationSinceUnixEpoch; +pub type PeerAnnouncement = Peer; + +#[derive(Debug, Serialize, Copy, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all_fields = "lowercase")] +pub enum PeerRole { + Seeder, + Leecher, +} + +impl PeerRole { + /// Returns the opposite role: Seeder becomes Leecher, and vice versa. + #[must_use] + pub fn opposite(self) -> Self { + match self { + PeerRole::Seeder => PeerRole::Leecher, + PeerRole::Leecher => PeerRole::Seeder, + } + } +} + +impl fmt::Display for PeerRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PeerRole::Seeder => write!(f, "seeder"), + PeerRole::Leecher => write!(f, "leecher"), + } + } +} + +impl FromStr for PeerRole { + type Err = ParsePeerRoleError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "seeder" => Ok(PeerRole::Seeder), + "leecher" => Ok(PeerRole::Leecher), + _ => Err(ParsePeerRoleError::InvalidPeerRole { + location: Location::caller(), + raw_param: s.to_string(), + }), + } + } +} + +#[derive(Error, Debug)] +pub enum ParsePeerRoleError { + #[error("invalid param {raw_param} in {location}")] + InvalidPeerRole { + location: &'static Location<'static>, + raw_param: String, + }, +} + /// Peer struct used by the core `Tracker`. /// /// A sample peer: @@ -139,12 +194,13 @@ impl Ord for Peer { impl PartialOrd for Peer { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.peer_id.cmp(&other.peer_id)) + Some(self.cmp(other)) } } pub trait ReadInfo { fn is_seeder(&self) -> bool; + fn is_leecher(&self) -> bool; fn get_event(&self) -> AnnounceEvent; fn get_id(&self) -> PeerId; fn get_updated(&self) -> DurationSinceUnixEpoch; @@ -156,6 +212,10 @@ impl ReadInfo for Peer { self.left.0.get() <= 0 && self.event != AnnounceEvent::Stopped } + fn is_leecher(&self) -> bool { + !self.is_seeder() + } + fn get_event(&self) -> AnnounceEvent { self.event } @@ -178,6 +238,10 @@ impl ReadInfo for Arc { self.left.0.get() <= 0 && self.event != AnnounceEvent::Stopped } + fn is_leecher(&self) -> bool { + !self.is_seeder() + } + fn get_event(&self) -> AnnounceEvent { self.event } @@ -201,6 +265,25 @@ impl Peer { self.left.0.get() <= 0 && self.event != AnnounceEvent::Stopped } + #[must_use] + pub fn is_leecher(&self) -> bool { + !self.is_seeder() + } + + #[must_use] + pub fn is_completed(&self) -> bool { + self.event == AnnounceEvent::Completed + } + + #[must_use] + pub fn role(&self) -> PeerRole { + if self.is_seeder() { + PeerRole::Seeder + } else { + PeerRole::Leecher + } + } + pub fn ip(&mut self) -> IpAddr { self.peer_addr.ip() } @@ -208,6 +291,26 @@ impl Peer { pub fn change_ip(&mut self, new_ip: &IpAddr) { self.peer_addr = SocketAddr::new(*new_ip, self.peer_addr.port()); } + + pub fn mark_as_completed(&mut self) { + self.event = AnnounceEvent::Completed; + } + + #[must_use] + pub fn into_completed(self) -> Self { + Self { + event: AnnounceEvent::Completed, + ..self + } + } + + #[must_use] + pub fn into_seeder(self) -> Self { + Self { + left: NumberOfBytes::new(0), + ..self + } + } } use std::panic::Location; @@ -414,7 +517,7 @@ pub mod fixture { pub fn seeder() -> Self { let peer = Peer { peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -455,34 +558,59 @@ pub mod fixture { self } - #[allow(dead_code)] #[must_use] - pub fn with_bytes_pending_to_download(mut self, left: i64) -> Self { + pub fn with_peer_address(mut self, peer_addr: SocketAddr) -> Self { + self.peer.peer_addr = peer_addr; + self + } + + #[must_use] + pub fn updated_on(mut self, updated: DurationSinceUnixEpoch) -> Self { + self.peer.updated = updated; + self + } + + #[must_use] + pub fn with_bytes_left_to_download(mut self, left: i64) -> Self { self.peer.left = NumberOfBytes::new(left); self } - #[allow(dead_code)] #[must_use] - pub fn with_no_bytes_pending_to_download(mut self) -> Self { + pub fn with_no_bytes_left_to_download(mut self) -> Self { self.peer.left = NumberOfBytes::new(0); self } - #[allow(dead_code)] #[must_use] pub fn last_updated_on(mut self, updated: DurationSinceUnixEpoch) -> Self { self.peer.updated = updated; self } - #[allow(dead_code)] + #[must_use] + pub fn with_event(mut self, event: AnnounceEvent) -> Self { + self.peer.event = event; + self + } + + #[must_use] + pub fn with_event_started(mut self) -> Self { + self.peer.event = AnnounceEvent::Started; + self + } + + #[must_use] + pub fn with_event_completed(mut self) -> Self { + self.peer.event = AnnounceEvent::Completed; + self + } + #[must_use] pub fn build(self) -> Peer { self.into() } - #[allow(dead_code)] #[must_use] pub fn into(self) -> Peer { self.peer @@ -493,7 +621,7 @@ pub mod fixture { fn default() -> Self { Self { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -513,6 +641,22 @@ pub mod fixture { #[cfg(test)] pub mod test { + + mod peer { + use crate::peer::fixture::PeerBuilder; + + #[test] + fn should_be_comparable() { + let seeder1 = PeerBuilder::seeder().build(); + let seeder2 = PeerBuilder::seeder().build(); + + let leecher1 = PeerBuilder::leecher().build(); + + assert!(seeder1 == seeder2); + assert!(seeder1 != leecher1); + } + } + mod torrent_peer_id { use aquatic_udp_protocol::PeerId; diff --git a/packages/primitives/src/service_binding.rs b/packages/primitives/src/service_binding.rs new file mode 100644 index 000000000..c1ec308c8 --- /dev/null +++ b/packages/primitives/src/service_binding.rs @@ -0,0 +1,297 @@ +use std::fmt; +use std::net::{IpAddr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +const DUAL_STACK_IP_V4_MAPPED_V6_PREFIX: &str = "::ffff:"; + +/// Represents the supported network protocols. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum Protocol { + UDP, + HTTP, + HTTPS, +} + +impl fmt::Display for Protocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let proto_str = match self { + Protocol::UDP => "udp", + Protocol::HTTP => "http", + Protocol::HTTPS => "https", + }; + write!(f, "{proto_str}") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum IpType { + /// Represents a plain IPv4 or IPv6 address. + Plain, + + /// Represents an IPv6 address that is a mapped IPv4 address. + /// + /// This is used for IPv6 addresses that represent an IPv4 address in a dual-stack network. + /// + /// For example: `[::ffff:192.0.2.33]` + V4MappedV6, +} + +impl fmt::Display for IpType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let ip_type_str = match self { + Self::Plain => "plain", + Self::V4MappedV6 => "v4_mapped_v6", + }; + write!(f, "{ip_type_str}") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum IpFamily { + // IPv4 + Inet, + // IPv6 + Inet6, +} + +impl fmt::Display for IpFamily { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let ip_family_str = match self { + Self::Inet => "inet", + Self::Inet6 => "inet6", + }; + write!(f, "{ip_family_str}") + } +} + +impl From for IpFamily { + fn from(ip: IpAddr) -> Self { + if ip.is_ipv4() { + return IpFamily::Inet; + } + + if ip.is_ipv6() { + return IpFamily::Inet6; + } + + panic!("Unsupported IP address type: {ip}"); + } +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("The port number cannot be zero. It must be an assigned valid port.")] + PortZeroNotAllowed, +} + +/// Represents a network service binding, encapsulating protocol and socket +/// address. +/// +/// This struct is used to define how a service binds to a network interface and +/// port. +/// +/// It's an URL without path and some restrictions: +/// +/// - Only some schemes are accepted: `udp`, `http`, `https`. +/// - The port number must be greater than zero. The service should be already +/// listening on that port. +/// - The authority part of the URL must be a valid socket address (wildcard is +/// accepted). +/// +/// Besides it accepts some non well-formed URLs, like: +/// or . Those URLs are not valid because they use non +/// standard ports (80 and 443). +/// +/// NOTICE: It does not represent a public valid URL clients can connect to. It +/// represents the service's internal URL configuration after assigning a port. +/// If the port in the configuration is not zero, it's basically the same +/// information you get from the configuration (binding address + protocol). +/// +/// # Examples +/// +/// ``` +/// use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +/// use torrust_tracker_primitives::service_binding::{ServiceBinding, Protocol}; +/// +/// let service_binding = ServiceBinding::new(Protocol::HTTP, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070)).unwrap(); +/// +/// assert_eq!(service_binding.url().to_string(), "http://127.0.0.1:7070/".to_string()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct ServiceBinding { + /// The network protocol used by the service (UDP, HTTP, HTTPS). + protocol: Protocol, + + /// The socket address (IP and port) to which the service binds. + bind_address: SocketAddr, +} + +impl ServiceBinding { + /// # Errors + /// + /// This function will return an error if the port number is zero. + pub fn new(protocol: Protocol, bind_address: SocketAddr) -> Result { + if bind_address.port() == 0 { + return Err(Error::PortZeroNotAllowed); + } + + Ok(Self { protocol, bind_address }) + } + + /// Returns the protocol used by the service. + #[must_use] + pub fn protocol(&self) -> Protocol { + self.protocol.clone() + } + + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.bind_address + } + + #[must_use] + pub fn bind_address_ip_type(&self) -> IpType { + if self.is_v4_mapped_v6() { + return IpType::V4MappedV6; + } + + IpType::Plain + } + + #[must_use] + pub fn bind_address_ip_family(&self) -> IpFamily { + self.bind_address.ip().into() + } + + /// # Panics + /// + /// It never panics because the URL is always valid. + #[must_use] + pub fn url(&self) -> Url { + Url::parse(&format!("{}://{}", self.protocol, self.bind_address)) + .expect("Service binding can always be parsed into a URL") + } + + fn is_v4_mapped_v6(&self) -> bool { + self.bind_address.ip().is_ipv6() + && self + .bind_address + .ip() + .to_string() + .starts_with(DUAL_STACK_IP_V4_MAPPED_V6_PREFIX) + } +} + +impl From for Url { + fn from(service_binding: ServiceBinding) -> Self { + service_binding.url() + } +} + +impl fmt::Display for ServiceBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.url()) + } +} + +#[cfg(test)] +mod tests { + + mod the_service_binding { + use std::net::SocketAddr; + use std::str::FromStr; + + use rstest::rstest; + use url::Url; + + use crate::service_binding::{Error, IpType, Protocol, ServiceBinding}; + + #[rstest] + #[case("wildcard_ip", Protocol::UDP, SocketAddr::from_str("0.0.0.0:6969").unwrap())] + #[case("udp_service", Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap())] + #[case("http_service", Protocol::HTTP, SocketAddr::from_str("127.0.0.1:7070").unwrap())] + #[case("https_service", Protocol::HTTPS, SocketAddr::from_str("127.0.0.1:7070").unwrap())] + fn should_allow_a_subset_of_urls(#[case] case: &str, #[case] protocol: Protocol, #[case] bind_address: SocketAddr) { + let service_binding = ServiceBinding::new(protocol.clone(), bind_address); + + assert!(service_binding.is_ok(), "{}", format!("{case} failed: {service_binding:?}")); + } + + #[test] + fn should_not_allow_undefined_port_zero() { + let service_binding = ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("127.0.0.1:0").unwrap()); + + assert!(matches!(service_binding, Err(Error::PortZeroNotAllowed))); + } + + #[test] + fn should_return_the_bind_address() { + let service_binding = ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap()).unwrap(); + + assert_eq!( + service_binding.bind_address(), + SocketAddr::from_str("127.0.0.1:6969").unwrap() + ); + } + + #[test] + fn should_return_the_bind_address_plain_type_for_ipv4_ips() { + let service_binding = ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap()).unwrap(); + + assert_eq!(service_binding.bind_address_ip_type(), IpType::Plain); + } + + #[test] + fn should_return_the_bind_address_plain_type_for_ipv6_ips() { + let service_binding = + ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("[0:0:0:0:0:0:0:1]:6969").unwrap()).unwrap(); + + assert_eq!(service_binding.bind_address_ip_type(), IpType::Plain); + } + + #[test] + fn should_return_the_bind_address_v4_mapped_v7_type_for_ipv4_ips_mapped_to_ipv6() { + let service_binding = + ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("[::ffff:192.0.2.33]:6969").unwrap()).unwrap(); + + assert_eq!(service_binding.bind_address_ip_type(), IpType::V4MappedV6); + } + + #[test] + fn should_return_the_corresponding_url() { + let service_binding = ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap()).unwrap(); + + assert_eq!(service_binding.url(), Url::parse("udp://127.0.0.1:6969").unwrap()); + } + + #[test] + fn should_be_converted_into_an_url() { + let service_binding = ServiceBinding::new(Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap()).unwrap(); + + let url: Url = service_binding.clone().into(); + + assert_eq!(url, Url::parse("udp://127.0.0.1:6969").unwrap()); + } + + #[rstest] + #[case("udp_service", Protocol::UDP, SocketAddr::from_str("127.0.0.1:6969").unwrap(), "udp://127.0.0.1:6969")] + #[case("http_service", Protocol::HTTP, SocketAddr::from_str("127.0.0.1:7070").unwrap(), "http://127.0.0.1:7070/")] + #[case("https_service", Protocol::HTTPS, SocketAddr::from_str("127.0.0.1:7070").unwrap(), "https://127.0.0.1:7070/")] + fn should_always_have_a_corresponding_unique_url( + #[case] case: &str, + #[case] protocol: Protocol, + #[case] bind_address: SocketAddr, + #[case] expected_url: String, + ) { + let service_binding = ServiceBinding::new(protocol.clone(), bind_address).unwrap(); + + assert_eq!( + service_binding.url().to_string(), + expected_url, + "{case} failed: {service_binding:?}", + ); + } + } +} diff --git a/packages/primitives/src/swarm_metadata.rs b/packages/primitives/src/swarm_metadata.rs index ca880b54d..57ba816d3 100644 --- a/packages/primitives/src/swarm_metadata.rs +++ b/packages/primitives/src/swarm_metadata.rs @@ -1,16 +1,24 @@ +use std::ops::AddAssign; + use derive_more::Constructor; /// Swarm statistics for one torrent. +/// /// Swarm metadata dictionary in the scrape response. /// /// See [BEP 48: Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html) -#[derive(Copy, Clone, Debug, PartialEq, Default, Constructor)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Constructor)] pub struct SwarmMetadata { - /// (i.e `completed`): The number of peers that have ever completed downloading - pub downloaded: u32, // - /// (i.e `seeders`): The number of active peers that have completed downloading (seeders) - pub complete: u32, //seeders - /// (i.e `leechers`): The number of active peers that have not completed downloading (leechers) + /// (i.e `completed`): The number of peers that have ever completed + /// downloading a given torrent. + pub downloaded: u32, + + /// (i.e `seeders`): The number of active peers that have completed + /// downloading (seeders) a given torrent. + pub complete: u32, + + /// (i.e `leechers`): The number of active peers that have not completed + /// downloading (leechers) a given torrent. pub incomplete: u32, } @@ -19,4 +27,46 @@ impl SwarmMetadata { pub fn zeroed() -> Self { Self::default() } + + #[must_use] + pub fn downloads(&self) -> u32 { + self.downloaded + } + + #[must_use] + pub fn seeders(&self) -> u32 { + self.complete + } + + #[must_use] + pub fn leechers(&self) -> u32 { + self.incomplete + } +} + +/// Structure that holds aggregate swarm metadata. +/// +/// Metrics are aggregate values for all active torrents/swarms. +#[derive(Copy, Clone, Debug, PartialEq, Default)] +pub struct AggregateActiveSwarmMetadata { + /// Total number of peers that have ever completed downloading. + pub total_downloaded: u64, + + /// Total number of seeders. + pub total_complete: u64, + + /// Total number of leechers. + pub total_incomplete: u64, + + /// Total number of torrents. + pub total_torrents: u64, +} + +impl AddAssign for AggregateActiveSwarmMetadata { + fn add_assign(&mut self, rhs: Self) { + self.total_complete += rhs.total_complete; + self.total_downloaded += rhs.total_downloaded; + self.total_incomplete += rhs.total_incomplete; + self.total_torrents += rhs.total_torrents; + } } diff --git a/packages/primitives/src/torrent_metrics.rs b/packages/primitives/src/torrent_metrics.rs deleted file mode 100644 index 02de02954..000000000 --- a/packages/primitives/src/torrent_metrics.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::ops::AddAssign; - -/// Structure that holds general `Tracker` torrents metrics. -/// -/// Metrics are aggregate values for all torrents. -#[derive(Copy, Clone, Debug, PartialEq, Default)] -pub struct TorrentsMetrics { - /// Total number of seeders for all torrents - pub complete: u64, - /// Total number of peers that have ever completed downloading for all torrents. - pub downloaded: u64, - /// Total number of leechers for all torrents. - pub incomplete: u64, - /// Total number of torrents. - pub torrents: u64, -} - -impl AddAssign for TorrentsMetrics { - fn add_assign(&mut self, rhs: Self) { - self.complete += rhs.complete; - self.downloaded += rhs.downloaded; - self.incomplete += rhs.incomplete; - self.torrents += rhs.torrents; - } -} diff --git a/packages/rest-tracker-api-client/Cargo.toml b/packages/rest-tracker-api-client/Cargo.toml new file mode 100644 index 000000000..c01b9c05a --- /dev/null +++ b/packages/rest-tracker-api-client/Cargo.toml @@ -0,0 +1,23 @@ +[package] +description = "A library to interact with the Torrust Tracker REST API." +keywords = ["bittorrent", "client", "tracker"] +license = "LGPL-3.0" +name = "torrust-rest-tracker-api-client" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +hyper = "1" +reqwest = { version = "0", features = ["json", "query"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +url = { version = "2", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } diff --git a/packages/rest-tracker-api-client/README.md b/packages/rest-tracker-api-client/README.md new file mode 100644 index 000000000..3c10cdb5c --- /dev/null +++ b/packages/rest-tracker-api-client/README.md @@ -0,0 +1,23 @@ +# Torrust Tracker API Client + +A library to interact with the Torrust Tracker REST API. + +## License + +**Copyright (c) 2024 The Torrust Developers.** + +This program is free software: you can redistribute it and/or modify it under the terms of the [GNU Lesser General Public License][LGPL_3_0] as published by the [Free Software Foundation][FSF], version 3. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Lesser General Public License][LGPL_3_0] for more details. + +You should have received a copy of the *GNU Lesser General Public License* along with this program. If not, see . + +Some files include explicit copyright notices and/or license notices. + +### Legacy Exception + +For prosperity, versions of Torrust BitTorrent Tracker Client that are older than five years are automatically granted the [MIT-0][MIT_0] license in addition to the existing [LGPL-3.0-only][LGPL_3_0] license. + +[LGPL_3_0]: ./LICENSE +[MIT_0]: ./docs/licenses/LICENSE-MIT_0 +[FSF]: https://www.fsf.org/ diff --git a/packages/rest-tracker-api-client/docs/licenses/LICENSE-MIT_0 b/packages/rest-tracker-api-client/docs/licenses/LICENSE-MIT_0 new file mode 100644 index 000000000..fc06cc4fe --- /dev/null +++ b/packages/rest-tracker-api-client/docs/licenses/LICENSE-MIT_0 @@ -0,0 +1,14 @@ +MIT No Attribution + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/rest-tracker-api-client/src/common/http.rs b/packages/rest-tracker-api-client/src/common/http.rs new file mode 100644 index 000000000..adbc7dc15 --- /dev/null +++ b/packages/rest-tracker-api-client/src/common/http.rs @@ -0,0 +1,57 @@ +pub type ReqwestQuery = Vec; +pub type ReqwestQueryParam = (String, String); + +/// URL Query component +#[derive(Default, Debug)] +pub struct Query { + params: Vec, +} + +impl Query { + #[must_use] + pub fn empty() -> Self { + Self { params: vec![] } + } + + #[must_use] + pub fn params(params: Vec) -> Self { + Self { params } + } + + pub fn add_param(&mut self, param: QueryParam) { + self.params.push(param); + } +} + +impl From for ReqwestQuery { + fn from(url_search_params: Query) -> Self { + url_search_params + .params + .iter() + .map(|param| ReqwestQueryParam::from((*param).clone())) + .collect() + } +} + +/// URL query param +#[derive(Clone, Debug)] +pub struct QueryParam { + name: String, + value: String, +} + +impl QueryParam { + #[must_use] + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + +impl From for ReqwestQueryParam { + fn from(param: QueryParam) -> Self { + (param.name, param.value) + } +} diff --git a/packages/rest-tracker-api-client/src/common/mod.rs b/packages/rest-tracker-api-client/src/common/mod.rs new file mode 100644 index 000000000..3883215fc --- /dev/null +++ b/packages/rest-tracker-api-client/src/common/mod.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/packages/rest-tracker-api-client/src/connection_info.rs b/packages/rest-tracker-api-client/src/connection_info.rs new file mode 100644 index 000000000..1224527ae --- /dev/null +++ b/packages/rest-tracker-api-client/src/connection_info.rs @@ -0,0 +1,154 @@ +use std::str::FromStr; + +use thiserror::Error; +use url::Url; + +#[derive(Clone)] +pub struct ConnectionInfo { + pub origin: Origin, + pub api_token: Option, +} + +impl ConnectionInfo { + #[must_use] + pub fn authenticated(origin: Origin, api_token: &str) -> Self { + Self { + origin, + api_token: Some(api_token.to_string()), + } + } + + #[must_use] + pub fn anonymous(origin: Origin) -> Self { + Self { origin, api_token: None } + } +} + +/// Represents the origin of a HTTP request. +/// +/// The format of the origin is a URL, but only the scheme, host, and port are used. +/// +/// Pattern: `scheme://host:port/` +#[derive(Debug, Clone)] +pub struct Origin { + url: Url, +} + +#[derive(Debug, Error)] +pub enum OriginError { + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + #[error("URL is missing scheme or host")] + InvalidOrigin, + + #[error("Invalid URL scheme, only http and https are supported")] + InvalidScheme, +} + +impl FromStr for Origin { + type Err = OriginError; + + fn from_str(s: &str) -> Result { + let mut url = Url::parse(s).map_err(OriginError::InvalidUrl)?; + + // Ensure the URL has a scheme and host + if url.scheme().is_empty() || url.host().is_none() { + return Err(OriginError::InvalidOrigin); + } + + if url.scheme() != "http" && url.scheme() != "https" { + return Err(OriginError::InvalidScheme); + } + + // Retain only the origin components + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + + Ok(Origin { url }) + } +} + +impl std::fmt::Display for Origin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url) + } +} + +impl Origin { + /// # Errors + /// + /// Will return an error if the string is not a valid URL containing a + /// scheme and host. + pub fn new(s: &str) -> Result { + s.parse() + } + + #[must_use] + pub fn url(&self) -> &Url { + &self.url + } +} + +#[cfg(test)] +mod tests { + mod origin { + use crate::connection_info::Origin; + + #[test] + fn should_be_parsed_from_a_string_representing_a_url() { + let origin = Origin::new("https://example.com:8080/path?query#fragment").unwrap(); + + assert_eq!(origin.to_string(), "https://example.com:8080/"); + } + + mod when_parsing_from_url_string { + use crate::connection_info::Origin; + + #[test] + fn should_ignore_default_ports() { + let origin = Origin::new("http://example.com:80").unwrap(); // DevSkim: ignore DS137138 + assert_eq!(origin.to_string(), "http://example.com/"); // DevSkim: ignore DS137138 + + let origin = Origin::new("https://example.com:443").unwrap(); + assert_eq!(origin.to_string(), "https://example.com/"); + } + + #[test] + fn should_add_the_slash_after_the_host() { + let origin = Origin::new("https://example.com:1212").unwrap(); + + assert_eq!(origin.to_string(), "https://example.com:1212/"); + } + + #[test] + fn should_remove_extra_path_and_query_parameters() { + let origin = Origin::new("https://example.com:1212/path/to/resource?query=1#fragment").unwrap(); + + assert_eq!(origin.to_string(), "https://example.com:1212/"); + } + + #[test] + fn should_fail_when_the_scheme_is_missing() { + let result = Origin::new("example.com"); + + assert!(result.is_err()); + } + + #[test] + fn should_fail_when_the_scheme_is_not_supported() { + let result = Origin::new("udp://example.com"); + + assert!(result.is_err()); + } + + #[test] + fn should_fail_when_the_host_is_missing() { + let result = Origin::new("http://"); + + assert!(result.is_err()); + } + } + } +} diff --git a/packages/rest-tracker-api-client/src/lib.rs b/packages/rest-tracker-api-client/src/lib.rs new file mode 100644 index 000000000..baf80e3cd --- /dev/null +++ b/packages/rest-tracker-api-client/src/lib.rs @@ -0,0 +1,3 @@ +pub mod common; +pub mod connection_info; +pub mod v1; diff --git a/packages/rest-tracker-api-client/src/v1/client.rs b/packages/rest-tracker-api-client/src/v1/client.rs new file mode 100644 index 000000000..02a5b0d9c --- /dev/null +++ b/packages/rest-tracker-api-client/src/v1/client.rs @@ -0,0 +1,265 @@ +use std::time::Duration; + +use hyper::{header, HeaderMap}; +use reqwest::{Error, Response}; +use serde::Serialize; +use url::Url; +use uuid::Uuid; + +use crate::common::http::{Query, QueryParam, ReqwestQuery}; +use crate::connection_info::ConnectionInfo; + +pub const TOKEN_PARAM_NAME: &str = "token"; +pub const AUTH_BEARER_TOKEN_HEADER_PREFIX: &str = "Bearer"; + +const API_PATH: &str = "api/v1/"; +const DEFAULT_REQUEST_TIMEOUT_IN_SECS: u64 = 5; + +/// API Client +#[allow(clippy::struct_field_names)] +pub struct Client { + connection_info: ConnectionInfo, + base_path: String, + http_client: reqwest::Client, +} + +impl Client { + /// # Errors + /// + /// Will return an error if the HTTP client can't be created. + pub fn new(connection_info: ConnectionInfo) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_IN_SECS)) + .build()?; + + Ok(Self { + connection_info, + base_path: API_PATH.to_string(), + http_client: client, + }) + } + + pub async fn generate_auth_key(&self, seconds_valid: i32, headers: Option) -> Response { + self.post_empty(&format!("key/{}", &seconds_valid), headers).await + } + + pub async fn add_auth_key(&self, add_key_form: AddKeyForm, headers: Option) -> Response { + self.post_form("keys", &add_key_form, headers).await + } + + pub async fn delete_auth_key(&self, key: &str, headers: Option) -> Response { + self.delete(&format!("key/{}", &key), headers).await + } + + pub async fn reload_keys(&self, headers: Option) -> Response { + self.get("keys/reload", Query::default(), headers).await + } + + pub async fn whitelist_a_torrent(&self, info_hash: &str, headers: Option) -> Response { + self.post_empty(&format!("whitelist/{}", &info_hash), headers).await + } + + pub async fn remove_torrent_from_whitelist(&self, info_hash: &str, headers: Option) -> Response { + self.delete(&format!("whitelist/{}", &info_hash), headers).await + } + + pub async fn reload_whitelist(&self, headers: Option) -> Response { + self.get("whitelist/reload", Query::default(), headers).await + } + + pub async fn get_torrent(&self, info_hash: &str, headers: Option) -> Response { + self.get(&format!("torrent/{}", &info_hash), Query::default(), headers).await + } + + pub async fn get_torrents(&self, params: Query, headers: Option) -> Response { + self.get("torrents", params, headers).await + } + + pub async fn get_tracker_statistics(&self, headers: Option) -> Response { + self.get("stats", Query::default(), headers).await + } + + pub async fn get(&self, path: &str, params: Query, headers: Option) -> Response { + let mut query: Query = params; + + if let Some(token) = &self.connection_info.api_token { + query.add_param(QueryParam::new(TOKEN_PARAM_NAME, token)); + } + + self.get_request_with_query(path, query, headers).await + } + + /// # Panics + /// + /// Will panic if the request can't be sent + pub async fn post_empty(&self, path: &str, headers: Option) -> Response { + let builder = self.http_client.post(self.base_url(path).clone()); + + let builder = match headers { + Some(headers) => builder.headers(headers), + None => builder, + }; + + let builder = match &self.connection_info.api_token { + Some(token) => builder.header(header::AUTHORIZATION, format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}")), + None => builder, + }; + + builder.send().await.unwrap() + } + + /// # Panics + /// + /// Will panic if the request can't be sent + pub async fn post_form(&self, path: &str, form: &T, headers: Option) -> Response { + let builder = self.http_client.post(self.base_url(path).clone()).json(&form); + + let builder = match headers { + Some(headers) => builder.headers(headers), + None => builder, + }; + + let builder = match &self.connection_info.api_token { + Some(token) => builder.header(header::AUTHORIZATION, format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}")), + None => builder, + }; + + builder.send().await.unwrap() + } + + /// # Panics + /// + /// Will panic if the request can't be sent + async fn delete(&self, path: &str, headers: Option) -> Response { + let builder = self.http_client.delete(self.base_url(path).clone()); + + let builder = match headers { + Some(headers) => builder.headers(headers), + None => builder, + }; + + let builder = match &self.connection_info.api_token { + Some(token) => builder.header(header::AUTHORIZATION, format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}")), + None => builder, + }; + + builder.send().await.unwrap() + } + + /// # Panics + /// + /// Will panic if it can't convert the authentication token to a `HeaderValue`. + pub async fn get_request_with_query(&self, path: &str, params: Query, headers: Option) -> Response { + match &self.connection_info.api_token { + Some(token) => { + let headers = if let Some(headers) = headers { + // Headers provided -> add auth token if not already present + + if headers.get(header::AUTHORIZATION).is_some() { + // Auth token already present -> use provided + headers + } else { + let mut headers = headers; + + headers.insert( + header::AUTHORIZATION, + format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}") + .parse() + .expect("the auth token is not a valid header value"), + ); + + headers + } + } else { + // No headers provided -> create headers with auth token + + let mut headers = HeaderMap::new(); + + headers.insert( + header::AUTHORIZATION, + format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}") + .parse() + .expect("the auth token is not a valid header value"), + ); + + headers + }; + + get(self.base_url(path), Some(params), Some(headers)).await + } + None => get(self.base_url(path), Some(params), headers).await, + } + } + + pub async fn get_request(&self, path: &str) -> Response { + get(self.base_url(path), None, None).await + } + + fn base_url(&self, path: &str) -> Url { + Url::parse(&format!("{}{}{path}", &self.connection_info.origin, &self.base_path)).unwrap() + } +} + +/// # Panics +/// +/// Will panic if the request can't be sent +pub async fn get(path: Url, query: Option, headers: Option) -> Response { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_IN_SECS)) + .build() + .unwrap(); + + let mut request_builder = client.get(path); + + if let Some(params) = query { + request_builder = request_builder.query(&ReqwestQuery::from(params)); + } + + if let Some(headers) = headers { + request_builder = request_builder.headers(headers); + } + + request_builder.send().await.unwrap() +} + +/// Returns a `HeaderMap` with a request id header. +/// +/// # Panics +/// +/// Will panic if the request ID can't be parsed into a `HeaderValue`. +#[must_use] +pub fn headers_with_request_id(request_id: Uuid) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "x-request-id", + request_id + .to_string() + .parse() + .expect("the request ID is not a valid header value"), + ); + headers +} + +/// Returns a `HeaderMap` with an authorization token. +/// +/// # Panics +/// +/// Will panic if the token can't be parsed into a `HeaderValue`. +#[must_use] +pub fn headers_with_auth_token(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}") + .parse() + .expect("the auth token is not a valid header value"), + ); + headers +} + +#[derive(Serialize, Debug)] +pub struct AddKeyForm { + #[serde(rename = "key")] + pub opt_key: Option, + pub seconds_valid: Option, +} diff --git a/packages/rest-tracker-api-client/src/v1/mod.rs b/packages/rest-tracker-api-client/src/v1/mod.rs new file mode 100644 index 000000000..b9babe5bc --- /dev/null +++ b/packages/rest-tracker-api-client/src/v1/mod.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/packages/rest-tracker-api-core/Cargo.toml b/packages/rest-tracker-api-core/Cargo.toml new file mode 100644 index 000000000..be6d493d7 --- /dev/null +++ b/packages/rest-tracker-api-core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors.workspace = true +description = "A library with the core functionality needed to implement a BitTorrent UDP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["api", "bittorrent", "core", "library", "tracker"] +license.workspace = true +name = "torrust-rest-tracker-api-core" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +bittorrent-http-tracker-core = { version = "3.0.0-develop", path = "../http-tracker-core" } +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } + +[dev-dependencies] +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } diff --git a/packages/rest-tracker-api-core/LICENSE b/packages/rest-tracker-api-core/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/rest-tracker-api-core/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/rest-tracker-api-core/README.md b/packages/rest-tracker-api-core/README.md new file mode 100644 index 000000000..96bf17bf7 --- /dev/null +++ b/packages/rest-tracker-api-core/README.md @@ -0,0 +1,11 @@ +# BitTorrent UDP Tracker Core library + +A library with the core functionality needed to implement the Torrust Tracker API + +## Documentation + +[Crate documentation](https://docs.rs/torrust-tracker-api-core). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/rest-tracker-api-core/src/container.rs b/packages/rest-tracker-api-core/src/container.rs new file mode 100644 index 000000000..bcc5a0186 --- /dev/null +++ b/packages/rest-tracker-api-core/src/container.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; +use bittorrent_tracker_core::container::TrackerCoreContainer; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use bittorrent_udp_tracker_core::services::banning::BanService; +use bittorrent_udp_tracker_core::{self}; +use tokio::sync::RwLock; +use torrust_tracker_configuration::{Core, HttpApi, HttpTracker, UdpTracker}; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; +use torrust_udp_tracker_server::container::UdpTrackerServerContainer; + +pub struct TrackerHttpApiCoreContainer { + pub http_api_config: Arc, + + // Swarm Coordination Registry Container + pub swarm_coordination_registry_container: Arc, + + // Tracker core + pub tracker_core_container: Arc, + + // HTTP tracker core + pub http_stats_repository: Arc, + + // UDP tracker core + pub ban_service: Arc>, + pub udp_core_stats_repository: Arc, + pub udp_server_stats_repository: Arc, +} + +impl TrackerHttpApiCoreContainer { + #[must_use] + pub fn initialize( + core_config: &Arc, + http_tracker_config: &Arc, + udp_tracker_config: &Arc, + http_api_config: &Arc, + ) -> Arc { + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + core_config, + &swarm_coordination_registry_container, + )); + + let http_tracker_core_container = + HttpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, http_tracker_config); + + let udp_tracker_core_container = + UdpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, udp_tracker_config); + + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(core_config); + + Self::initialize_from( + &swarm_coordination_registry_container, + &tracker_core_container, + &http_tracker_core_container, + &udp_tracker_core_container, + &udp_tracker_server_container, + http_api_config, + ) + } + + #[must_use] + pub fn initialize_from( + swarm_coordination_registry_container: &Arc, + tracker_core_container: &Arc, + http_tracker_core_container: &Arc, + udp_tracker_core_container: &Arc, + udp_tracker_server_container: &Arc, + http_api_config: &Arc, + ) -> Arc { + Arc::new(TrackerHttpApiCoreContainer { + http_api_config: http_api_config.clone(), + + // Swarm Coordination Registry Container + swarm_coordination_registry_container: swarm_coordination_registry_container.clone(), + + // Tracker core + tracker_core_container: tracker_core_container.clone(), + + // HTTP tracker core + http_stats_repository: http_tracker_core_container.stats_repository.clone(), + + // UDP tracker core + ban_service: udp_tracker_core_container.ban_service.clone(), + udp_core_stats_repository: udp_tracker_core_container.stats_repository.clone(), + udp_server_stats_repository: udp_tracker_server_container.stats_repository.clone(), + }) + } +} diff --git a/packages/rest-tracker-api-core/src/lib.rs b/packages/rest-tracker-api-core/src/lib.rs new file mode 100644 index 000000000..ddf1d9afd --- /dev/null +++ b/packages/rest-tracker-api-core/src/lib.rs @@ -0,0 +1,2 @@ +pub mod container; +pub mod statistics; diff --git a/packages/rest-tracker-api-core/src/statistics/metrics.rs b/packages/rest-tracker-api-core/src/statistics/metrics.rs new file mode 100644 index 000000000..ecdecd130 --- /dev/null +++ b/packages/rest-tracker-api-core/src/statistics/metrics.rs @@ -0,0 +1,118 @@ +use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + +/// Metrics collected by the tracker at the swarm layer. +#[derive(Copy, Clone, Debug, PartialEq, Default)] +pub struct TorrentsMetrics { + /// Total number of peers that have ever completed downloading. + pub total_downloaded: u64, + + /// Total number of seeders. + pub total_complete: u64, + + /// Total number of leechers. + pub total_incomplete: u64, + + /// Total number of torrents. + pub total_torrents: u64, +} + +impl From for TorrentsMetrics { + fn from(value: AggregateActiveSwarmMetadata) -> Self { + Self { + total_downloaded: value.total_downloaded, + total_complete: value.total_complete, + total_incomplete: value.total_incomplete, + total_torrents: value.total_torrents, + } + } +} + +/// Metrics collected by the tracker at the delivery layer. +/// +/// - Number of connections handled +/// - Number of `announce` requests handled +/// - Number of `scrape` request handled +/// +/// These metrics are collected for each connection type: UDP and HTTP +/// and also for each IP version used by the peers: IPv4 and IPv6. +#[derive(Debug, PartialEq, Default)] +pub struct ProtocolMetrics { + /// Total number of TCP (HTTP tracker) connections from IPv4 peers. + /// Since the HTTP tracker spec does not require a handshake, this metric + /// increases for every HTTP request. + #[deprecated(since = "3.1.0")] + pub tcp4_connections_handled: u64, + + /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. + pub tcp4_announces_handled: u64, + + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv4 peers. + pub tcp4_scrapes_handled: u64, + + /// Total number of TCP (HTTP tracker) connections from IPv6 peers. + #[deprecated(since = "3.1.0")] + pub tcp6_connections_handled: u64, + + /// Total number of TCP (HTTP tracker) `announce` requests from IPv6 peers. + pub tcp6_announces_handled: u64, + + /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. + pub tcp6_scrapes_handled: u64, + + // UDP + /// Total number of UDP (UDP tracker) requests aborted. + pub udp_requests_aborted: u64, + + /// Total number of UDP (UDP tracker) requests banned. + pub udp_requests_banned: u64, + + /// Total number of banned IPs. + pub udp_banned_ips_total: u64, + + /// Average rounded time spent processing UDP connect requests. + pub udp_avg_connect_processing_time_ns: u64, + + /// Average rounded time spent processing UDP announce requests. + pub udp_avg_announce_processing_time_ns: u64, + + /// Average rounded time spent processing UDP scrape requests. + pub udp_avg_scrape_processing_time_ns: u64, + + // UDPv4 + /// Total number of UDP (UDP tracker) requests from IPv4 peers. + pub udp4_requests: u64, + + /// Total number of UDP (UDP tracker) connections from IPv4 peers. + pub udp4_connections_handled: u64, + + /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. + pub udp4_announces_handled: u64, + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. + pub udp4_scrapes_handled: u64, + + /// Total number of UDP (UDP tracker) responses from IPv4 peers. + pub udp4_responses: u64, + + /// Total number of UDP (UDP tracker) `error` requests from IPv4 peers. + pub udp4_errors_handled: u64, + + // UDPv6 + /// Total number of UDP (UDP tracker) requests from IPv6 peers. + pub udp6_requests: u64, + + /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. + pub udp6_connections_handled: u64, + + /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. + pub udp6_announces_handled: u64, + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. + pub udp6_scrapes_handled: u64, + + /// Total number of UDP (UDP tracker) responses from IPv6 peers. + pub udp6_responses: u64, + + /// Total number of UDP (UDP tracker) `error` requests from IPv6 peers. + pub udp6_errors_handled: u64, +} diff --git a/packages/rest-tracker-api-core/src/statistics/mod.rs b/packages/rest-tracker-api-core/src/statistics/mod.rs new file mode 100644 index 000000000..a3c8a4b0e --- /dev/null +++ b/packages/rest-tracker-api-core/src/statistics/mod.rs @@ -0,0 +1,2 @@ +pub mod metrics; +pub mod services; diff --git a/packages/rest-tracker-api-core/src/statistics/services.rs b/packages/rest-tracker-api-core/src/statistics/services.rs new file mode 100644 index 000000000..f87cb8c76 --- /dev/null +++ b/packages/rest-tracker-api-core/src/statistics/services.rs @@ -0,0 +1,260 @@ +use std::sync::Arc; + +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use bittorrent_udp_tracker_core::services::banning::BanService; +use bittorrent_udp_tracker_core::{self}; +use tokio::sync::RwLock; +use torrust_tracker_metrics::metric_collection::MetricCollection; +use torrust_udp_tracker_server::statistics::{self as udp_server_statistics}; + +use super::metrics::TorrentsMetrics; +use crate::statistics::metrics::ProtocolMetrics; + +/// All the metrics collected by the tracker. +#[derive(Debug, PartialEq)] +pub struct TrackerMetrics { + /// Domain level metrics. + /// + /// General metrics for all torrents (number of seeders, leechers, etcetera) + pub torrents_metrics: TorrentsMetrics, + + /// Application level metrics. Usage statistics/metrics. + /// + /// Metrics about how the tracker is been used (number of udp announce requests, number of http scrape requests, etcetera) + pub protocol_metrics: ProtocolMetrics, +} + +/// It returns all the [`TrackerMetrics`] +pub async fn get_metrics( + in_memory_torrent_repository: Arc, + tracker_core_stats_repository: Arc, + http_stats_repository: Arc, + udp_server_stats_repository: Arc, +) -> TrackerMetrics { + TrackerMetrics { + torrents_metrics: get_torrents_metrics(in_memory_torrent_repository, tracker_core_stats_repository).await, + protocol_metrics: get_protocol_metrics(http_stats_repository.clone(), udp_server_stats_repository.clone()).await, + } +} + +async fn get_torrents_metrics( + in_memory_torrent_repository: Arc, + + tracker_core_stats_repository: Arc, +) -> TorrentsMetrics { + let aggregate_active_swarm_metadata = in_memory_torrent_repository.get_aggregate_swarm_metadata().await; + + let mut torrents_metrics: TorrentsMetrics = aggregate_active_swarm_metadata.into(); + torrents_metrics.total_downloaded = tracker_core_stats_repository.get_torrents_downloads_total().await; + + torrents_metrics +} + +#[allow(deprecated)] +#[allow(clippy::too_many_lines)] +async fn get_protocol_metrics( + http_stats_repository: Arc, + udp_server_stats_repository: Arc, +) -> ProtocolMetrics { + let http_stats = http_stats_repository.get_stats().await; + let udp_server_stats = udp_server_stats_repository.get_stats().await; + + // TCPv4 + + let tcp4_announces_handled = http_stats.tcp4_announces_handled(); + let tcp4_scrapes_handled = http_stats.tcp4_scrapes_handled(); + + // TCPv6 + + let tcp6_announces_handled = http_stats.tcp6_announces_handled(); + let tcp6_scrapes_handled = http_stats.tcp6_scrapes_handled(); + + // UDP + + let udp_requests_aborted = udp_server_stats.udp_requests_aborted_total(); + let udp_requests_banned = udp_server_stats.udp_requests_banned_total(); + let udp_banned_ips_total = udp_server_stats.udp_banned_ips_total(); + let udp_avg_connect_processing_time_ns = udp_server_stats.udp_avg_connect_processing_time_ns_averaged(); + let udp_avg_announce_processing_time_ns = udp_server_stats.udp_avg_announce_processing_time_ns_averaged(); + let udp_avg_scrape_processing_time_ns = udp_server_stats.udp_avg_scrape_processing_time_ns_averaged(); + + // UDPv4 + + let udp4_requests = udp_server_stats.udp4_requests_received_total(); + let udp4_connections_handled = udp_server_stats.udp4_connect_requests_accepted_total(); + let udp4_announces_handled = udp_server_stats.udp4_announce_requests_accepted_total(); + let udp4_scrapes_handled = udp_server_stats.udp4_scrape_requests_accepted_total(); + let udp4_responses = udp_server_stats.udp4_responses_sent_total(); + let udp4_errors_handled = udp_server_stats.udp4_errors_total(); + + // UDPv6 + + let udp6_requests = udp_server_stats.udp6_requests_received_total(); + let udp6_connections_handled = udp_server_stats.udp6_connect_requests_accepted_total(); + let udp6_announces_handled = udp_server_stats.udp6_announce_requests_accepted_total(); + let udp6_scrapes_handled = udp_server_stats.udp6_scrape_requests_accepted_total(); + let udp6_responses = udp_server_stats.udp6_responses_sent_total(); + let udp6_errors_handled = udp_server_stats.udp6_errors_total(); + + // For backward compatibility we keep the `tcp4_connections_handled` and + // `tcp6_connections_handled` metrics. They don't make sense for the HTTP + // tracker, but we keep them for now. In new major versions we should remove + // them. + + ProtocolMetrics { + // TCPv4 + tcp4_connections_handled: tcp4_announces_handled + tcp4_scrapes_handled, + tcp4_announces_handled, + tcp4_scrapes_handled, + // TCPv6 + tcp6_connections_handled: tcp6_announces_handled + tcp6_scrapes_handled, + tcp6_announces_handled, + tcp6_scrapes_handled, + // UDP + udp_requests_aborted, + udp_requests_banned, + udp_banned_ips_total, + udp_avg_connect_processing_time_ns, + udp_avg_announce_processing_time_ns, + udp_avg_scrape_processing_time_ns, + // UDPv4 + udp4_requests, + udp4_connections_handled, + udp4_announces_handled, + udp4_scrapes_handled, + udp4_responses, + udp4_errors_handled, + // UDPv6 + udp6_requests, + udp6_connections_handled, + udp6_announces_handled, + udp6_scrapes_handled, + udp6_responses, + udp6_errors_handled, + } +} + +#[derive(Debug, PartialEq)] +pub struct TrackerLabeledMetrics { + pub metrics: MetricCollection, +} + +/// It returns all the [`TrackerLabeledMetrics`] +/// +/// # Panics +/// +/// Will panic if the metrics cannot be merged. This could happen if the +/// packages are producing duplicate metric names, for example. +pub async fn get_labeled_metrics( + in_memory_torrent_repository: Arc, + ban_service: Arc>, + swarms_stats_repository: Arc, + tracker_core_stats_repository: Arc, + http_stats_repository: Arc, + udp_stats_repository: Arc, + udp_server_stats_repository: Arc, +) -> TrackerLabeledMetrics { + let _torrents_metrics = in_memory_torrent_repository.get_aggregate_swarm_metadata(); + let _udp_banned_ips_total = ban_service.read().await.get_banned_ips_total(); + + let swarms_stats = swarms_stats_repository.get_metrics().await; + let tracker_core_stats = tracker_core_stats_repository.get_metrics().await; + let http_stats = http_stats_repository.get_stats().await; + let udp_stats_repository = udp_stats_repository.get_stats().await; + let udp_server_stats = udp_server_stats_repository.get_stats().await; + + // Merge all the metrics into a single collection + let mut metrics = MetricCollection::default(); + + metrics + .merge(&swarms_stats.metric_collection) + .expect("msg: failed to merge torrent repository metrics"); + metrics + .merge(&tracker_core_stats.metric_collection) + .expect("msg: failed to merge tracker core metrics"); + metrics + .merge(&http_stats.metric_collection) + .expect("msg: failed to merge HTTP core metrics"); + metrics + .merge(&udp_stats_repository.metric_collection) + .expect("failed to merge UDP core metrics"); + metrics + .merge(&udp_server_stats.metric_collection) + .expect("failed to merge UDP server metrics"); + + TrackerLabeledMetrics { metrics } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bittorrent_http_tracker_core::event::bus::EventBus; + use bittorrent_http_tracker_core::event::sender::Broadcaster; + use bittorrent_http_tracker_core::statistics::event::listener::run_event_listener; + use bittorrent_http_tracker_core::statistics::repository::Repository; + use bittorrent_tracker_core::container::TrackerCoreContainer; + use bittorrent_tracker_core::{self}; + use bittorrent_udp_tracker_core::services::banning::BanService; + use bittorrent_udp_tracker_core::MAX_CONNECTION_ID_ERRORS_PER_IP; + use tokio::sync::RwLock; + use tokio_util::sync::CancellationToken; + use torrust_tracker_configuration::Configuration; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + use torrust_tracker_test_helpers::configuration; + + use crate::statistics::metrics::{ProtocolMetrics, TorrentsMetrics}; + use crate::statistics::services::{get_metrics, TrackerMetrics}; + + pub fn tracker_configuration() -> Configuration { + configuration::ephemeral() + } + + #[tokio::test] + async fn the_statistics_service_should_return_the_tracker_metrics() { + let cancellation_token = CancellationToken::new(); + + let config = tracker_configuration(); + let core_config = Arc::new(config.core.clone()); + + let swarm_coordination_registry_container = + Arc::new(SwarmCoordinationRegistryContainer::initialize(SenderStatus::Enabled)); + + let tracker_core_container = + TrackerCoreContainer::initialize_from(&core_config, &swarm_coordination_registry_container.clone()); + + let _ban_service = Arc::new(RwLock::new(BanService::new(MAX_CONNECTION_ID_ERRORS_PER_IP))); + + // HTTP core stats + let http_core_broadcaster = Broadcaster::default(); + let http_stats_repository = Arc::new(Repository::new()); + let http_stats_event_bus = Arc::new(EventBus::new( + config.core.tracker_usage_statistics.into(), + http_core_broadcaster.clone(), + )); + + if config.core.tracker_usage_statistics { + let _unused = run_event_listener(http_stats_event_bus.receiver(), cancellation_token, &http_stats_repository); + } + + // UDP server stats + let udp_server_stats_repository = Arc::new(torrust_udp_tracker_server::statistics::repository::Repository::new()); + + let tracker_metrics = get_metrics( + tracker_core_container.in_memory_torrent_repository.clone(), + tracker_core_container.stats_repository.clone(), + http_stats_repository.clone(), + udp_server_stats_repository.clone(), + ) + .await; + + assert_eq!( + tracker_metrics, + TrackerMetrics { + torrents_metrics: TorrentsMetrics::default(), + protocol_metrics: ProtocolMetrics::default(), + } + ); + } +} diff --git a/packages/server-lib/Cargo.toml b/packages/server-lib/Cargo.toml new file mode 100644 index 000000000..1d30e7fb5 --- /dev/null +++ b/packages/server-lib/Cargo.toml @@ -0,0 +1,24 @@ +[package] +authors.workspace = true +description = "Common functionality used in all Torrust HTTP servers." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["lib", "server", "torrust"] +license.workspace = true +name = "torrust-server-lib" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +derive_more = { version = "2", features = ["as_ref", "constructor", "display", "from"] } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +tower-http = { version = "0", features = ["compression-full", "cors", "propagate-header", "request-id", "trace"] } +tracing = "0" + +[dev-dependencies] +rstest = "0.25.0" diff --git a/packages/server-lib/LICENSE b/packages/server-lib/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/server-lib/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/server-lib/README.md b/packages/server-lib/README.md new file mode 100644 index 000000000..820225a00 --- /dev/null +++ b/packages/server-lib/README.md @@ -0,0 +1,11 @@ +# Torrust Server Lib + +Common functionality used in all Torrust HTTP servers. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-axum-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/server-lib/src/lib.rs b/packages/server-lib/src/lib.rs new file mode 100644 index 000000000..324041822 --- /dev/null +++ b/packages/server-lib/src/lib.rs @@ -0,0 +1,3 @@ +pub mod logging; +pub mod registar; +pub mod signals; diff --git a/packages/server-lib/src/logging.rs b/packages/server-lib/src/logging.rs new file mode 100644 index 000000000..c63ba3caf --- /dev/null +++ b/packages/server-lib/src/logging.rs @@ -0,0 +1,58 @@ +use std::fmt; +use std::time::Duration; + +use tower_http::LatencyUnit; + +/// This is the prefix used in logs to identify a started service. +/// +/// For example: +/// +/// ```text +/// 2024-06-25T12:36:25.025312Z INFO UDP TRACKER: Started on: udp://0.0.0.0:6969 +/// 2024-06-25T12:36:25.025445Z INFO HTTP TRACKER: Started on: http://0.0.0.0:7070 +/// 2024-06-25T12:36:25.025527Z INFO API: Started on: http://0.0.0.0:1212 +/// 2024-06-25T12:36:25.025580Z INFO HEALTH CHECK API: Started on: http://127.0.0.1:1313 +/// ``` +pub const STARTED_ON: &str = "Started on"; + +/* + +todo: we should use a field fot the URL. + +For example, instead of: + +``` +2024-06-25T12:36:25.025312Z INFO UDP TRACKER: Started on: udp://0.0.0.0:6969 +``` + +We should use something like: + +``` +2024-06-25T12:36:25.025312Z INFO UDP TRACKER started_at_url=udp://0.0.0.0:6969 +``` + +*/ + +pub struct Latency { + unit: LatencyUnit, + duration: Duration, +} + +impl Latency { + #[must_use] + pub fn new(unit: LatencyUnit, duration: Duration) -> Self { + Self { unit, duration } + } +} + +impl fmt::Display for Latency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.unit { + LatencyUnit::Seconds => write!(f, "{} s", self.duration.as_secs_f64()), + LatencyUnit::Millis => write!(f, "{} ms", self.duration.as_millis()), + LatencyUnit::Micros => write!(f, "{} μs", self.duration.as_micros()), + LatencyUnit::Nanos => write!(f, "{} ns", self.duration.as_nanos()), + _ => panic!("Invalid latency unit"), + } + } +} diff --git a/src/servers/registar.rs b/packages/server-lib/src/registar.rs similarity index 85% rename from src/servers/registar.rs rename to packages/server-lib/src/registar.rs index 6b67188dc..efa94034b 100644 --- a/src/servers/registar.rs +++ b/packages/server-lib/src/registar.rs @@ -1,12 +1,12 @@ //! Registar. Registers Services for Health Check. use std::collections::HashMap; -use std::net::SocketAddr; use std::sync::Arc; use derive_more::Constructor; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use torrust_tracker_primitives::service_binding::ServiceBinding; /// A [`ServiceHeathCheckResult`] is returned by a completed health check. pub type ServiceHeathCheckResult = Result; @@ -16,29 +16,30 @@ pub type ServiceHeathCheckResult = Result; /// The `job` awaits a [`ServiceHeathCheckResult`]. #[derive(Debug, Constructor)] pub struct ServiceHealthCheckJob { - pub binding: SocketAddr, + pub service_binding: ServiceBinding, pub info: String, + pub service_type: String, pub job: JoinHandle, } /// The function specification [`FnSpawnServiceHeathCheck`]. /// /// A function fulfilling this specification will spawn a new [`ServiceHealthCheckJob`]. -pub type FnSpawnServiceHeathCheck = fn(&SocketAddr) -> ServiceHealthCheckJob; +pub type FnSpawnServiceHeathCheck = fn(&ServiceBinding) -> ServiceHealthCheckJob; /// A [`ServiceRegistration`] is provided to the [`Registar`] for registration. /// /// Each registration includes a function that fulfils the [`FnSpawnServiceHeathCheck`] specification. #[derive(Clone, Debug, Constructor)] pub struct ServiceRegistration { - binding: SocketAddr, + service_binding: ServiceBinding, check_fn: FnSpawnServiceHeathCheck, } impl ServiceRegistration { #[must_use] pub fn spawn_check(&self) -> ServiceHealthCheckJob { - (self.check_fn)(&self.binding) + (self.check_fn)(&self.service_binding) } } @@ -46,7 +47,7 @@ impl ServiceRegistration { pub type ServiceRegistrationForm = tokio::sync::oneshot::Sender; /// The [`ServiceRegistry`] contains each unique [`ServiceRegistration`] by it's [`SocketAddr`]. -pub type ServiceRegistry = Arc>>; +pub type ServiceRegistry = Arc>>; /// The [`Registar`] manages the [`ServiceRegistry`]. #[derive(Clone, Debug)] @@ -89,7 +90,7 @@ impl Registar { let mut mutex = self.registry.lock().await; - mutex.insert(service_registration.binding, service_registration); + mutex.insert(service_registration.service_binding.clone(), service_registration); } /// Returns the [`ServiceRegistry`] of services diff --git a/src/servers/signals.rs b/packages/server-lib/src/signals.rs similarity index 77% rename from src/servers/signals.rs rename to packages/server-lib/src/signals.rs index b83dd5213..581729e57 100644 --- a/src/servers/signals.rs +++ b/packages/server-lib/src/signals.rs @@ -1,10 +1,17 @@ //! This module contains functions to handle signals. -use std::time::Duration; - use derive_more::Display; -use tokio::time::sleep; +use torrust_tracker_primitives::service_binding::ServiceBinding; use tracing::instrument; +/// This is the message that the "launcher" spawned task sends to the main +/// application process to notify the service was successfully started. +/// +#[derive(Debug)] +pub struct Started { + pub service_binding: ServiceBinding, + pub address: std::net::SocketAddr, +} + /// This is the message that the "launcher" spawned task receives from the main /// application process to notify the service to shutdown. /// @@ -68,19 +75,3 @@ pub async fn shutdown_signal_with_message(rx_halt: tokio::sync::oneshot::Receive tracing::info!("{message}"); } - -#[instrument(skip(handle, rx_halt, message))] -pub async fn graceful_shutdown(handle: axum_server::Handle, rx_halt: tokio::sync::oneshot::Receiver, message: String) { - shutdown_signal_with_message(rx_halt, message).await; - - tracing::debug!("Sending graceful shutdown signal"); - handle.graceful_shutdown(Some(Duration::from_secs(90))); - - println!("!! shuting down in 90 seconds !!"); - - loop { - sleep(Duration::from_secs(1)).await; - - tracing::info!("remaining alive connections: {}", handle.connection_count()); - } -} diff --git a/packages/swarm-coordination-registry/.gitignore b/packages/swarm-coordination-registry/.gitignore new file mode 100644 index 000000000..c9907ae11 --- /dev/null +++ b/packages/swarm-coordination-registry/.gitignore @@ -0,0 +1 @@ +/.coverage/ diff --git a/packages/swarm-coordination-registry/Cargo.toml b/packages/swarm-coordination-registry/Cargo.toml new file mode 100644 index 000000000..45359ad81 --- /dev/null +++ b/packages/swarm-coordination-registry/Cargo.toml @@ -0,0 +1,41 @@ +[package] +description = "A library that provides a repository of torrents files and their peers." +keywords = ["library", "repository", "torrents"] +name = "torrust-tracker-swarm-coordination-registry" +readme = "README.md" + +authors.workspace = true +categories.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +chrono = { version = "0", default-features = false, features = ["clock"] } +crossbeam-skiplist = "0" +futures = "0" +serde = { version = "1.0.219", features = ["derive"] } +thiserror = "2.0.12" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +tracing = "0" + +[dev-dependencies] +async-std = { version = "1", features = ["attributes", "tokio1"] } +criterion = { version = "0", features = ["async_tokio"] } +mockall = "0" +rand = "0" +rstest = "0" +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } diff --git a/packages/swarm-coordination-registry/README.md b/packages/swarm-coordination-registry/README.md new file mode 100644 index 000000000..a8c55746b --- /dev/null +++ b/packages/swarm-coordination-registry/README.md @@ -0,0 +1,22 @@ +# Torrust Tracker Torrent Repository + +A library to provide a torrent repository to the [Torrust Tracker](https://github.com/torrust/torrust-tracker). + +Its main responsibilities include: + +- Managing Torrent Entries: It stores, retrieves, and manages torrent entries, which are torrents being tracked. +- Persistence: It supports lading tracked torrents from a persistent storage, ensuring that torrent data can be restored across restarts. +- Pagination and sorting: It provides paginated and stable/sorted access to torrent entries. +- Peer management: It manages peers associated with torrents, including removing inactive peers and handling torrents with no peers (peerless torrents). +- Policy handling: It supports different policies for handling torrents, such as persisting, removing, or custom policies for torrents with no peers. +- Metrics: It can provide metrics about the torrents, such as counts or statuses, likely for monitoring or statistics. + +This repo is a core component for managing the state and lifecycle of torrents and their peers in a BitTorrent tracker, with peer management, and flexible policies. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-tracker-torrent-repository). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/swarm-coordination-registry/src/container.rs b/packages/swarm-coordination-registry/src/container.rs new file mode 100644 index 000000000..718e3ee52 --- /dev/null +++ b/packages/swarm-coordination-registry/src/container.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use torrust_tracker_events::bus::SenderStatus; + +use crate::event::bus::EventBus; +use crate::event::sender::Broadcaster; +use crate::event::{self}; +use crate::statistics::repository::Repository; +use crate::{statistics, Registry}; + +pub struct SwarmCoordinationRegistryContainer { + pub swarms: Arc, + pub event_bus: Arc, + pub stats_event_sender: event::sender::Sender, + pub stats_repository: Arc, +} + +impl SwarmCoordinationRegistryContainer { + #[must_use] + pub fn initialize(sender_status: SenderStatus) -> Self { + // // Swarm Coordination Registry Container stats + let broadcaster = Broadcaster::default(); + let stats_repository = Arc::new(Repository::new()); + + let event_bus = Arc::new(EventBus::new(sender_status, broadcaster.clone())); + + let stats_event_sender = event_bus.sender(); + + let swarms = Arc::new(Registry::new(stats_event_sender.clone())); + + Self { + swarms, + event_bus, + stats_event_sender, + stats_repository, + } + } +} diff --git a/packages/swarm-coordination-registry/src/event.rs b/packages/swarm-coordination-registry/src/event.rs new file mode 100644 index 000000000..65a65ce8c --- /dev/null +++ b/packages/swarm-coordination-registry/src/event.rs @@ -0,0 +1,111 @@ +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::peer::{Peer, PeerAnnouncement}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Event { + TorrentAdded { + info_hash: InfoHash, + announcement: PeerAnnouncement, + }, + TorrentRemoved { + info_hash: InfoHash, + }, + PeerAdded { + info_hash: InfoHash, + peer: Peer, + }, + PeerRemoved { + info_hash: InfoHash, + peer: Peer, + }, + PeerUpdated { + info_hash: InfoHash, + old_peer: Peer, + new_peer: Peer, + }, + PeerDownloadCompleted { + info_hash: InfoHash, + peer: Peer, + }, +} + +pub mod sender { + use std::sync::Arc; + + use super::Event; + + pub type Sender = Option>>; + pub type Broadcaster = torrust_tracker_events::broadcaster::Broadcaster; + + #[cfg(test)] + pub mod tests { + + use futures::future::{self, BoxFuture}; + use mockall::mock; + use mockall::predicate::eq; + use torrust_tracker_events::sender::{SendError, Sender}; + + use crate::event::Event; + + mock! { + pub EventSender {} + + impl Sender for EventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } + } + + pub fn expect_event(mock: &mut MockEventSender, event: Event) { + mock.expect_send() + .with(eq(event)) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + } + + pub fn expect_event_sequence(mock: &mut MockEventSender, event: Vec) { + for e in event { + expect_event(mock, e); + } + } + } +} + +pub mod receiver { + use super::Event; + + pub type Receiver = Box>; +} + +pub mod bus { + use crate::event::Event; + + pub type EventBus = torrust_tracker_events::bus::EventBus; +} + +#[cfg(test)] +pub mod test { + + use torrust_tracker_primitives::peer::Peer; + + use super::Event; + use crate::tests::sample_info_hash; + + #[test] + fn events_should_be_comparable() { + let info_hash = sample_info_hash(); + + let event1 = Event::TorrentAdded { + info_hash, + announcement: Peer::default(), + }; + + let event2 = Event::TorrentRemoved { info_hash }; + + let event1_clone = event1.clone(); + + assert!(event1 == event1_clone); + assert!(event1 != event2); + } +} diff --git a/packages/swarm-coordination-registry/src/lib.rs b/packages/swarm-coordination-registry/src/lib.rs new file mode 100644 index 000000000..eb2721a0c --- /dev/null +++ b/packages/swarm-coordination-registry/src/lib.rs @@ -0,0 +1,145 @@ +pub mod container; +pub mod event; +pub mod statistics; +pub mod swarm; + +use std::sync::Arc; + +use tokio::sync::Mutex; +use torrust_tracker_clock::clock; + +pub type Registry = swarm::registry::Registry; +pub type CoordinatorHandle = Arc>; +pub type Coordinator = swarm::coordinator::Coordinator; + +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + +pub const SWARM_COORDINATION_REGISTRY_LOG_TARGET: &str = "SWARM_COORDINATION_REGISTRY"; + +#[cfg(test)] +pub(crate) mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash_one() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash_alphabetically_ordered_after_sample_info_hash_one() -> InfoHash { + "99c82bb73505a3c0b453f9fa0e881d6e5a32a0c1" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// Sample peer whose state is not relevant for the tests. + #[must_use] + pub fn sample_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + #[must_use] + pub fn sample_peer_one() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000001"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + #[must_use] + pub fn sample_peer_two() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000002"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + #[must_use] + pub fn seeder() -> Peer { + complete_peer() + } + + #[must_use] + pub fn leecher() -> Peer { + incomplete_peer() + } + + /// A peer that counts as `complete` is swarm metadata + /// IMPORTANT!: it only counts if the it has been announce at least once before + /// announcing the `AnnounceEvent::Completed` event. + #[must_use] + pub fn complete_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + /// A peer that counts as `incomplete` is swarm metadata + #[must_use] + pub fn incomplete_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(1000), // Still bytes to download + event: AnnounceEvent::Started, + } + } +} diff --git a/packages/swarm-coordination-registry/src/statistics/activity_metrics_updater.rs b/packages/swarm-coordination-registry/src/statistics/activity_metrics_updater.rs new file mode 100644 index 000000000..cf814e810 --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/activity_metrics_updater.rs @@ -0,0 +1,104 @@ +//! Job that runs a task on intervals to update peers' activity metrics. +use std::sync::Arc; + +use chrono::Utc; +use tokio::task::JoinHandle; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use tracing::instrument; + +use super::repository::Repository; +use crate::statistics::{SWARM_COORDINATION_REGISTRY_PEERS_INACTIVE_TOTAL, SWARM_COORDINATION_REGISTRY_TORRENTS_INACTIVE_TOTAL}; +use crate::{CurrentClock, Registry}; + +#[must_use] +#[instrument(skip(swarms, stats_repository))] +pub fn start_job( + swarms: &Arc, + stats_repository: &Arc, + inactivity_cutoff: DurationSinceUnixEpoch, +) -> JoinHandle<()> { + let weak_swarms = std::sync::Arc::downgrade(swarms); + let weak_stats_repository = std::sync::Arc::downgrade(stats_repository); + + let interval_in_secs = 15; // todo: make this configurable + + tokio::spawn(async move { + let interval = std::time::Duration::from_secs(interval_in_secs); + let mut interval = tokio::time::interval(interval); + interval.tick().await; + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("Stopping peers activity metrics update job (ctrl-c signal received) ..."); + break; + } + _ = interval.tick() => { + if let (Some(swarms), Some(stats_repository)) = (weak_swarms.upgrade(), weak_stats_repository.upgrade()) { + update_activity_metrics(interval_in_secs, &swarms, &stats_repository, inactivity_cutoff).await; + } else { + tracing::info!("Stopping peers activity metrics update job (can't upgrade weak pointers) ..."); + break; + } + } + } + } + }) +} + +async fn update_activity_metrics( + interval_in_secs: u64, + swarms: &Arc, + stats_repository: &Arc, + inactivity_cutoff: DurationSinceUnixEpoch, +) { + let start_time = Utc::now().time(); + + tracing::debug!( + "Updating peers and torrents activity metrics (executed every {} secs) ...", + interval_in_secs + ); + + let activity_metadata = swarms.get_activity_metadata(inactivity_cutoff).await; + + activity_metadata.log(); + + update_inactive_peers_total(stats_repository, activity_metadata.inactive_peers_total).await; + update_inactive_torrents_total(stats_repository, activity_metadata.inactive_torrents_total).await; + + tracing::debug!( + "Peers and torrents activity metrics updated in {} ms", + (Utc::now().time() - start_time).num_milliseconds() + ); +} + +async fn update_inactive_peers_total(stats_repository: &Arc, inactive_peers_total: usize) { + #[allow(clippy::cast_precision_loss)] + let inactive_peers_total = inactive_peers_total as f64; + + let _unused = stats_repository + .set_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_INACTIVE_TOTAL), + &LabelSet::default(), + inactive_peers_total, + CurrentClock::now(), + ) + .await; +} + +async fn update_inactive_torrents_total(stats_repository: &Arc, inactive_torrents_total: usize) { + #[allow(clippy::cast_precision_loss)] + let inactive_torrents_total = inactive_torrents_total as f64; + + let _unused = stats_repository + .set_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_INACTIVE_TOTAL), + &LabelSet::default(), + inactive_torrents_total, + CurrentClock::now(), + ) + .await; +} diff --git a/packages/swarm-coordination-registry/src/statistics/event/handler.rs b/packages/swarm-coordination-registry/src/statistics/event/handler.rs new file mode 100644 index 000000000..1d3f8f32c --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/event/handler.rs @@ -0,0 +1,655 @@ +use std::sync::Arc; + +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::peer::Peer; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::Event; +use crate::statistics::repository::Repository; +use crate::statistics::{ + SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL, SWARM_COORDINATION_REGISTRY_PEERS_COMPLETED_STATE_REVERTED_TOTAL, + SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL, SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL, + SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL, SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL, + SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL, SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL, + SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL, +}; + +#[allow(clippy::too_many_lines)] +pub async fn handle_event(event: Event, stats_repository: &Arc, now: DurationSinceUnixEpoch) { + match event { + // Torrent events + Event::TorrentAdded { info_hash, .. } => { + tracing::debug!(info_hash = ?info_hash, "Torrent added",); + + let _unused = stats_repository + .increment_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL), + &LabelSet::default(), + now, + ) + .await; + + let _unused = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL), + &LabelSet::default(), + now, + ) + .await; + } + Event::TorrentRemoved { info_hash } => { + tracing::debug!(info_hash = ?info_hash, "Torrent removed",); + + let _unused = stats_repository + .decrement_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL), + &LabelSet::default(), + now, + ) + .await; + + let _unused = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL), + &LabelSet::default(), + now, + ) + .await; + } + + // Peer events + Event::PeerAdded { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer added", ); + + let label_set = label_set_for_peer(&peer); + + let _unused = stats_repository + .increment_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL), + &label_set, + now, + ) + .await; + + let _unused = stats_repository + .increment_counter(&metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL), &label_set, now) + .await; + } + Event::PeerRemoved { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer removed", ); + + let label_set = label_set_for_peer(&peer); + + let _unused = stats_repository + .decrement_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL), + &label_set, + now, + ) + .await; + + let _unused = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL), + &label_set, + now, + ) + .await; + } + Event::PeerUpdated { + info_hash, + old_peer, + new_peer, + } => { + tracing::debug!(info_hash = ?info_hash, old_peer = ?old_peer, new_peer = ?new_peer, "Peer updated", ); + + // If the peer's role has changed, we need to adjust the number of + // connections + if old_peer.role() != new_peer.role() { + let _unused = stats_repository + .increment_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL), + &label_set_for_peer(&new_peer), + now, + ) + .await; + + let _unused = stats_repository + .decrement_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL), + &label_set_for_peer(&old_peer), + now, + ) + .await; + } + + // If the peer reverted from a completed state to any other state, + // we need to increment the counter for reverted completed. + if old_peer.is_completed() && !new_peer.is_completed() { + let _unused = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_COMPLETED_STATE_REVERTED_TOTAL), + &LabelSet::default(), + now, + ) + .await; + } + + // Regardless of the role change, we still need to increment the + // counter for updated peers. + let label_set = label_set_for_peer(&new_peer); + + let _unused = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL), + &label_set, + now, + ) + .await; + } + Event::PeerDownloadCompleted { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer download completed", ); + + let _unused: Result<(), torrust_tracker_metrics::metric_collection::Error> = stats_repository + .increment_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL), + &label_set_for_peer(&peer), + now, + ) + .await; + } + } +} + +/// Returns the label set to be included in the metrics for the given peer. +pub(crate) fn label_set_for_peer(peer: &Peer) -> LabelSet { + if peer.is_seeder() { + (label_name!("peer_role"), LabelValue::new("seeder")).into() + } else { + (label_name!("peer_role"), LabelValue::new("leecher")).into() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use aquatic_udp_protocol::NumberOfBytes; + use torrust_tracker_metrics::label::LabelSet; + use torrust_tracker_metrics::metric::MetricName; + use torrust_tracker_primitives::peer::{Peer, PeerRole}; + + use crate::statistics::repository::Repository; + use crate::tests::{leecher, seeder}; + + fn make_peer(role: PeerRole) -> Peer { + match role { + PeerRole::Seeder => seeder(), + PeerRole::Leecher => leecher(), + } + } + + // It returns a peer with the opposite role of the given peer. + fn make_opposite_role_peer(peer: &Peer) -> Peer { + let mut opposite_role_peer = *peer; + + match peer.role() { + PeerRole::Seeder => { + opposite_role_peer.left = NumberOfBytes::new(1); + } + PeerRole::Leecher => { + opposite_role_peer.left = NumberOfBytes::new(0); + } + } + + opposite_role_peer + } + + pub async fn expect_counter_metric_to_be( + stats_repository: &Arc, + metric_name: &MetricName, + label_set: &LabelSet, + expected_value: u64, + ) { + let value = get_counter_metric(stats_repository, metric_name, label_set).await; + assert_eq!(value.to_string(), expected_value.to_string()); + } + + async fn get_counter_metric(stats_repository: &Arc, metric_name: &MetricName, label_set: &LabelSet) -> u64 { + stats_repository + .get_metrics() + .await + .metric_collection + .get_counter_value(metric_name, label_set) + .unwrap_or_else(|| panic!("Failed to get counter value for metric name '{metric_name}' and label set '{label_set}'")) + .value() + } + + async fn expect_gauge_metric_to_be( + stats_repository: &Arc, + metric_name: &MetricName, + label_set: &LabelSet, + expected_value: f64, + ) { + let value = get_gauge_metric(stats_repository, metric_name, label_set).await; + assert_eq!(value.to_string(), expected_value.to_string()); + } + + async fn get_gauge_metric(stats_repository: &Arc, metric_name: &MetricName, label_set: &LabelSet) -> f64 { + stats_repository + .get_metrics() + .await + .metric_collection + .get_gauge_value(metric_name, label_set) + .unwrap_or_else(|| panic!("Failed to get gauge value for metric name '{metric_name}' and label set '{label_set}'")) + .value() + } + + mod for_torrent_metrics { + + use std::sync::Arc; + + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + use torrust_tracker_metrics::label::LabelSet; + use torrust_tracker_metrics::metric_name; + + use crate::event::Event; + use crate::statistics::event::handler::handle_event; + use crate::statistics::event::handler::tests::{expect_counter_metric_to_be, expect_gauge_metric_to_be}; + use crate::statistics::repository::Repository; + use crate::statistics::{ + SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL, SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL, + SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL, + }; + use crate::tests::{sample_info_hash, sample_peer}; + use crate::CurrentClock; + + #[tokio::test] + async fn it_should_increment_the_number_of_torrents_when_a_torrent_added_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + handle_event( + Event::TorrentAdded { + info_hash: sample_info_hash(), + announcement: sample_peer(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_gauge_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL), + &LabelSet::default(), + 1.0, + ) + .await; + } + + #[tokio::test] + async fn it_should_decrement_the_number_of_torrents_when_a_torrent_removed_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + let metric_name = metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL); + let label_set = LabelSet::default(); + + // Increment the gauge first to simulate a torrent being added. + stats_repository + .increment_gauge(&metric_name, &label_set, CurrentClock::now()) + .await + .unwrap(); + + handle_event( + Event::TorrentRemoved { + info_hash: sample_info_hash(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_gauge_metric_to_be(&stats_repository, &metric_name, &label_set, 0.0).await; + } + + #[tokio::test] + async fn it_should_increment_the_number_of_torrents_added_when_a_torrent_added_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + handle_event( + Event::TorrentAdded { + info_hash: sample_info_hash(), + announcement: sample_peer(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL), + &LabelSet::default(), + 1, + ) + .await; + } + + #[tokio::test] + async fn it_should_increment_the_number_of_torrents_removed_when_a_torrent_removed_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + handle_event( + Event::TorrentRemoved { + info_hash: sample_info_hash(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL), + &LabelSet::default(), + 1, + ) + .await; + } + } + + mod for_peer_metrics { + use std::sync::Arc; + + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + use torrust_tracker_metrics::metric_name; + + use crate::event::Event; + use crate::statistics::event::handler::tests::expect_counter_metric_to_be; + use crate::statistics::event::handler::{handle_event, label_set_for_peer}; + use crate::statistics::repository::Repository; + use crate::statistics::{ + SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL, SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL, + SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL, + }; + use crate::tests::{sample_info_hash, sample_peer}; + use crate::CurrentClock; + + mod peer_connections_total { + + use std::sync::Arc; + + use rstest::rstest; + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + use torrust_tracker_metrics::label::LabelValue; + use torrust_tracker_metrics::{label_name, metric_name}; + use torrust_tracker_primitives::peer::PeerRole; + + use crate::event::Event; + use crate::statistics::event::handler::handle_event; + use crate::statistics::event::handler::tests::{ + expect_gauge_metric_to_be, get_gauge_metric, make_opposite_role_peer, make_peer, + }; + use crate::statistics::repository::Repository; + use crate::statistics::SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL; + use crate::tests::sample_info_hash; + use crate::CurrentClock; + + #[rstest] + #[case("seeder")] + #[case("leecher")] + #[tokio::test] + async fn it_should_increment_the_number_of_peer_connections_when_a_peer_added_event_is_received( + #[case] role: PeerRole, + ) { + clock::Stopped::local_set_to_unix_epoch(); + + let peer = make_peer(role); + + let stats_repository = Arc::new(Repository::new()); + let metric_name = metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL); + let label_set = (label_name!("peer_role"), LabelValue::new(&role.to_string())).into(); + + handle_event( + Event::PeerAdded { + info_hash: sample_info_hash(), + peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_gauge_metric_to_be(&stats_repository, &metric_name, &label_set, 1.0).await; + } + + #[rstest] + #[case("seeder")] + #[case("leecher")] + #[tokio::test] + async fn it_should_decrement_the_number_of_peer_connections_when_a_peer_removed_event_is_received( + #[case] role: PeerRole, + ) { + clock::Stopped::local_set_to_unix_epoch(); + + let peer = make_peer(role); + + let stats_repository = Arc::new(Repository::new()); + + let metric_name = metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL); + let label_set = (label_name!("peer_role"), LabelValue::new(&role.to_string())).into(); + + // Increment the gauge first to simulate a peer being added. + stats_repository + .increment_gauge(&metric_name, &label_set, CurrentClock::now()) + .await + .unwrap(); + + handle_event( + Event::PeerRemoved { + info_hash: sample_info_hash(), + peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_gauge_metric_to_be(&stats_repository, &metric_name, &label_set, 0.0).await; + } + + #[rstest] + #[case("seeder")] + #[case("leecher")] + #[tokio::test] + async fn it_should_adjust_the_number_of_seeders_and_leechers_when_a_peer_updated_event_is_received_and_the_peer_changed_its_role( + #[case] old_role: PeerRole, + ) { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + let old_peer = make_peer(old_role); + let new_peer = make_opposite_role_peer(&old_peer); + + let metric_name = metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL); + let old_role_label_set = (label_name!("peer_role"), LabelValue::new(&old_peer.role().to_string())).into(); + let new_role_label_set = (label_name!("peer_role"), LabelValue::new(&new_peer.role().to_string())).into(); + + // Increment the gauge first by simulating a peer was added. + handle_event( + Event::PeerAdded { + info_hash: sample_info_hash(), + peer: old_peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let old_role_total = get_gauge_metric(&stats_repository, &metric_name, &old_role_label_set).await; + let new_role_total = 0.0; + + // The peer's role has changed, so we need to increment the new + // role and decrement the old one. + handle_event( + Event::PeerUpdated { + info_hash: sample_info_hash(), + old_peer, + new_peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + // The peer's role has changed, so the new role has incremented. + expect_gauge_metric_to_be(&stats_repository, &metric_name, &new_role_label_set, new_role_total + 1.0).await; + + // And the old role has decremented. + expect_gauge_metric_to_be(&stats_repository, &metric_name, &old_role_label_set, old_role_total - 1.0).await; + } + } + + #[tokio::test] + async fn it_should_increment_the_number_of_peers_added_when_a_peer_added_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + let peer = sample_peer(); + + handle_event( + Event::PeerAdded { + info_hash: sample_info_hash(), + peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL), + &label_set_for_peer(&peer), + 1, + ) + .await; + } + + #[tokio::test] + async fn it_should_increment_the_number_of_peers_removed_when_a_peer_removed_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + let peer = sample_peer(); + + handle_event( + Event::PeerRemoved { + info_hash: sample_info_hash(), + peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL), + &label_set_for_peer(&peer), + 1, + ) + .await; + } + + #[tokio::test] + async fn it_should_increment_the_number_of_peers_updated_when_a_peer_updated_event_is_received() { + clock::Stopped::local_set_to_unix_epoch(); + + let stats_repository = Arc::new(Repository::new()); + + let new_peer = sample_peer(); + + handle_event( + Event::PeerUpdated { + info_hash: sample_info_hash(), + old_peer: sample_peer(), + new_peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be( + &stats_repository, + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL), + &label_set_for_peer(&new_peer), + 1, + ) + .await; + } + + mod torrent_downloads_total { + + use std::sync::Arc; + + use rstest::rstest; + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + use torrust_tracker_metrics::label::LabelValue; + use torrust_tracker_metrics::{label_name, metric_name}; + use torrust_tracker_primitives::peer::PeerRole; + + use crate::event::Event; + use crate::statistics::event::handler::handle_event; + use crate::statistics::event::handler::tests::{expect_counter_metric_to_be, make_peer}; + use crate::statistics::repository::Repository; + use crate::statistics::SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL; + use crate::tests::sample_info_hash; + use crate::CurrentClock; + + #[rstest] + #[case("seeder")] + #[case("leecher")] + #[tokio::test] + async fn it_should_increment_the_number_of_downloads_when_a_peer_downloaded_event_is_received( + #[case] role: PeerRole, + ) { + clock::Stopped::local_set_to_unix_epoch(); + + let peer = make_peer(role); + + let stats_repository = Arc::new(Repository::new()); + let metric_name = metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL); + let label_set = (label_name!("peer_role"), LabelValue::new(&role.to_string())).into(); + + handle_event( + Event::PeerDownloadCompleted { + info_hash: sample_info_hash(), + peer, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + expect_counter_metric_to_be(&stats_repository, &metric_name, &label_set, 1).await; + } + } + } +} diff --git a/packages/swarm-coordination-registry/src/statistics/event/listener.rs b/packages/swarm-coordination-registry/src/statistics/event/listener.rs new file mode 100644 index 000000000..b578d1284 --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/event/listener.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; + +use super::handler::handle_event; +use crate::event::receiver::Receiver; +use crate::statistics::repository::Repository; +use crate::{CurrentClock, SWARM_COORDINATION_REGISTRY_LOG_TARGET}; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + repository: &Arc, +) -> JoinHandle<()> { + let stats_repository = repository.clone(); + + tracing::info!(target: SWARM_COORDINATION_REGISTRY_LOG_TARGET, "Starting swarm coordination registry event listener"); + + tokio::spawn(async move { + dispatch_events(receiver, cancellation_token, stats_repository).await; + + tracing::info!(target: SWARM_COORDINATION_REGISTRY_LOG_TARGET, "Swarm coordination registry listener finished"); + }) +} + +async fn dispatch_events(mut receiver: Receiver, cancellation_token: CancellationToken, stats_repository: Arc) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: SWARM_COORDINATION_REGISTRY_LOG_TARGET, "Received cancellation request, shutting down swarm coordination registry event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: SWARM_COORDINATION_REGISTRY_LOG_TARGET, "Swarm coordination registry event receiver closed."); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: SWARM_COORDINATION_REGISTRY_LOG_TARGET, "Swarm coordination registry event receiver lagged by {} events.", n); + } + } + } + } + } + } + } +} diff --git a/packages/swarm-coordination-registry/src/statistics/event/mod.rs b/packages/swarm-coordination-registry/src/statistics/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/swarm-coordination-registry/src/statistics/metrics.rs b/packages/swarm-coordination-registry/src/statistics/metrics.rs new file mode 100644 index 000000000..d62a1ba6e --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/metrics.rs @@ -0,0 +1,63 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::{Error, MetricCollection}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +/// Metrics collected by the torrent repository. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct Metrics { + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increment_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_counter(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn set_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_gauge(metric_name, labels, value, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increment_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_gauge(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn decrement_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.decrement_gauge(metric_name, labels, now) + } +} diff --git a/packages/swarm-coordination-registry/src/statistics/mod.rs b/packages/swarm-coordination-registry/src/statistics/mod.rs new file mode 100644 index 000000000..a4bf4c018 --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/mod.rs @@ -0,0 +1,117 @@ +pub mod activity_metrics_updater; +pub mod event; +pub mod metrics; +pub mod repository; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_metrics::unit::Unit; + +// Torrent metrics + +const SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL: &str = "swarm_coordination_registry_torrents_added_total"; +const SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL: &str = "swarm_coordination_registry_torrents_removed_total"; + +const SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL: &str = "swarm_coordination_registry_torrents_total"; +const SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL: &str = "swarm_coordination_registry_torrents_downloads_total"; +const SWARM_COORDINATION_REGISTRY_TORRENTS_INACTIVE_TOTAL: &str = "swarm_coordination_registry_torrents_inactive_total"; + +// Peers metrics + +const SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL: &str = "swarm_coordination_registry_peers_added_total"; +const SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL: &str = "swarm_coordination_registry_peers_removed_total"; +const SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL: &str = "swarm_coordination_registry_peers_updated_total"; + +const SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL: &str = "swarm_coordination_registry_peer_connections_total"; +const SWARM_COORDINATION_REGISTRY_UNIQUE_PEERS_TOTAL: &str = "swarm_coordination_registry_unique_peers_total"; // todo: not implemented yet +const SWARM_COORDINATION_REGISTRY_PEERS_INACTIVE_TOTAL: &str = "swarm_coordination_registry_peers_inactive_total"; +const SWARM_COORDINATION_REGISTRY_PEERS_COMPLETED_STATE_REVERTED_TOTAL: &str = + "swarm_coordination_registry_peers_completed_state_reverted_total"; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + // Torrent metrics + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_ADDED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of torrents added.")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_REMOVED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of torrents removed.")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of torrents.")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_DOWNLOADS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of torrent downloads.")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_TORRENTS_INACTIVE_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of inactive torrents.")), + ); + + // Peers metrics + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_ADDED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of peers added.")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_REMOVED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of peers removed.")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_UPDATED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of peers updated.")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEER_CONNECTIONS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new( + "The total number of peer connections (one connection per torrent).", + )), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_UNIQUE_PEERS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of unique peers.")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_INACTIVE_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of inactive peers.")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(SWARM_COORDINATION_REGISTRY_PEERS_COMPLETED_STATE_REVERTED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new( + "The total number of peers whose completed state was reverted.", + )), + ); + + metrics +} diff --git a/packages/swarm-coordination-registry/src/statistics/repository.rs b/packages/swarm-coordination-registry/src/statistics/repository.rs new file mode 100644 index 000000000..fe1292d00 --- /dev/null +++ b/packages/swarm-coordination-registry/src/statistics/repository.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::Error; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::describe_metrics; +use super::metrics::Metrics; + +/// A repository for the torrent repository metrics. +#[derive(Clone)] +pub struct Repository { + pub stats: Arc>, +} + +impl Default for Repository { + fn default() -> Self { + Self::new() + } +} + +impl Repository { + #[must_use] + pub fn new() -> Self { + let stats = Arc::new(RwLock::new(describe_metrics())); + + Self { stats } + } + + pub async fn get_metrics(&self) -> RwLockReadGuard<'_, Metrics> { + self.stats.read().await + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increment the counter. + pub async fn increment_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increment_counter(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to increment the counter: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// set the gauge. + pub async fn set_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.set_gauge(metric_name, labels, value, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to set the gauge: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increment the gauge. + pub async fn increment_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increment_gauge(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to increment the gauge: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// decrement the gauge. + pub async fn decrement_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.decrement_gauge(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to decrement the gauge: {}", err), + } + + result + } +} diff --git a/packages/swarm-coordination-registry/src/swarm/coordinator.rs b/packages/swarm-coordination-registry/src/swarm/coordinator.rs new file mode 100644 index 000000000..433ab9d32 --- /dev/null +++ b/packages/swarm-coordination-registry/src/swarm/coordinator.rs @@ -0,0 +1,1040 @@ +//! A swarm is a collection of peers that are all trying to download the same +//! torrent. +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::sync::Arc; + +use aquatic_udp_protocol::AnnounceEvent; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_configuration::TrackerPolicy; +use torrust_tracker_primitives::peer::{self, Peer, PeerAnnouncement}; +use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::sender::Sender; +use crate::event::Event; + +#[derive(Clone)] +pub struct Coordinator { + info_hash: InfoHash, + peers: BTreeMap>, + metadata: SwarmMetadata, + event_sender: Sender, +} + +impl Coordinator { + #[must_use] + pub fn new(info_hash: &InfoHash, downloaded: u32, event_sender: Sender) -> Self { + Self { + info_hash: *info_hash, + peers: BTreeMap::new(), + metadata: SwarmMetadata::new(downloaded, 0, 0), + event_sender, + } + } + + pub async fn handle_announcement(&mut self, incoming_announce: &PeerAnnouncement) { + let _previous_peer = match peer::ReadInfo::get_event(incoming_announce) { + AnnounceEvent::Started | AnnounceEvent::None | AnnounceEvent::Completed => { + self.upsert_peer(Arc::new(*incoming_announce)).await + } + AnnounceEvent::Stopped => self.remove_peer(&incoming_announce.peer_addr).await, + }; + } + + pub async fn remove_inactive(&mut self, current_cutoff: DurationSinceUnixEpoch) -> usize { + let peers_to_remove = self.inactive_peers(current_cutoff); + + for peer_addr in &peers_to_remove { + self.remove_peer(peer_addr).await; + } + + peers_to_remove.len() + } + + #[must_use] + pub fn get(&self, peer_addr: &SocketAddr) -> Option<&Arc> { + self.peers.get(peer_addr) + } + + #[must_use] + pub fn peers(&self, limit: Option) -> Vec> { + match limit { + Some(limit) => self.peers.values().take(limit).cloned().collect(), + None => self.peers.values().cloned().collect(), + } + } + + #[must_use] + pub fn peers_excluding(&self, peer_addr: &SocketAddr, limit: Option) -> Vec> { + match limit { + Some(limit) => self + .peers + .values() + // Take peers which are not the client peer + .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) + // Limit the number of peers on the result + .take(limit) + .cloned() + .collect(), + None => self + .peers + .values() + // Take peers which are not the client peer + .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) + .cloned() + .collect(), + } + } + + #[must_use] + pub fn metadata(&self) -> SwarmMetadata { + self.metadata + } + + /// Returns the number of seeders and leechers in the swarm. + /// + /// # Panics + /// + /// This function will panic if the `complete` or `incomplete` fields in the + /// `metadata` field cannot be converted to `usize`. + #[must_use] + pub fn seeders_and_leechers(&self) -> (usize, usize) { + let seeders = self + .metadata + .complete + .try_into() + .expect("Failed to convert 'complete' (seeders) count to usize"); + let leechers = self + .metadata + .incomplete + .try_into() + .expect("Failed to convert 'incomplete' (leechers) count to usize"); + + (seeders, leechers) + } + + #[must_use] + pub fn count_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> usize { + self.peers + .iter() + .filter(|(_, peer)| peer::ReadInfo::get_updated(&**peer) <= current_cutoff) + .count() + } + + #[must_use] + pub fn get_activity_metadata(&self, current_cutoff: DurationSinceUnixEpoch) -> ActivityMetadata { + let inactive_peers_total = self.count_inactive_peers(current_cutoff); + + let active_peers_total = self.len() - inactive_peers_total; + + let is_active = active_peers_total > 0; + + ActivityMetadata::new(is_active, active_peers_total, inactive_peers_total) + } + + #[must_use] + pub fn len(&self) -> usize { + self.peers.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.peers.is_empty() + } + + #[must_use] + pub fn is_peerless(&self) -> bool { + self.is_empty() + } + + /// Returns true if the swarm meets the retention policy, meaning that + /// it should be kept in the list of swarms. + #[must_use] + pub fn meets_retaining_policy(&self, policy: &TrackerPolicy) -> bool { + !self.should_be_removed(policy) + } + + async fn upsert_peer(&mut self, incoming_announce: Arc) -> Option> { + let announcement = incoming_announce.clone(); + + if let Some(previous_announce) = self.peers.insert(incoming_announce.peer_addr, incoming_announce) { + let downloads_increased = self.update_metadata_on_update(&previous_announce, &announcement); + + self.trigger_peer_updated_event(&previous_announce, &announcement).await; + + if downloads_increased { + self.trigger_peer_download_completed_event(&announcement).await; + } + + Some(previous_announce) + } else { + self.update_metadata_on_insert(&announcement); + + self.trigger_peer_added_event(&announcement).await; + + None + } + } + + async fn remove_peer(&mut self, peer_addr: &SocketAddr) -> Option> { + if let Some(old_peer) = self.peers.remove(peer_addr) { + self.update_metadata_on_removal(&old_peer); + + self.trigger_peer_removed_event(&old_peer).await; + + Some(old_peer) + } else { + None + } + } + + #[must_use] + fn inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> Vec { + self.peers + .iter() + .filter(|(_, peer)| peer::ReadInfo::get_updated(&**peer) <= current_cutoff) + .map(|(addr, _)| *addr) + .collect() + } + + /// Returns true if the swarm should be removed according to the retention + /// policy. + fn should_be_removed(&self, policy: &TrackerPolicy) -> bool { + policy.remove_peerless_torrents && self.is_empty() + } + + fn update_metadata_on_insert(&mut self, added_peer: &Arc) { + if added_peer.is_seeder() { + self.metadata.complete += 1; + } else { + self.metadata.incomplete += 1; + } + } + + fn update_metadata_on_removal(&mut self, removed_peer: &Arc) { + if removed_peer.is_seeder() { + self.metadata.complete -= 1; + } else { + self.metadata.incomplete -= 1; + } + } + + fn update_metadata_on_update( + &mut self, + previous_announce: &Arc, + new_announce: &Arc, + ) -> bool { + let mut downloads_increased = false; + + if previous_announce.role() != new_announce.role() { + if new_announce.is_seeder() { + self.metadata.complete += 1; + self.metadata.incomplete -= 1; + } else { + self.metadata.complete -= 1; + self.metadata.incomplete += 1; + } + } + + if new_announce.is_completed() && !previous_announce.is_completed() { + self.metadata.downloaded += 1; + downloads_increased = true; + } + + downloads_increased + } + + async fn trigger_peer_added_event(&self, announcement: &Arc) { + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender + .send(Event::PeerAdded { + info_hash: self.info_hash, + peer: *announcement.clone(), + }) + .await; + } + } + + async fn trigger_peer_removed_event(&self, old_peer: &Arc) { + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender + .send(Event::PeerRemoved { + info_hash: self.info_hash, + peer: *old_peer.clone(), + }) + .await; + } + } + + async fn trigger_peer_updated_event(&self, old_announce: &Arc, new_announce: &Arc) { + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender + .send(Event::PeerUpdated { + info_hash: self.info_hash, + old_peer: *old_announce.clone(), + new_peer: *new_announce.clone(), + }) + .await; + } + } + + async fn trigger_peer_download_completed_event(&self, new_announce: &Arc) { + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender + .send(Event::PeerDownloadCompleted { + info_hash: self.info_hash, + peer: *new_announce.clone(), + }) + .await; + } + } +} + +#[derive(Clone)] +pub struct ActivityMetadata { + /// Indicates if the swarm is active. It's inactive if there are no active + /// peers. + pub is_active: bool, + + /// The number of active peers in the swarm. + pub active_peers_total: usize, + + /// The number of inactive peers in the swarm. + pub inactive_peers_total: usize, +} + +impl ActivityMetadata { + #[must_use] + pub fn new(is_active: bool, active_peers_total: usize, inactive_peers_total: usize) -> Self { + Self { + is_active, + active_peers_total, + inactive_peers_total, + } + } +} + +#[cfg(test)] +mod tests { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::PeerId; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[test] + fn it_should_be_empty_when_no_peers_have_been_inserted() { + let swarm = Coordinator::new(&sample_info_hash(), 0, None); + + assert!(swarm.is_empty()); + } + + #[test] + fn it_should_have_zero_length_when_no_peers_have_been_inserted() { + let swarm = Coordinator::new(&sample_info_hash(), 0, None); + + assert_eq!(swarm.len(), 0); + } + + #[tokio::test] + async fn it_should_allow_inserting_a_new_peer() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + assert_eq!(swarm.upsert_peer(peer.into()).await, None); + } + + #[tokio::test] + async fn it_should_allow_updating_a_preexisting_peer() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.upsert_peer(peer.into()).await, Some(Arc::new(peer))); + } + + #[tokio::test] + async fn it_should_allow_getting_all_peers() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.peers(None), [Arc::new(peer)]); + } + + #[tokio::test] + async fn it_should_allow_getting_one_peer_by_id() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.get(&peer.peer_addr), Some(Arc::new(peer)).as_ref()); + } + + #[tokio::test] + async fn it_should_increase_the_number_of_peers_after_inserting_a_new_one() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.len(), 1); + } + + #[tokio::test] + async fn it_should_decrease_the_number_of_peers_after_removing_one() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + swarm.remove_peer(&peer.peer_addr).await; + + assert!(swarm.is_empty()); + } + + #[tokio::test] + async fn it_should_allow_removing_an_existing_peer() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + swarm.upsert_peer(peer.into()).await; + + let old = swarm.remove_peer(&peer.peer_addr).await; + + assert_eq!(old, Some(Arc::new(peer))); + assert_eq!(swarm.get(&peer.peer_addr), None); + } + + #[tokio::test] + async fn it_should_allow_removing_a_non_existing_peer() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer = PeerBuilder::default().build(); + + assert_eq!(swarm.remove_peer(&peer.peer_addr).await, None); + } + + #[tokio::test] + async fn it_should_allow_getting_all_peers_excluding_peers_with_a_given_address() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer1 = PeerBuilder::default() + .with_peer_id(&PeerId(*b"-qB00000000000000001")) + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) + .build(); + swarm.upsert_peer(peer1.into()).await; + + let peer2 = PeerBuilder::default() + .with_peer_id(&PeerId(*b"-qB00000000000000002")) + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 6969)) + .build(); + swarm.upsert_peer(peer2.into()).await; + + assert_eq!(swarm.peers_excluding(&peer2.peer_addr, None), [Arc::new(peer1)]); + } + + #[tokio::test] + async fn it_should_count_inactive_peers() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let one_second = DurationSinceUnixEpoch::new(1, 0); + + // Insert the peer + let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); + let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); + swarm.upsert_peer(peer.into()).await; + + let inactive_peers_total = swarm.count_inactive_peers(last_update_time + one_second); + + assert_eq!(inactive_peers_total, 1); + } + + #[tokio::test] + async fn it_should_remove_inactive_peers() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let one_second = DurationSinceUnixEpoch::new(1, 0); + + // Insert the peer + let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); + let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); + swarm.upsert_peer(peer.into()).await; + + // Remove peers not updated since one second after inserting the peer + swarm.remove_inactive(last_update_time + one_second).await; + + assert_eq!(swarm.len(), 0); + } + + #[tokio::test] + async fn it_should_not_remove_active_peers() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let one_second = DurationSinceUnixEpoch::new(1, 0); + + // Insert the peer + let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); + let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); + swarm.upsert_peer(peer.into()).await; + + // Remove peers not updated since one second before inserting the peer. + swarm.remove_inactive(last_update_time.checked_sub(one_second).unwrap()).await; + + assert_eq!(swarm.len(), 1); + } + + mod for_retaining_policy { + + use torrust_tracker_configuration::TrackerPolicy; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + + use crate::tests::sample_info_hash; + use crate::Coordinator; + + fn empty_swarm() -> Coordinator { + Coordinator::new(&sample_info_hash(), 0, None) + } + + async fn not_empty_swarm() -> Coordinator { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + swarm.upsert_peer(PeerBuilder::default().build().into()).await; + swarm + } + + async fn not_empty_swarm_with_downloads() -> Coordinator { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let mut peer = PeerBuilder::leecher().build(); + + swarm.upsert_peer(peer.into()).await; + + peer.event = aquatic_udp_protocol::AnnounceEvent::Completed; + + swarm.upsert_peer(peer.into()).await; + + assert!(swarm.metadata().downloads() > 0); + + swarm + } + + fn remove_peerless_torrents_policy() -> TrackerPolicy { + TrackerPolicy { + remove_peerless_torrents: true, + ..Default::default() + } + } + + fn don_not_remove_peerless_torrents_policy() -> TrackerPolicy { + TrackerPolicy { + remove_peerless_torrents: false, + ..Default::default() + } + } + + mod when_removing_peerless_torrents_is_enabled { + + use torrust_tracker_configuration::TrackerPolicy; + + use crate::swarm::coordinator::tests::for_retaining_policy::{ + empty_swarm, not_empty_swarm, not_empty_swarm_with_downloads, remove_peerless_torrents_policy, + }; + + #[test] + fn it_should_be_removed_if_the_swarm_is_empty() { + assert!(empty_swarm().should_be_removed(&remove_peerless_torrents_policy())); + } + + #[tokio::test] + async fn it_should_not_be_removed_is_the_swarm_is_not_empty() { + assert!(!not_empty_swarm().await.should_be_removed(&remove_peerless_torrents_policy())); + } + + #[tokio::test] + async fn it_should_not_be_removed_even_if_the_swarm_is_empty_if_we_need_to_track_stats_for_downloads_and_there_has_been_downloads( + ) { + let policy = TrackerPolicy { + remove_peerless_torrents: true, + persistent_torrent_completed_stat: true, + ..Default::default() + }; + + assert!(!not_empty_swarm_with_downloads().await.should_be_removed(&policy)); + } + } + + mod when_removing_peerless_torrents_is_disabled { + + use crate::swarm::coordinator::tests::for_retaining_policy::{ + don_not_remove_peerless_torrents_policy, empty_swarm, not_empty_swarm, + }; + + #[test] + fn it_should_not_be_removed_even_if_the_swarm_is_empty() { + assert!(!empty_swarm().should_be_removed(&don_not_remove_peerless_torrents_policy())); + } + + #[tokio::test] + async fn it_should_not_be_removed_is_the_swarm_is_not_empty() { + assert!(!not_empty_swarm() + .await + .should_be_removed(&don_not_remove_peerless_torrents_policy())); + } + } + } + + #[tokio::test] + async fn it_should_allow_inserting_two_identical_peers_except_for_the_socket_address() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let peer1 = PeerBuilder::default() + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) + .build(); + swarm.upsert_peer(peer1.into()).await; + + let peer2 = PeerBuilder::default() + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 6969)) + .build(); + swarm.upsert_peer(peer2.into()).await; + + assert_eq!(swarm.len(), 2); + } + + #[tokio::test] + async fn it_should_not_allow_inserting_two_peers_with_different_peer_id_but_the_same_socket_address() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + // When that happens the peer ID will be changed in the swarm. + // In practice, it's like if the peer had changed its ID. + + let peer1 = PeerBuilder::default() + .with_peer_id(&PeerId(*b"-qB00000000000000001")) + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) + .build(); + swarm.upsert_peer(peer1.into()).await; + + let peer2 = PeerBuilder::default() + .with_peer_id(&PeerId(*b"-qB00000000000000002")) + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) + .build(); + swarm.upsert_peer(peer2.into()).await; + + assert_eq!(swarm.len(), 1); + } + + #[tokio::test] + async fn it_should_return_the_swarm_metadata() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeder = PeerBuilder::seeder().build(); + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(leecher.into()).await; + + assert_eq!( + swarm.metadata(), + SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 1, + } + ); + } + + #[tokio::test] + async fn it_should_return_the_number_of_seeders_in_the_list() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeder = PeerBuilder::seeder().build(); + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(leecher.into()).await; + + let (seeders, _leechers) = swarm.seeders_and_leechers(); + + assert_eq!(seeders, 1); + } + + #[tokio::test] + async fn it_should_return_the_number_of_leechers_in_the_list() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeder = PeerBuilder::seeder().build(); + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(leecher.into()).await; + + let (_seeders, leechers) = swarm.seeders_and_leechers(); + + assert_eq!(leechers, 1); + } + + #[tokio::test] + async fn it_should_be_a_peerless_swarm_when_it_does_not_contain_any_peers() { + let swarm = Coordinator::new(&sample_info_hash(), 0, None); + assert!(swarm.is_peerless()); + } + + mod updating_the_swarm_metadata { + + mod when_a_new_peer_is_added { + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_increase_the_number_of_leechers_if_the_new_peer_is_a_leecher_() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let leechers = swarm.metadata().leechers(); + + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(leecher.into()).await; + + assert_eq!(swarm.metadata().leechers(), leechers + 1); + } + + #[tokio::test] + async fn it_should_increase_the_number_of_seeders_if_the_new_peer_is_a_seeder() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeders = swarm.metadata().seeders(); + + let seeder = PeerBuilder::seeder().build(); + + swarm.upsert_peer(seeder.into()).await; + + assert_eq!(swarm.metadata().seeders(), seeders + 1); + } + + #[tokio::test] + async fn it_should_not_increasing_the_number_of_downloads_if_the_new_peer_has_completed_downloading_as_it_was_not_previously_known( + ) { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let downloads = swarm.metadata().downloads(); + + let seeder = PeerBuilder::seeder().build(); + + swarm.upsert_peer(seeder.into()).await; + + assert_eq!(swarm.metadata().downloads(), downloads); + } + } + + mod when_a_peer_is_removed { + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_decrease_the_number_of_leechers_if_the_removed_peer_was_a_leecher() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(leecher.into()).await; + + let leechers = swarm.metadata().leechers(); + + swarm.remove_peer(&leecher.peer_addr).await; + + assert_eq!(swarm.metadata().leechers(), leechers - 1); + } + + #[tokio::test] + async fn it_should_decrease_the_number_of_seeders_if_the_removed_peer_was_a_seeder() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeder = PeerBuilder::seeder().build(); + + swarm.upsert_peer(seeder.into()).await; + + let seeders = swarm.metadata().seeders(); + + swarm.remove_peer(&seeder.peer_addr).await; + + assert_eq!(swarm.metadata().seeders(), seeders - 1); + } + } + + mod when_a_peer_is_removed_due_to_inactivity { + use std::time::Duration; + + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_decrease_the_number_of_leechers_when_a_removed_peer_is_a_leecher() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let leecher = PeerBuilder::leecher().build(); + + swarm.upsert_peer(leecher.into()).await; + + let leechers = swarm.metadata().leechers(); + + swarm.remove_inactive(leecher.updated + Duration::from_secs(1)).await; + + assert_eq!(swarm.metadata().leechers(), leechers - 1); + } + + #[tokio::test] + async fn it_should_decrease_the_number_of_seeders_when_the_removed_peer_is_a_seeder() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let seeder = PeerBuilder::seeder().build(); + + swarm.upsert_peer(seeder.into()).await; + + let seeders = swarm.metadata().seeders(); + + swarm.remove_inactive(seeder.updated + Duration::from_secs(1)).await; + + assert_eq!(swarm.metadata().seeders(), seeders - 1); + } + } + + mod for_changes_in_existing_peers { + use aquatic_udp_protocol::NumberOfBytes; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_increase_seeders_and_decreasing_leechers_when_the_peer_changes_from_leecher_to_seeder_() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let mut peer = PeerBuilder::leecher().build(); + + swarm.upsert_peer(peer.into()).await; + + let leechers = swarm.metadata().leechers(); + let seeders = swarm.metadata().seeders(); + + peer.left = NumberOfBytes::new(0); // Convert to seeder + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.metadata().seeders(), seeders + 1); + assert_eq!(swarm.metadata().leechers(), leechers - 1); + } + + #[tokio::test] + async fn it_should_increase_leechers_and_decreasing_seeders_when_the_peer_changes_from_seeder_to_leecher() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let mut peer = PeerBuilder::seeder().build(); + + swarm.upsert_peer(peer.into()).await; + + let leechers = swarm.metadata().leechers(); + let seeders = swarm.metadata().seeders(); + + peer.left = NumberOfBytes::new(10); // Convert to leecher + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.metadata().leechers(), leechers + 1); + assert_eq!(swarm.metadata().seeders(), seeders - 1); + } + + #[tokio::test] + async fn it_should_increase_the_number_of_downloads_when_the_peer_announces_completed_downloading() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let mut peer = PeerBuilder::leecher().build(); + + swarm.upsert_peer(peer.into()).await; + + let downloads = swarm.metadata().downloads(); + + peer.event = aquatic_udp_protocol::AnnounceEvent::Completed; + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.metadata().downloads(), downloads + 1); + } + + #[tokio::test] + async fn it_should_not_increasing_the_number_of_downloads_when_the_peer_announces_completed_downloading_twice_() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + + let mut peer = PeerBuilder::leecher().build(); + + swarm.upsert_peer(peer.into()).await; + + let downloads = swarm.metadata().downloads(); + + peer.event = aquatic_udp_protocol::AnnounceEvent::Completed; + + swarm.upsert_peer(peer.into()).await; + + swarm.upsert_peer(peer.into()).await; + + assert_eq!(swarm.metadata().downloads(), downloads + 1); + } + } + } + + mod triggering_events { + + use std::sync::Arc; + + use aquatic_udp_protocol::AnnounceEvent::Started; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::event::sender::tests::{expect_event_sequence, MockEventSender}; + use crate::event::Event; + use crate::swarm::coordinator::Coordinator; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_new_peer_is_added() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence(&mut event_sender_mock, vec![Event::PeerAdded { info_hash, peer }]); + + let mut swarm = Coordinator::new(&sample_info_hash(), 0, Some(Arc::new(event_sender_mock))); + + swarm.upsert_peer(peer.into()).await; + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_peer_is_directly_removed() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![Event::PeerAdded { info_hash, peer }, Event::PeerRemoved { info_hash, peer }], + ); + + let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); + + // Insert the peer + swarm.upsert_peer(peer.into()).await; + + swarm.remove_peer(&peer.peer_addr).await; + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_peer_is_removed_due_to_inactivity() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![Event::PeerAdded { info_hash, peer }, Event::PeerRemoved { info_hash, peer }], + ); + + let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); + + // Insert the peer + swarm.upsert_peer(peer.into()).await; + + // Peers not updated after this time will be removed + let current_cutoff = peer.updated + DurationSinceUnixEpoch::from_secs(1); + + swarm.remove_inactive(current_cutoff).await; + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_peer_is_updated() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().with_event(Started).build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![ + Event::PeerAdded { info_hash, peer }, + Event::PeerUpdated { + info_hash, + old_peer: peer, + new_peer: peer, + }, + ], + ); + + let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); + + // Insert the peer + swarm.upsert_peer(peer.into()).await; + + // Update the peer + swarm.upsert_peer(peer.into()).await; + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_peer_completes_a_download() { + let info_hash = sample_info_hash(); + let started_peer = PeerBuilder::leecher().with_event(Started).build(); + let completed_peer = started_peer.into_completed(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![ + Event::PeerAdded { + info_hash, + peer: started_peer, + }, + Event::PeerUpdated { + info_hash, + old_peer: started_peer, + new_peer: completed_peer, + }, + Event::PeerDownloadCompleted { + info_hash, + peer: completed_peer, + }, + ], + ); + + let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); + + // Insert the peer + swarm.upsert_peer(started_peer.into()).await; + + // Announce as completed + swarm.upsert_peer(completed_peer.into()).await; + } + } +} diff --git a/packages/swarm-coordination-registry/src/swarm/mod.rs b/packages/swarm-coordination-registry/src/swarm/mod.rs new file mode 100644 index 000000000..925ae4948 --- /dev/null +++ b/packages/swarm-coordination-registry/src/swarm/mod.rs @@ -0,0 +1,2 @@ +pub mod coordinator; +pub mod registry; diff --git a/packages/swarm-coordination-registry/src/swarm/registry.rs b/packages/swarm-coordination-registry/src/swarm/registry.rs new file mode 100644 index 000000000..c8e98f307 --- /dev/null +++ b/packages/swarm-coordination-registry/src/swarm/registry.rs @@ -0,0 +1,1447 @@ +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use crossbeam_skiplist::SkipMap; +use tokio::sync::Mutex; +use torrust_tracker_clock::conv::convert_from_timestamp_to_datetime_utc; +use torrust_tracker_configuration::TrackerPolicy; +use torrust_tracker_primitives::pagination::Pagination; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use crate::event::sender::Sender; +use crate::event::Event; +use crate::swarm::coordinator::Coordinator; +use crate::CoordinatorHandle; + +#[derive(Default)] +pub struct Registry { + swarms: SkipMap, + event_sender: Sender, +} + +impl Registry { + #[must_use] + pub fn new(event_sender: Sender) -> Self { + Self { + swarms: SkipMap::new(), + event_sender, + } + } + + /// Upsert a peer into the swarm of a torrent. + /// + /// Optionally, it can also preset the number of downloads of the torrent + /// only if it's the first time the torrent is being inserted. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// * `peer` - The peer to upsert. + /// * `opt_persistent_torrent` - The optional persisted data about a torrent + /// (number of downloads for the torrent). + /// + /// # Returns + /// + /// Returns `true` if the number of downloads was increased because the peer + /// completed the download. + /// + /// # Errors + /// + /// This function panics if the lock for the swarm handle cannot be acquired. + pub async fn handle_announcement( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + opt_persistent_torrent: Option, + ) -> Result<(), Error> { + let swarm_handle = match self.swarms.get(info_hash) { + None => { + let number_of_downloads = opt_persistent_torrent.unwrap_or_default(); + + let new_swarm_handle = + CoordinatorHandle::new(Coordinator::new(info_hash, number_of_downloads, self.event_sender.clone()).into()); + + let new_swarm_handle = self.swarms.get_or_insert(*info_hash, new_swarm_handle); + + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender + .send(Event::TorrentAdded { + info_hash: *info_hash, + announcement: *peer, + }) + .await; + } + + new_swarm_handle + } + Some(existing_swarm_handle) => existing_swarm_handle, + }; + + let mut swarm = swarm_handle.value().lock().await; + + swarm.handle_announcement(peer).await; + + Ok(()) + } + + /// Inserts a new swarm. Only used for testing purposes. + pub fn insert(&self, info_hash: &InfoHash, swarm: Coordinator) { + // code-review: swarms builder? or constructor from vec? + // It's only used for testing purposes. It allows to pre-define the + // initial state of the swarm without having to go through the upsert + // process. + + let swarm_handle = Arc::new(Mutex::new(swarm)); + + self.swarms.insert(*info_hash, swarm_handle); + + // IMPORTANT: Notice this does not send an event because is used only + // for testing purposes. The event is sent only when the torrent is + // announced for the first time. + } + + /// Removes a torrent entry from the repository. + /// + /// # Returns + /// + /// An `Option` containing the removed torrent entry if it existed. + #[must_use] + pub async fn remove(&self, key: &InfoHash) -> Option { + let swarm_handle = self.swarms.remove(key).map(|entry| entry.value().clone()); + + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender.send(Event::TorrentRemoved { info_hash: *key }).await; + } + + swarm_handle + } + + /// Retrieves a tracked torrent handle by its infohash. + /// + /// # Returns + /// + /// An `Option` containing the tracked torrent handle if found. + #[must_use] + pub fn get(&self, key: &InfoHash) -> Option { + let maybe_entry = self.swarms.get(key); + maybe_entry.map(|entry| entry.value().clone()) + } + + /// Retrieves a paginated list of tracked torrent handles. + /// + /// This method returns a vector of tuples, each containing an infohash and + /// its associated tracked torrent handle. The pagination parameters + /// (offset and limit) can be used to control the size of the result set. + /// + /// # Returns + /// + /// A vector of `(InfoHash, TorrentEntry)` tuples. + #[must_use] + pub fn get_paginated(&self, pagination: Option<&Pagination>) -> Vec<(InfoHash, CoordinatorHandle)> { + match pagination { + Some(pagination) => self + .swarms + .iter() + .skip(pagination.offset as usize) + .take(pagination.limit as usize) + .map(|entry| (*entry.key(), entry.value().clone())) + .collect(), + None => self + .swarms + .iter() + .map(|entry| (*entry.key(), entry.value().clone())) + .collect(), + } + } + + /// Retrieves swarm metadata for a given torrent. + /// + /// # Returns + /// + /// A `SwarmMetadata` struct containing the aggregated torrent data if found. + /// + /// # Errors + /// + /// This function panics if the lock for the swarm handle cannot be acquired. + pub async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Result, Error> { + match self.swarms.get(info_hash) { + None => Ok(None), + Some(swarm_handle) => { + let swarm = swarm_handle.value().lock().await; + Ok(Some(swarm.metadata())) + } + } + } + + /// Retrieves swarm metadata for a given torrent. + /// + /// # Returns + /// + /// A `SwarmMetadata` struct containing the aggregated torrent data if it's + /// found or a zeroed metadata struct if not. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for the + /// swarm handle. + pub async fn get_swarm_metadata_or_default(&self, info_hash: &InfoHash) -> Result { + match self.get_swarm_metadata(info_hash).await { + Ok(Some(swarm_metadata)) => Ok(swarm_metadata), + Ok(None) => Ok(SwarmMetadata::zeroed()), + Err(err) => Err(err), + } + } + + /// Retrieves torrent peers for a given torrent and client, excluding the + /// requesting client. + /// + /// This method filters out the client making the request (based on its + /// network address) and returns up to a maximum number of peers, defined by + /// the greater of the provided limit or the global `TORRENT_PEERS_LIMIT`. + /// + /// # Returns + /// + /// A vector of peers (wrapped in `Arc`) representing the active peers for + /// the torrent, excluding the requesting client. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for the + /// swarm handle. + pub async fn get_peers_peers_excluding( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + limit: usize, + ) -> Result>, Error> { + match self.get(info_hash) { + None => Ok(vec![]), + Some(swarm_handle) => { + let swarm = swarm_handle.lock().await; + Ok(swarm.peers_excluding(&peer.peer_addr, Some(limit))) + } + } + } + + /// Retrieves the list of peers for a given torrent. + /// + /// This method returns up to `TORRENT_PEERS_LIMIT` peers for the torrent + /// specified by the info-hash. + /// + /// # Returns + /// + /// A vector of peers (wrapped in `Arc`) representing the active peers for + /// the torrent. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for the + /// swarm handle. + pub async fn get_swarm_peers(&self, info_hash: &InfoHash, limit: usize) -> Result>, Error> { + match self.get(info_hash) { + None => Ok(vec![]), + Some(swarm_handle) => { + let swarm = swarm_handle.lock().await; + Ok(swarm.peers(Some(limit))) + } + } + } + + pub async fn get_activity_metadata(&self, current_cutoff: DurationSinceUnixEpoch) -> AggregateActivityMetadata { + let mut active_peers_total = 0; + let mut inactive_peers_total = 0; + let mut active_torrents_total = 0; + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + + let activity_metadata = swarm.get_activity_metadata(current_cutoff); + + if activity_metadata.is_active { + active_torrents_total += 1; + } + + active_peers_total += activity_metadata.active_peers_total; + inactive_peers_total += activity_metadata.inactive_peers_total; + } + + AggregateActivityMetadata { + active_peers_total, + inactive_peers_total, + active_torrents_total, + inactive_torrents_total: self.len() - active_torrents_total, + } + } + + /// Counts the number of inactive peers across all torrents. + pub async fn count_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> usize { + let mut inactive_peers_total = 0; + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + inactive_peers_total += swarm.count_inactive_peers(current_cutoff); + } + + inactive_peers_total + } + + /// Removes inactive peers from all torrent entries. + /// + /// A peer is considered inactive if its last update timestamp is older than + /// the provided cutoff time. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for any + /// swarm handle. + pub async fn remove_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> Result { + tracing::info!( + "Removing inactive peers since: {:?} ...", + convert_from_timestamp_to_datetime_utc(current_cutoff) + ); + + let mut inactive_peers_removed = 0; + + for swarm_handle in &self.swarms { + let mut swarm = swarm_handle.value().lock().await; + let removed = swarm.remove_inactive(current_cutoff).await; + inactive_peers_removed += removed; + } + + tracing::info!(inactive_peers_removed = inactive_peers_removed); + + Ok(inactive_peers_removed) + } + + /// Removes torrent entries that have no active peers. + /// + /// Depending on the tracker policy, torrents without any peers may be + /// removed to conserve memory. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for any + /// swarm handle. + pub async fn remove_peerless_torrents(&self, policy: &TrackerPolicy) -> Result { + tracing::info!("Removing peerless torrents ..."); + + let mut peerless_torrents_removed = 0; + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + + if swarm.meets_retaining_policy(policy) { + continue; + } + + let info_hash = *swarm_handle.key(); + + swarm_handle.remove(); + + peerless_torrents_removed += 1; + + if let Some(event_sender) = self.event_sender.as_deref() { + event_sender.send(Event::TorrentRemoved { info_hash }).await; + } + } + + tracing::info!(peerless_torrents_removed = peerless_torrents_removed); + + Ok(peerless_torrents_removed) + } + + /// Imports persistent torrent data into the in-memory repository. + /// + /// This method takes a set of persisted torrent entries (e.g., from a + /// database) and imports them into the in-memory repository for immediate + /// access. + pub fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) -> u64 { + tracing::info!("Importing persisted info about torrents ..."); + + let mut torrents_imported = 0; + + for (info_hash, completed) in persistent_torrents { + if self.swarms.contains_key(info_hash) { + continue; + } + + let entry = CoordinatorHandle::new(Coordinator::new(info_hash, *completed, self.event_sender.clone()).into()); + + // Since SkipMap is lock-free the torrent could have been inserted + // after checking if it exists. + self.swarms.get_or_insert(*info_hash, entry); + + torrents_imported += 1; + } + + tracing::info!(imported_torrents = torrents_imported); + + torrents_imported + } + + /// Calculates and returns overall torrent metrics. + /// + /// The returned [`AggregateSwarmMetadata`] contains aggregate data such as + /// the total number of torrents, total complete (seeders), incomplete + /// (leechers), and downloaded counts. + /// + /// # Returns + /// + /// A [`AggregateSwarmMetadata`] struct with the aggregated metrics. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for any + /// swarm handle. + pub async fn get_aggregate_swarm_metadata(&self) -> Result { + let mut metrics = AggregateActiveSwarmMetadata::default(); + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + + let stats = swarm.metadata(); + + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; + } + + Ok(metrics) + } + + /// Counts the number of torrents that are peerless (i.e., have no active + /// peers). + /// + /// # Returns + /// + /// A `usize` representing the number of peerless torrents. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for any + /// swarm handle. + pub async fn count_peerless_torrents(&self) -> Result { + let mut peerless_torrents = 0; + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + + if swarm.is_peerless() { + peerless_torrents += 1; + } + } + + Ok(peerless_torrents) + } + + /// Counts the total number of peers across all torrents. + /// + /// # Returns + /// + /// A `usize` representing the total number of peers. + /// + /// # Errors + /// + /// This function returns an error if it fails to acquire the lock for any + /// swarm handle. + pub async fn count_peers(&self) -> Result { + let mut peers = 0; + + for swarm_handle in &self.swarms { + let swarm = swarm_handle.value().lock().await; + + peers += swarm.len(); + } + + Ok(peers) + } + + #[must_use] + pub fn len(&self) -> usize { + self.swarms.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.swarms.is_empty() + } + + pub fn contains(&self, key: &InfoHash) -> bool { + self.swarms.contains_key(key) + } +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error {} + +#[derive(Clone, Debug, Default)] +pub struct AggregateActivityMetadata { + /// The number of active peers in all swarms. + pub active_peers_total: usize, + + /// The number of inactive peers in all swarms. + pub inactive_peers_total: usize, + + /// The number of active torrents. + pub active_torrents_total: usize, + + /// The number of inactive torrents. + pub inactive_torrents_total: usize, +} + +impl AggregateActivityMetadata { + pub fn log(&self) { + tracing::info!( + active_peers_total = self.active_peers_total, + inactive_peers_total = self.inactive_peers_total, + active_torrents_total = self.active_torrents_total, + inactive_torrents_total = self.inactive_torrents_total + ); + } +} +#[cfg(test)] +mod tests { + + mod the_swarm_repository { + + use std::sync::Arc; + + use aquatic_udp_protocol::PeerId; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + /// It generates a peer id from a number where the number is the last + /// part of the peer ID. For example, for `12` it returns + /// `-qB00000000000000012`. + fn numeric_peer_id(two_digits_value: i32) -> PeerId { + // Format idx as a string with leading zeros, ensuring it has exactly 2 digits + let idx_str = format!("{two_digits_value:02}"); + + // Create the base part of the peer ID. + let base = b"-qB00000000000000000"; + + // Concatenate the base with idx bytes, ensuring the total length is 20 bytes. + let mut peer_id_bytes = [0u8; 20]; + peer_id_bytes[..base.len()].copy_from_slice(base); + peer_id_bytes[base.len() - idx_str.len()..].copy_from_slice(idx_str.as_bytes()); + + PeerId(peer_id_bytes) + } + + // The `TorrentRepository` has these responsibilities: + // - To maintain the peer lists for each torrent. + // - To maintain the the torrent entries, which contains all the info + // about the torrents, including the peer lists. + // - To return the torrent entries (swarm handles). + // - To return the peer lists for a given torrent. + // - To return the torrent metrics. + // - To return the swarm metadata for a given torrent. + // - To handle the persistence of the torrent entries. + + #[tokio::test] + async fn it_should_return_zero_length_when_it_has_no_swarms() { + let swarms = Arc::new(Registry::default()); + assert_eq!(swarms.len(), 0); + } + + #[tokio::test] + async fn it_should_return_the_length_when_it_has_swarms() { + let swarms = Arc::new(Registry::default()); + let info_hash = sample_info_hash(); + let peer = sample_peer(); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + assert_eq!(swarms.len(), 1); + } + + #[tokio::test] + async fn it_should_be_empty_when_it_has_no_swarms() { + let swarms = Arc::new(Registry::default()); + assert!(swarms.is_empty()); + + let info_hash = sample_info_hash(); + let peer = sample_peer(); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + assert!(!swarms.is_empty()); + } + + #[tokio::test] + async fn it_should_not_be_empty_when_it_has_at_least_one_swarm() { + let swarms = Arc::new(Registry::default()); + let info_hash = sample_info_hash(); + let peer = sample_peer(); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + assert!(!swarms.is_empty()); + } + + mod maintaining_the_peer_lists { + + use std::sync::Arc; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn it_should_add_the_first_peer_to_the_torrent_peer_list() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + + swarms.handle_announcement(&info_hash, &sample_peer(), None).await.unwrap(); + + assert!(swarms.get(&info_hash).is_some()); + } + + #[tokio::test] + async fn it_should_allow_adding_the_same_peer_twice_to_the_torrent_peer_list() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + + swarms.handle_announcement(&info_hash, &sample_peer(), None).await.unwrap(); + swarms.handle_announcement(&info_hash, &sample_peer(), None).await.unwrap(); + + assert!(swarms.get(&info_hash).is_some()); + } + } + + mod returning_peer_lists_for_a_torrent { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::swarm::registry::tests::the_swarm_repository::numeric_peer_id; + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn it_should_return_the_peers_for_a_given_torrent() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let peer = sample_peer(); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + let peers = swarms.get_swarm_peers(&info_hash, 74).await.unwrap(); + + assert_eq!(peers, vec![Arc::new(peer)]); + } + + #[tokio::test] + async fn it_should_return_an_empty_list_or_peers_for_a_non_existing_torrent() { + let swarms = Arc::new(Registry::default()); + + let peers = swarms.get_swarm_peers(&sample_info_hash(), 74).await.unwrap(); + + assert!(peers.is_empty()); + } + + #[tokio::test] + async fn it_should_return_74_peers_at_the_most_for_a_given_torrent() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + + for idx in 1..=75 { + let peer = Peer { + peer_id: numeric_peer_id(idx), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + }; + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + } + + let peers = swarms.get_swarm_peers(&info_hash, 74).await.unwrap(); + + assert_eq!(peers.len(), 74); + } + + mod excluding_the_client_peer { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; + use torrust_tracker_configuration::TORRENT_PEERS_LIMIT; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::swarm::registry::tests::the_swarm_repository::numeric_peer_id; + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn it_should_return_an_empty_peer_list_for_a_non_existing_torrent() { + let swarms = Arc::new(Registry::default()); + + let peers = swarms + .get_peers_peers_excluding(&sample_info_hash(), &sample_peer(), TORRENT_PEERS_LIMIT) + .await + .unwrap(); + + assert_eq!(peers, vec![]); + } + + #[tokio::test] + async fn it_should_return_the_peers_for_a_given_torrent_excluding_a_given_peer() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let peer = sample_peer(); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + let peers = swarms + .get_peers_peers_excluding(&info_hash, &peer, TORRENT_PEERS_LIMIT) + .await + .unwrap(); + + assert_eq!(peers, vec![]); + } + + #[tokio::test] + async fn it_should_return_74_peers_at_the_most_for_a_given_torrent_when_it_filters_out_a_given_peer() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + + let excluded_peer = sample_peer(); + + swarms.handle_announcement(&info_hash, &excluded_peer, None).await.unwrap(); + + // Add 74 peers + for idx in 2..=75 { + let peer = Peer { + peer_id: numeric_peer_id(idx), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + }; + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + } + + let peers = swarms + .get_peers_peers_excluding(&info_hash, &excluded_peer, TORRENT_PEERS_LIMIT) + .await + .unwrap(); + + assert_eq!(peers.len(), 74); + } + } + } + + mod maintaining_the_torrent_entries { + + use std::ops::Add; + use std::sync::Arc; + use std::time::Duration; + + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_configuration::TrackerPolicy; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn it_should_remove_a_torrent_entry() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + swarms.handle_announcement(&info_hash, &sample_peer(), None).await.unwrap(); + + let _unused = swarms.remove(&info_hash).await; + + assert!(swarms.get(&info_hash).is_none()); + } + + #[tokio::test] + async fn it_should_count_inactive_peers() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let mut peer = sample_peer(); + peer.updated = DurationSinceUnixEpoch::new(0, 0); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + // Cut off time is 1 second after the peer was updated + let inactive_peers_total = swarms.count_inactive_peers(peer.updated.add(Duration::from_secs(1))).await; + + assert_eq!(inactive_peers_total, 1); + } + + #[tokio::test] + async fn it_should_remove_peers_that_have_not_been_updated_after_a_cutoff_time() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let mut peer = sample_peer(); + peer.updated = DurationSinceUnixEpoch::new(0, 0); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + // Cut off time is 1 second after the peer was updated + swarms + .remove_inactive_peers(peer.updated.add(Duration::from_secs(1))) + .await + .unwrap(); + + assert!(!swarms + .get_swarm_peers(&info_hash, 74) + .await + .unwrap() + .contains(&Arc::new(peer))); + } + + async fn initialize_repository_with_one_torrent_without_peers(info_hash: &InfoHash) -> Arc { + let swarms = Arc::new(Registry::default()); + + // Insert a sample peer for the torrent to force adding the torrent entry + let mut peer = sample_peer(); + peer.updated = DurationSinceUnixEpoch::new(0, 0); + swarms.handle_announcement(info_hash, &peer, None).await.unwrap(); + + // Remove the peer + swarms + .remove_inactive_peers(peer.updated.add(Duration::from_secs(1))) + .await + .unwrap(); + + swarms + } + + #[tokio::test] + async fn it_should_remove_torrents_without_peers() { + let info_hash = sample_info_hash(); + + let swarms = initialize_repository_with_one_torrent_without_peers(&info_hash).await; + + let tracker_policy = TrackerPolicy { + remove_peerless_torrents: true, + ..Default::default() + }; + + swarms.remove_peerless_torrents(&tracker_policy).await.unwrap(); + + assert!(swarms.get(&info_hash).is_none()); + } + } + mod returning_torrent_entries { + + use std::sync::Arc; + + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + use crate::{Coordinator, CoordinatorHandle}; + + /// `TorrentEntry` data is not directly accessible. It's only + /// accessible through the trait methods. We need this temporary + /// DTO to write simple and more readable assertions. + #[derive(Debug, Clone, PartialEq)] + struct TorrentEntryInfo { + swarm_metadata: SwarmMetadata, + peers: Vec, + number_of_peers: usize, + } + + async fn torrent_entry_info(swarm_handle: CoordinatorHandle) -> TorrentEntryInfo { + let torrent_guard = swarm_handle.lock().await; + torrent_guard.clone().into() + } + + #[allow(clippy::from_over_into)] + impl Into for Coordinator { + fn into(self) -> TorrentEntryInfo { + let torrent_entry_info = TorrentEntryInfo { + swarm_metadata: self.metadata(), + peers: self.peers(None).iter().map(|peer| *peer.clone()).collect(), + number_of_peers: self.len(), + }; + torrent_entry_info + } + } + + #[tokio::test] + async fn it_should_return_one_torrent_entry_by_infohash() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let peer = sample_peer(); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + let torrent_entry_info = torrent_entry_info(swarms.get(&info_hash).unwrap()).await; + + assert_eq!( + TorrentEntryInfo { + swarm_metadata: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0 + }, + peers: vec!(peer), + number_of_peers: 1 + }, + torrent_entry_info + ); + } + + mod it_should_return_many_torrent_entries { + use std::sync::Arc; + + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::swarm::registry::tests::the_swarm_repository::returning_torrent_entries::{ + torrent_entry_info, TorrentEntryInfo, + }; + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn without_pagination() { + let swarms = Arc::new(Registry::default()); + + let info_hash = sample_info_hash(); + let peer = sample_peer(); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + let torrent_entries = swarms.get_paginated(None); + + assert_eq!(torrent_entries.len(), 1); + + let torrent_entry = torrent_entry_info(torrent_entries.first().unwrap().1.clone()).await; + + assert_eq!( + TorrentEntryInfo { + swarm_metadata: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0 + }, + peers: vec!(peer), + number_of_peers: 1 + }, + torrent_entry + ); + } + + mod with_pagination { + use std::sync::Arc; + + use torrust_tracker_primitives::pagination::Pagination; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::swarm::registry::tests::the_swarm_repository::returning_torrent_entries::{ + torrent_entry_info, TorrentEntryInfo, + }; + use crate::swarm::registry::Registry; + use crate::tests::{ + sample_info_hash_alphabetically_ordered_after_sample_info_hash_one, sample_info_hash_one, + sample_peer_one, sample_peer_two, + }; + + #[tokio::test] + async fn it_should_return_the_first_page() { + let swarms = Arc::new(Registry::default()); + + // Insert one torrent entry + let info_hash_one = sample_info_hash_one(); + let peer_one = sample_peer_one(); + swarms.handle_announcement(&info_hash_one, &peer_one, None).await.unwrap(); + + // Insert another torrent entry + let info_hash_one = sample_info_hash_alphabetically_ordered_after_sample_info_hash_one(); + let peer_two = sample_peer_two(); + swarms.handle_announcement(&info_hash_one, &peer_two, None).await.unwrap(); + + // Get only the first page where page size is 1 + let torrent_entries = swarms.get_paginated(Some(&Pagination { offset: 0, limit: 1 })); + + assert_eq!(torrent_entries.len(), 1); + + let torrent_entry_info = torrent_entry_info(torrent_entries.first().unwrap().1.clone()).await; + + assert_eq!( + TorrentEntryInfo { + swarm_metadata: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0 + }, + peers: vec!(peer_one), + number_of_peers: 1 + }, + torrent_entry_info + ); + } + + #[tokio::test] + async fn it_should_return_the_second_page() { + let swarms = Arc::new(Registry::default()); + + // Insert one torrent entry + let info_hash_one = sample_info_hash_one(); + let peer_one = sample_peer_one(); + swarms.handle_announcement(&info_hash_one, &peer_one, None).await.unwrap(); + + // Insert another torrent entry + let info_hash_one = sample_info_hash_alphabetically_ordered_after_sample_info_hash_one(); + let peer_two = sample_peer_two(); + swarms.handle_announcement(&info_hash_one, &peer_two, None).await.unwrap(); + + // Get only the first page where page size is 1 + let torrent_entries = swarms.get_paginated(Some(&Pagination { offset: 1, limit: 1 })); + + assert_eq!(torrent_entries.len(), 1); + + let torrent_entry_info = torrent_entry_info(torrent_entries.first().unwrap().1.clone()).await; + + assert_eq!( + TorrentEntryInfo { + swarm_metadata: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0 + }, + peers: vec!(peer_two), + number_of_peers: 1 + }, + torrent_entry_info + ); + } + + #[tokio::test] + async fn it_should_allow_changing_the_page_size() { + let swarms = Arc::new(Registry::default()); + + // Insert one torrent entry + let info_hash_one = sample_info_hash_one(); + let peer_one = sample_peer_one(); + swarms.handle_announcement(&info_hash_one, &peer_one, None).await.unwrap(); + + // Insert another torrent entry + let info_hash_one = sample_info_hash_alphabetically_ordered_after_sample_info_hash_one(); + let peer_two = sample_peer_two(); + swarms.handle_announcement(&info_hash_one, &peer_two, None).await.unwrap(); + + // Get only the first page where page size is 1 + let torrent_entries = swarms.get_paginated(Some(&Pagination { offset: 1, limit: 1 })); + + assert_eq!(torrent_entries.len(), 1); + } + } + } + } + + mod returning_aggregate_swarm_metadata { + + use std::sync::Arc; + + use bittorrent_primitives::info_hash::fixture::gen_seeded_infohash; + use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + + use crate::swarm::registry::Registry; + use crate::tests::{complete_peer, leecher, sample_info_hash, seeder}; + + // todo: refactor to use test parametrization + + #[tokio::test] + async fn it_should_get_empty_aggregate_swarm_metadata_when_there_are_no_torrents() { + let swarms = Arc::new(Registry::default()); + + let aggregate_swarm_metadata = swarms.get_aggregate_swarm_metadata().await.unwrap(); + + assert_eq!( + aggregate_swarm_metadata, + AggregateActiveSwarmMetadata { + total_complete: 0, + total_downloaded: 0, + total_incomplete: 0, + total_torrents: 0 + } + ); + } + + #[tokio::test] + async fn it_should_return_the_aggregate_swarm_metadata_when_there_is_a_leecher() { + let swarms = Arc::new(Registry::default()); + + swarms + .handle_announcement(&sample_info_hash(), &leecher(), None) + .await + .unwrap(); + + let aggregate_swarm_metadata = swarms.get_aggregate_swarm_metadata().await.unwrap(); + + assert_eq!( + aggregate_swarm_metadata, + AggregateActiveSwarmMetadata { + total_complete: 0, + total_downloaded: 0, + total_incomplete: 1, + total_torrents: 1, + } + ); + } + + #[tokio::test] + async fn it_should_return_the_aggregate_swarm_metadata_when_there_is_a_seeder() { + let swarms = Arc::new(Registry::default()); + + swarms + .handle_announcement(&sample_info_hash(), &seeder(), None) + .await + .unwrap(); + + let aggregate_swarm_metadata = swarms.get_aggregate_swarm_metadata().await.unwrap(); + + assert_eq!( + aggregate_swarm_metadata, + AggregateActiveSwarmMetadata { + total_complete: 1, + total_downloaded: 0, + total_incomplete: 0, + total_torrents: 1, + } + ); + } + + #[tokio::test] + async fn it_should_return_the_aggregate_swarm_metadata_when_there_is_a_completed_peer() { + let swarms = Arc::new(Registry::default()); + + swarms + .handle_announcement(&sample_info_hash(), &complete_peer(), None) + .await + .unwrap(); + + let aggregate_swarm_metadata = swarms.get_aggregate_swarm_metadata().await.unwrap(); + + assert_eq!( + aggregate_swarm_metadata, + AggregateActiveSwarmMetadata { + total_complete: 1, + total_downloaded: 0, + total_incomplete: 0, + total_torrents: 1, + } + ); + } + + #[tokio::test] + async fn it_should_return_the_aggregate_swarm_metadata_when_there_are_multiple_torrents() { + let swarms = Arc::new(Registry::default()); + + let start_time = std::time::Instant::now(); + for i in 0..1_000_000 { + swarms + .handle_announcement(&gen_seeded_infohash(&i), &leecher(), None) + .await + .unwrap(); + } + let result_a = start_time.elapsed(); + + let start_time = std::time::Instant::now(); + let aggregate_swarm_metadata = swarms.get_aggregate_swarm_metadata().await.unwrap(); + let result_b = start_time.elapsed(); + + assert_eq!( + (aggregate_swarm_metadata), + (AggregateActiveSwarmMetadata { + total_complete: 0, + total_downloaded: 0, + total_incomplete: 1_000_000, + total_torrents: 1_000_000, + }), + "{result_a:?} {result_b:?}" + ); + } + + mod it_should_count_peerless_torrents { + use std::sync::Arc; + + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn no_peerless_torrents() { + let swarms = Arc::new(Registry::default()); + assert_eq!(swarms.count_peerless_torrents().await.unwrap(), 0); + } + + #[tokio::test] + async fn one_peerless_torrents() { + let info_hash = sample_info_hash(); + let peer = sample_peer(); + + let swarms = Arc::new(Registry::default()); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + let current_cutoff = peer.updated + DurationSinceUnixEpoch::from_secs(1); + swarms.remove_inactive_peers(current_cutoff).await.unwrap(); + + assert_eq!(swarms.count_peerless_torrents().await.unwrap(), 1); + } + } + + mod it_should_count_peers { + use std::sync::Arc; + + use crate::swarm::registry::Registry; + use crate::tests::{sample_info_hash, sample_peer}; + + #[tokio::test] + async fn no_peers() { + let swarms = Arc::new(Registry::default()); + assert_eq!(swarms.count_peers().await.unwrap(), 0); + } + + #[tokio::test] + async fn one_peer() { + let info_hash = sample_info_hash(); + let peer = sample_peer(); + + let swarms = Arc::new(Registry::default()); + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + assert_eq!(swarms.count_peers().await.unwrap(), 1); + } + } + } + + mod returning_swarm_metadata { + + use std::sync::Arc; + + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::swarm::registry::Registry; + use crate::tests::{leecher, sample_info_hash}; + + #[tokio::test] + async fn it_should_get_swarm_metadata_for_an_existing_torrent() { + let swarms = Arc::new(Registry::default()); + + let infohash = sample_info_hash(); + + swarms.handle_announcement(&infohash, &leecher(), None).await.unwrap(); + + let swarm_metadata = swarms.get_swarm_metadata_or_default(&infohash).await.unwrap(); + + assert_eq!( + swarm_metadata, + SwarmMetadata { + complete: 0, + downloaded: 0, + incomplete: 1, + } + ); + } + + #[tokio::test] + async fn it_should_return_zeroed_swarm_metadata_for_a_non_existing_torrent() { + let swarms = Arc::new(Registry::default()); + + let swarm_metadata = swarms.get_swarm_metadata_or_default(&sample_info_hash()).await.unwrap(); + + assert_eq!(swarm_metadata, SwarmMetadata::zeroed()); + } + } + + mod handling_persistence { + + use std::sync::Arc; + + use torrust_tracker_primitives::NumberOfDownloadsBTreeMap; + + use crate::swarm::registry::Registry; + use crate::tests::{leecher, sample_info_hash}; + + #[tokio::test] + async fn it_should_allow_importing_persisted_torrent_entries() { + let swarms = Arc::new(Registry::default()); + + let infohash = sample_info_hash(); + + let mut persistent_torrents = NumberOfDownloadsBTreeMap::default(); + + persistent_torrents.insert(infohash, 1); + + swarms.import_persistent(&persistent_torrents); + + let swarm_metadata = swarms.get_swarm_metadata_or_default(&infohash).await.unwrap(); + + // Only the number of downloads is persisted. + assert_eq!(swarm_metadata.downloaded, 1); + } + + #[tokio::test] + async fn it_should_allow_overwriting_a_previously_imported_persisted_torrent() { + // code-review: do we want to allow this? + + let swarms = Arc::new(Registry::default()); + + let infohash = sample_info_hash(); + + let mut persistent_torrents = NumberOfDownloadsBTreeMap::default(); + + persistent_torrents.insert(infohash, 1); + persistent_torrents.insert(infohash, 2); + + swarms.import_persistent(&persistent_torrents); + + let swarm_metadata = swarms.get_swarm_metadata_or_default(&infohash).await.unwrap(); + + // It takes the last value + assert_eq!(swarm_metadata.downloaded, 2); + } + + #[tokio::test] + async fn it_should_now_allow_importing_a_persisted_torrent_if_it_already_exists() { + let swarms = Arc::new(Registry::default()); + + let infohash = sample_info_hash(); + + // Insert a new the torrent entry + swarms.handle_announcement(&infohash, &leecher(), None).await.unwrap(); + let initial_number_of_downloads = swarms.get_swarm_metadata_or_default(&infohash).await.unwrap().downloaded; + + // Try to import the torrent entry + let new_number_of_downloads = initial_number_of_downloads + 1; + let mut persistent_torrents = NumberOfDownloadsBTreeMap::default(); + persistent_torrents.insert(infohash, new_number_of_downloads); + swarms.import_persistent(&persistent_torrents); + + // The number of downloads should not be changed + assert_eq!( + swarms.get_swarm_metadata_or_default(&infohash).await.unwrap().downloaded, + initial_number_of_downloads + ); + } + } + } + + mod triggering_events { + + use std::sync::Arc; + + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::event::sender::tests::{expect_event_sequence, MockEventSender}; + use crate::event::Event; + use crate::swarm::registry::Registry; + use crate::tests::sample_info_hash; + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_new_torrent_is_added() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![ + Event::TorrentAdded { + info_hash, + announcement: peer, + }, + Event::PeerAdded { info_hash, peer }, + ], + ); + + let swarms = Registry::new(Some(Arc::new(event_sender_mock))); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_torrent_is_directly_removed() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![ + Event::TorrentAdded { + info_hash, + announcement: peer, + }, + Event::PeerAdded { info_hash, peer }, + Event::TorrentRemoved { info_hash }, + ], + ); + + let swarms = Registry::new(Some(Arc::new(event_sender_mock))); + + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + swarms.remove(&info_hash).await.unwrap(); + } + + #[tokio::test] + async fn it_should_trigger_an_event_when_a_peerless_torrent_is_removed() { + let info_hash = sample_info_hash(); + let peer = PeerBuilder::leecher().build(); + + let mut event_sender_mock = MockEventSender::new(); + + expect_event_sequence( + &mut event_sender_mock, + vec![ + Event::TorrentAdded { + info_hash, + announcement: peer, + }, + Event::PeerAdded { info_hash, peer }, + Event::PeerRemoved { info_hash, peer }, + Event::TorrentRemoved { info_hash }, + ], + ); + + let swarms = Registry::new(Some(Arc::new(event_sender_mock))); + + // Add the new torrent + swarms.handle_announcement(&info_hash, &peer, None).await.unwrap(); + + // Remove the peer + let current_cutoff = peer.updated + DurationSinceUnixEpoch::from_secs(1); + swarms.remove_inactive_peers(current_cutoff).await.unwrap(); + + // Remove peerless torrents + + let tracker_policy = torrust_tracker_configuration::TrackerPolicy { + remove_peerless_torrents: true, + ..Default::default() + }; + + swarms.remove_peerless_torrents(&tracker_policy).await.unwrap(); + } + } +} diff --git a/packages/test-helpers/Cargo.toml b/packages/test-helpers/Cargo.toml index ccf08b570..3495c314a 100644 --- a/packages/test-helpers/Cargo.toml +++ b/packages/test-helpers/Cargo.toml @@ -16,4 +16,6 @@ version.workspace = true [dependencies] rand = "0" -torrust-tracker-configuration = { version = "3.0.0-rc.1", path = "../configuration" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +tracing = "0" +tracing-subscriber = { version = "0", features = ["json"] } diff --git a/packages/test-helpers/src/configuration.rs b/packages/test-helpers/src/configuration.rs index dbd8eef9e..ffe3af3b2 100644 --- a/packages/test-helpers/src/configuration.rs +++ b/packages/test-helpers/src/configuration.rs @@ -1,6 +1,8 @@ //! Tracker configuration factories for testing. use std::env; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::PathBuf; +use std::time::Duration; use torrust_tracker_configuration::{Configuration, HttpApi, HttpTracker, Threshold, UdpTracker}; @@ -28,12 +30,17 @@ pub fn ephemeral() -> Configuration { let mut config = Configuration::default(); - config.logging.threshold = Threshold::Off; // It should always be off here, the tests manage their own logging. + // This have to be Off otherwise the tracing global subscriber + // initialization will panic because you can't set a global subscriber more + // than once. You can use enable logging in tests with: + // `crate::common::logging::setup(LevelFilter::ERROR);` + // That will also allow you to capture logs and write assertions on them. + config.logging.threshold = Threshold::Off; // Ephemeral socket address for API let api_port = 0u16; let mut http_api = HttpApi { - bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), api_port), + bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), api_port), ..Default::default() }; http_api.add_token("admin", "MyAccessToken"); @@ -41,30 +48,37 @@ pub fn ephemeral() -> Configuration { // Ephemeral socket address for Health Check API let health_check_api_port = 0u16; - config.health_check_api.bind_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), health_check_api_port); + config.health_check_api.bind_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), health_check_api_port); // Ephemeral socket address for UDP tracker let udp_port = 0u16; config.udp_trackers = Some(vec![UdpTracker { - bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), udp_port), + bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), udp_port), + cookie_lifetime: Duration::from_secs(120), + tracker_usage_statistics: true, }]); // Ephemeral socket address for HTTP tracker let http_port = 0u16; config.http_trackers = Some(vec![HttpTracker { - bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), http_port), + bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), http_port), tsl_config: None, + tracker_usage_statistics: true, }]); - // Ephemeral sqlite database - let temp_directory = env::temp_dir(); - let random_db_id = random::string(16); - let temp_file = temp_directory.join(format!("data_{random_db_id}.db")); + let temp_file = ephemeral_sqlite_database(); temp_file.to_str().unwrap().clone_into(&mut config.core.database.path); config } +#[must_use] +pub fn ephemeral_sqlite_database() -> PathBuf { + let temp_directory = env::temp_dir(); + let random_db_id = random::string(16); + temp_directory.join(format!("data_{random_db_id}.db")) +} + /// Ephemeral configuration with reverse proxy enabled. #[must_use] pub fn ephemeral_with_reverse_proxy() -> Configuration { @@ -142,11 +156,11 @@ pub fn ephemeral_with_external_ip(ip: IpAddr) -> Configuration { pub fn ephemeral_ipv6() -> Configuration { let mut cfg = ephemeral(); - let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)), 0); + let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0); if let Some(ref mut http_api) = cfg.http_api { http_api.bind_address.clone_from(&ipv6); - }; + } if let Some(ref mut http_trackers) = cfg.http_trackers { http_trackers[0].bind_address.clone_from(&ipv6); diff --git a/packages/test-helpers/src/lib.rs b/packages/test-helpers/src/lib.rs index e66ea2adc..bd67ca770 100644 --- a/packages/test-helpers/src/lib.rs +++ b/packages/test-helpers/src/lib.rs @@ -2,4 +2,5 @@ //! //! A collection of functions and types to help with testing the tracker server. pub mod configuration; +pub mod logging; pub mod random; diff --git a/packages/test-helpers/src/logging.rs b/packages/test-helpers/src/logging.rs new file mode 100644 index 000000000..564074f3e --- /dev/null +++ b/packages/test-helpers/src/logging.rs @@ -0,0 +1,156 @@ +//! Setup for logging in tests. +use std::collections::VecDeque; +use std::io; +use std::sync::{Mutex, MutexGuard, Once, OnceLock}; + +use torrust_tracker_configuration::logging::TraceStyle; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::fmt::MakeWriter; + +static INIT: Once = Once::new(); + +/// A global buffer containing the latest lines captured from logs. +#[doc(hidden)] +pub fn captured_logs_buffer() -> &'static Mutex { + static CAPTURED_LOGS_GLOBAL_BUFFER: OnceLock> = OnceLock::new(); + CAPTURED_LOGS_GLOBAL_BUFFER.get_or_init(|| Mutex::new(CircularBuffer::new(10000, 200))) +} + +pub fn setup() { + INIT.call_once(|| { + tracing_init(LevelFilter::ERROR, &TraceStyle::Default); + }); +} + +fn tracing_init(level_filter: LevelFilter, style: &TraceStyle) { + let mock_writer = LogCapturer::new(captured_logs_buffer()); + + let builder = tracing_subscriber::fmt() + .with_max_level(level_filter) + .with_ansi(true) + .with_test_writer() + .with_writer(mock_writer); + + let () = match style { + TraceStyle::Default => builder.init(), + TraceStyle::Pretty(display_filename) => builder.pretty().with_file(*display_filename).init(), + TraceStyle::Compact => builder.compact().init(), + TraceStyle::Json => builder.json().init(), + }; + + tracing::info!("Logging initialized"); +} + +/// It returns true is there is a log line containing all the texts passed. +/// +/// # Panics +/// +/// Will panic if it can't get the lock for the global buffer or convert it into +/// a vec. +#[must_use] +#[allow(dead_code)] +pub fn logs_contains_a_line_with(texts: &[&str]) -> bool { + // code-review: we can search directly in the buffer instead of converting + // the buffer into a string but that would slow down the tests because + // cloning should be faster that locking the buffer for searching. + // Because the buffer is not big. + let logs = String::from_utf8(captured_logs_buffer().lock().unwrap().as_vec()).unwrap(); + + for line in logs.split('\n') { + if contains(line, texts) { + return true; + } + } + + false +} + +#[allow(dead_code)] +fn contains(text: &str, texts: &[&str]) -> bool { + texts.iter().all(|&word| text.contains(word)) +} + +/// A tracing writer which captures the latests logs lines into a buffer. +/// It's used to capture the logs in the tests. +#[derive(Debug)] +pub struct LogCapturer<'a> { + logs: &'a Mutex, +} + +impl<'a> LogCapturer<'a> { + pub fn new(buf: &'a Mutex) -> Self { + Self { logs: buf } + } + + fn buf(&self) -> io::Result> { + self.logs.lock().map_err(|_| io::Error::from(io::ErrorKind::Other)) + } +} + +impl io::Write for LogCapturer<'_> { + fn write(&mut self, buf: &[u8]) -> io::Result { + print!("{}", String::from_utf8(buf.to_vec()).unwrap()); + + let mut target = self.buf()?; + + target.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buf()?.flush() + } +} + +impl MakeWriter<'_> for LogCapturer<'_> { + type Writer = Self; + + fn make_writer(&self) -> Self::Writer { + LogCapturer::new(self.logs) + } +} + +#[derive(Debug)] +pub struct CircularBuffer { + max_size: usize, + buffer: VecDeque, +} + +impl CircularBuffer { + #[must_use] + pub fn new(max_lines: usize, average_line_size: usize) -> Self { + Self { + max_size: max_lines * average_line_size, + buffer: VecDeque::with_capacity(max_lines * average_line_size), + } + } + + /// # Errors + /// + /// Won't return any error. + #[allow(clippy::unnecessary_wraps)] + pub fn write(&mut self, buf: &[u8]) -> io::Result { + for &byte in buf { + if self.buffer.len() == self.max_size { + // Remove oldest byte to make space + self.buffer.pop_front(); + } + self.buffer.push_back(byte); + } + + Ok(buf.len()) + } + + /// # Errors + /// + /// Won't return any error. + #[allow(clippy::unnecessary_wraps)] + #[allow(clippy::unused_self)] + pub fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + + #[must_use] + pub fn as_vec(&self) -> Vec { + self.buffer.iter().copied().collect() + } +} diff --git a/packages/test-helpers/src/random.rs b/packages/test-helpers/src/random.rs index 2133dcd29..62265dbd7 100644 --- a/packages/test-helpers/src/random.rs +++ b/packages/test-helpers/src/random.rs @@ -1,10 +1,10 @@ //! Random data generators for testing. -use rand::distributions::Alphanumeric; -use rand::{thread_rng, Rng}; +use rand::distr::Alphanumeric; +use rand::{rng, RngExt}; /// Returns a random alphanumeric string of a certain size. /// /// It is useful for generating random names, IDs, etc for testing. pub fn string(size: usize) -> String { - thread_rng().sample_iter(&Alphanumeric).take(size).map(char::from).collect() + rng().sample_iter(&Alphanumeric).take(size).map(char::from).collect() } diff --git a/packages/torrent-repository/Cargo.toml b/packages/torrent-repository-benchmarking/Cargo.toml similarity index 61% rename from packages/torrent-repository/Cargo.toml rename to packages/torrent-repository-benchmarking/Cargo.toml index 0650d608f..1a93c513c 100644 --- a/packages/torrent-repository/Cargo.toml +++ b/packages/torrent-repository-benchmarking/Cargo.toml @@ -1,7 +1,7 @@ [package] -description = "A library that provides a repository of torrents files and their peers." +description = "A library to runt benchmarking for different implementations of a repository of torrents files and their peers." keywords = ["library", "repository", "torrents"] -name = "torrust-tracker-torrent-repository" +name = "torrust-tracker-torrent-repository-benchmarking" readme = "README.md" authors.workspace = true @@ -17,15 +17,16 @@ version.workspace = true [dependencies] aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" crossbeam-skiplist = "0" dashmap = "6" futures = "0" parking_lot = "0" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } -torrust-tracker-clock = { version = "3.0.0-rc.1", path = "../clock" } -torrust-tracker-configuration = { version = "3.0.0-rc.1", path = "../configuration" } -torrust-tracker-primitives = { version = "3.0.0-rc.1", path = "../primitives" } -zerocopy = "0" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +zerocopy = "0.7" [dev-dependencies] async-std = { version = "1", features = ["attributes", "tokio1"] } diff --git a/packages/torrent-repository/README.md b/packages/torrent-repository-benchmarking/README.md similarity index 73% rename from packages/torrent-repository/README.md rename to packages/torrent-repository-benchmarking/README.md index ffc71f1d7..a0556a58f 100644 --- a/packages/torrent-repository/README.md +++ b/packages/torrent-repository-benchmarking/README.md @@ -1,6 +1,6 @@ -# Torrust Tracker Torrent Repository +# Torrust Tracker Swarm Coordination Registry Benchmarking -A library to provide a torrent repository to the [Torrust Tracker](https://github.com/torrust/torrust-tracker). +A library to runt benchmarking for different implementations of a repository of torrents files and their peers. Torrent repositories are used by the [Torrust Tracker](https://github.com/torrust/torrust-tracker). ## Benchmarking diff --git a/packages/torrent-repository/benches/helpers/asyn.rs b/packages/torrent-repository-benchmarking/benches/helpers/asyn.rs similarity index 92% rename from packages/torrent-repository/benches/helpers/asyn.rs rename to packages/torrent-repository-benchmarking/benches/helpers/asyn.rs index 08862abc8..4deb1955a 100644 --- a/packages/torrent-repository/benches/helpers/asyn.rs +++ b/packages/torrent-repository-benchmarking/benches/helpers/asyn.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; +use bittorrent_primitives::info_hash::InfoHash; use futures::stream::FuturesUnordered; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_torrent_repository::repository::RepositoryAsync; +use torrust_tracker_torrent_repository_benchmarking::repository::RepositoryAsync; use super::utils::{generate_unique_info_hashes, DEFAULT_PEER}; @@ -18,7 +18,7 @@ where let info_hash = InfoHash::default(); - torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER).await; + torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER, None).await; torrent_repository.get_swarm_metadata(&info_hash).await; } @@ -37,7 +37,7 @@ where let handles = FuturesUnordered::new(); // Add the torrent/peer to the torrent repository - torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER).await; + torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER, None).await; torrent_repository.get_swarm_metadata(&info_hash).await; @@ -47,7 +47,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER).await; + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None).await; torrent_repository_clone.get_swarm_metadata(&info_hash).await; @@ -87,7 +87,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER).await; + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None).await; torrent_repository_clone.get_swarm_metadata(&info_hash).await; @@ -123,7 +123,7 @@ where // Add the torrents/peers to the torrent repository for info_hash in &info_hashes { - torrent_repository.upsert_peer(info_hash, &DEFAULT_PEER).await; + torrent_repository.upsert_peer(info_hash, &DEFAULT_PEER, None).await; torrent_repository.get_swarm_metadata(info_hash).await; } @@ -133,7 +133,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER).await; + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None).await; torrent_repository_clone.get_swarm_metadata(&info_hash).await; if let Some(sleep_time) = sleep { diff --git a/packages/torrent-repository/benches/helpers/mod.rs b/packages/torrent-repository-benchmarking/benches/helpers/mod.rs similarity index 100% rename from packages/torrent-repository/benches/helpers/mod.rs rename to packages/torrent-repository-benchmarking/benches/helpers/mod.rs diff --git a/packages/torrent-repository/benches/helpers/sync.rs b/packages/torrent-repository-benchmarking/benches/helpers/sync.rs similarity index 92% rename from packages/torrent-repository/benches/helpers/sync.rs rename to packages/torrent-repository-benchmarking/benches/helpers/sync.rs index 77055911d..2cefb5a4a 100644 --- a/packages/torrent-repository/benches/helpers/sync.rs +++ b/packages/torrent-repository-benchmarking/benches/helpers/sync.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; +use bittorrent_primitives::info_hash::InfoHash; use futures::stream::FuturesUnordered; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_torrent_repository::repository::Repository; +use torrust_tracker_torrent_repository_benchmarking::repository::Repository; use super::utils::{generate_unique_info_hashes, DEFAULT_PEER}; @@ -20,7 +20,7 @@ where let info_hash = InfoHash::default(); - torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER); + torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER, None); torrent_repository.get_swarm_metadata(&info_hash); } @@ -39,7 +39,7 @@ where let handles = FuturesUnordered::new(); // Add the torrent/peer to the torrent repository - torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER); + torrent_repository.upsert_peer(&info_hash, &DEFAULT_PEER, None); torrent_repository.get_swarm_metadata(&info_hash); @@ -49,7 +49,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER); + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None); torrent_repository_clone.get_swarm_metadata(&info_hash); @@ -89,7 +89,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER); + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None); torrent_repository_clone.get_swarm_metadata(&info_hash); @@ -125,7 +125,7 @@ where // Add the torrents/peers to the torrent repository for info_hash in &info_hashes { - torrent_repository.upsert_peer(info_hash, &DEFAULT_PEER); + torrent_repository.upsert_peer(info_hash, &DEFAULT_PEER, None); torrent_repository.get_swarm_metadata(info_hash); } @@ -135,7 +135,7 @@ where let torrent_repository_clone = torrent_repository.clone(); let handle = runtime.spawn(async move { - torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER); + torrent_repository_clone.upsert_peer(&info_hash, &DEFAULT_PEER, None); torrent_repository_clone.get_swarm_metadata(&info_hash); if let Some(sleep_time) = sleep { diff --git a/packages/torrent-repository/benches/helpers/utils.rs b/packages/torrent-repository-benchmarking/benches/helpers/utils.rs similarity index 89% rename from packages/torrent-repository/benches/helpers/utils.rs rename to packages/torrent-repository-benchmarking/benches/helpers/utils.rs index e21ac7332..16ba0bf7f 100644 --- a/packages/torrent-repository/benches/helpers/utils.rs +++ b/packages/torrent-repository-benchmarking/benches/helpers/utils.rs @@ -2,14 +2,14 @@ use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; -use torrust_tracker_primitives::info_hash::InfoHash; +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_primitives::peer::Peer; use torrust_tracker_primitives::DurationSinceUnixEpoch; use zerocopy::I64; pub const DEFAULT_PEER: Peer = Peer { peer_id: PeerId([0; 20]), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), updated: DurationSinceUnixEpoch::from_secs(0), uploaded: NumberOfBytes(I64::ZERO), downloaded: NumberOfBytes(I64::ZERO), diff --git a/packages/torrent-repository/benches/repository_benchmark.rs b/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs similarity index 99% rename from packages/torrent-repository/benches/repository_benchmark.rs rename to packages/torrent-repository-benchmarking/benches/repository_benchmark.rs index 4e50f1454..a58207492 100644 --- a/packages/torrent-repository/benches/repository_benchmark.rs +++ b/packages/torrent-repository-benchmarking/benches/repository_benchmark.rs @@ -3,7 +3,7 @@ use std::time::Duration; mod helpers; use criterion::{criterion_group, criterion_main, Criterion}; -use torrust_tracker_torrent_repository::{ +use torrust_tracker_torrent_repository_benchmarking::{ TorrentsDashMapMutexStd, TorrentsRwLockStd, TorrentsRwLockStdMutexStd, TorrentsRwLockStdMutexTokio, TorrentsRwLockTokio, TorrentsRwLockTokioMutexStd, TorrentsRwLockTokioMutexTokio, TorrentsSkipMapMutexParkingLot, TorrentsSkipMapMutexStd, TorrentsSkipMapRwLockParkingLot, diff --git a/packages/torrent-repository/src/entry/mod.rs b/packages/torrent-repository-benchmarking/src/entry/mod.rs similarity index 100% rename from packages/torrent-repository/src/entry/mod.rs rename to packages/torrent-repository-benchmarking/src/entry/mod.rs diff --git a/packages/torrent-repository/src/entry/mutex_parking_lot.rs b/packages/torrent-repository-benchmarking/src/entry/mutex_parking_lot.rs similarity index 100% rename from packages/torrent-repository/src/entry/mutex_parking_lot.rs rename to packages/torrent-repository-benchmarking/src/entry/mutex_parking_lot.rs diff --git a/packages/torrent-repository/src/entry/mutex_std.rs b/packages/torrent-repository-benchmarking/src/entry/mutex_std.rs similarity index 100% rename from packages/torrent-repository/src/entry/mutex_std.rs rename to packages/torrent-repository-benchmarking/src/entry/mutex_std.rs diff --git a/packages/torrent-repository/src/entry/mutex_tokio.rs b/packages/torrent-repository-benchmarking/src/entry/mutex_tokio.rs similarity index 100% rename from packages/torrent-repository/src/entry/mutex_tokio.rs rename to packages/torrent-repository-benchmarking/src/entry/mutex_tokio.rs diff --git a/packages/torrent-repository/src/entry/peer_list.rs b/packages/torrent-repository-benchmarking/src/entry/peer_list.rs similarity index 98% rename from packages/torrent-repository/src/entry/peer_list.rs rename to packages/torrent-repository-benchmarking/src/entry/peer_list.rs index 33270cf27..976e89d03 100644 --- a/packages/torrent-repository/src/entry/peer_list.rs +++ b/packages/torrent-repository-benchmarking/src/entry/peer_list.rs @@ -195,7 +195,7 @@ mod tests { let peer1 = PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000001")) - .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 6969)) + .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); peer_list.upsert(peer1.into()); @@ -265,7 +265,7 @@ mod tests { peer_list.upsert(peer.into()); // Remove peers not updated since one second before inserting the peer. - peer_list.remove_inactive_peers(last_update_time - one_second); + peer_list.remove_inactive_peers(last_update_time.checked_sub(one_second).unwrap()); assert_eq!(peer_list.len(), 1); } diff --git a/packages/torrent-repository/src/entry/rw_lock_parking_lot.rs b/packages/torrent-repository-benchmarking/src/entry/rw_lock_parking_lot.rs similarity index 100% rename from packages/torrent-repository/src/entry/rw_lock_parking_lot.rs rename to packages/torrent-repository-benchmarking/src/entry/rw_lock_parking_lot.rs diff --git a/packages/torrent-repository/src/entry/single.rs b/packages/torrent-repository-benchmarking/src/entry/single.rs similarity index 88% rename from packages/torrent-repository/src/entry/single.rs rename to packages/torrent-repository-benchmarking/src/entry/single.rs index 7f8cfc4e6..0f922bd02 100644 --- a/packages/torrent-repository/src/entry/single.rs +++ b/packages/torrent-repository-benchmarking/src/entry/single.rs @@ -51,7 +51,7 @@ impl Entry for EntrySingle { } fn upsert_peer(&mut self, peer: &peer::Peer) -> bool { - let mut downloaded_stats_updated: bool = false; + let mut number_of_downloads_increased: bool = false; match peer::ReadInfo::get_event(peer) { AnnounceEvent::Stopped => { @@ -62,15 +62,17 @@ impl Entry for EntrySingle { // Don't count if peer was not previously known and not already completed. if previous.is_some_and(|p| p.event != AnnounceEvent::Completed) { self.downloaded += 1; - downloaded_stats_updated = true; + number_of_downloads_increased = true; } } _ => { + // `Started` event (first announced event) or + // `None` event (announcements done at regular intervals). drop(self.swarm.upsert(Arc::new(*peer))); } } - downloaded_stats_updated + number_of_downloads_increased } fn remove_inactive_peers(&mut self, current_cutoff: DurationSinceUnixEpoch) { diff --git a/packages/torrent-repository/src/lib.rs b/packages/torrent-repository-benchmarking/src/lib.rs similarity index 100% rename from packages/torrent-repository/src/lib.rs rename to packages/torrent-repository-benchmarking/src/lib.rs diff --git a/packages/torrent-repository/src/repository/dash_map_mutex_std.rs b/packages/torrent-repository-benchmarking/src/repository/dash_map_mutex_std.rs similarity index 75% rename from packages/torrent-repository/src/repository/dash_map_mutex_std.rs rename to packages/torrent-repository-benchmarking/src/repository/dash_map_mutex_std.rs index 4354c12ec..fec94b4a5 100644 --- a/packages/torrent-repository/src/repository/dash_map_mutex_std.rs +++ b/packages/torrent-repository-benchmarking/src/repository/dash_map_mutex_std.rs @@ -1,12 +1,11 @@ use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use dashmap::DashMap; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::Repository; use crate::entry::peer_list::PeerList; @@ -23,13 +22,17 @@ where EntryMutexStd: EntrySync, EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, _opt_persistent_torrent: Option) -> bool { + // todo: load persistent torrent data if provided + if let Some(entry) = self.torrents.get(info_hash) { - entry.upsert_peer(peer); + entry.upsert_peer(peer) } else { let _unused = self.torrents.insert(*info_hash, Arc::default()); if let Some(entry) = self.torrents.get(info_hash) { - entry.upsert_peer(peer); + entry.upsert_peer(peer) + } else { + false } } } @@ -43,15 +46,15 @@ where maybe_entry.map(|entry| entry.clone()) } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in &self.torrents { let stats = entry.value().lock().expect("it should get a lock").get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -74,7 +77,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { for (info_hash, completed) in persistent_torrents { if self.torrents.contains_key(info_hash) { continue; diff --git a/packages/torrent-repository/src/repository/mod.rs b/packages/torrent-repository-benchmarking/src/repository/mod.rs similarity index 65% rename from packages/torrent-repository/src/repository/mod.rs rename to packages/torrent-repository-benchmarking/src/repository/mod.rs index f198288f8..cf58838a1 100644 --- a/packages/torrent-repository/src/repository/mod.rs +++ b/packages/torrent-repository-benchmarking/src/repository/mod.rs @@ -1,9 +1,8 @@ +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; pub mod dash_map_mutex_std; pub mod rw_lock_std; @@ -18,25 +17,30 @@ use std::fmt::Debug; pub trait Repository: Debug + Default + Sized + 'static { fn get(&self, key: &InfoHash) -> Option; - fn get_metrics(&self) -> TorrentsMetrics; + fn get_metrics(&self) -> AggregateActiveSwarmMetadata; fn get_paginated(&self, pagination: Option<&Pagination>) -> Vec<(InfoHash, T)>; - fn import_persistent(&self, persistent_torrents: &PersistentTorrents); + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap); fn remove(&self, key: &InfoHash) -> Option; fn remove_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch); fn remove_peerless_torrents(&self, policy: &TrackerPolicy); - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer); + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, opt_persistent_torrent: Option) -> bool; fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option; } #[allow(clippy::module_name_repetitions)] pub trait RepositoryAsync: Debug + Default + Sized + 'static { fn get(&self, key: &InfoHash) -> impl std::future::Future> + Send; - fn get_metrics(&self) -> impl std::future::Future + Send; + fn get_metrics(&self) -> impl std::future::Future + Send; fn get_paginated(&self, pagination: Option<&Pagination>) -> impl std::future::Future> + Send; - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) -> impl std::future::Future + Send; + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) -> impl std::future::Future + Send; fn remove(&self, key: &InfoHash) -> impl std::future::Future> + Send; fn remove_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> impl std::future::Future + Send; fn remove_peerless_torrents(&self, policy: &TrackerPolicy) -> impl std::future::Future + Send; - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) -> impl std::future::Future + Send; + fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + opt_persistent_torrent: Option, + ) -> impl std::future::Future + Send; fn get_swarm_metadata(&self, info_hash: &InfoHash) -> impl std::future::Future> + Send; } diff --git a/packages/torrent-repository/src/repository/rw_lock_std.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std.rs similarity index 78% rename from packages/torrent-repository/src/repository/rw_lock_std.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_std.rs index 5439fdd79..5000579dd 100644 --- a/packages/torrent-repository/src/repository/rw_lock_std.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std.rs @@ -1,9 +1,8 @@ +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::Repository; use crate::entry::peer_list::PeerList; @@ -21,7 +20,7 @@ impl RwLockStd { /// Panics if unable to get a lock. pub fn write( &self, - ) -> std::sync::RwLockWriteGuard<'_, std::collections::BTreeMap> { + ) -> std::sync::RwLockWriteGuard<'_, std::collections::BTreeMap> { self.torrents.write().expect("it should get lock") } } @@ -46,12 +45,14 @@ impl Repository for TorrentsRwLockStd where EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, _opt_persistent_torrent: Option) -> bool { + // todo: load persistent torrent data if provided + let mut db = self.get_torrents_mut(); let entry = db.entry(*info_hash).or_insert(EntrySingle::default()); - entry.upsert_peer(peer); + entry.upsert_peer(peer) } fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -63,15 +64,15 @@ where db.get(key).cloned() } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in self.get_torrents().values() { let stats = entry.get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -91,7 +92,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut torrents = self.get_torrents_mut(); for (info_hash, downloaded) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/rw_lock_std_mutex_std.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_std.rs similarity index 80% rename from packages/torrent-repository/src/repository/rw_lock_std_mutex_std.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_std.rs index 7d58b0b10..085256ff1 100644 --- a/packages/torrent-repository/src/repository/rw_lock_std_mutex_std.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_std.rs @@ -1,11 +1,10 @@ use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::Repository; use crate::entry::peer_list::PeerList; @@ -33,7 +32,9 @@ where EntryMutexStd: EntrySync, EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, _opt_persistent_torrent: Option) -> bool { + // todo: load persistent torrent data if provided + let maybe_entry = self.get_torrents().get(info_hash).cloned(); let entry = if let Some(entry) = maybe_entry { @@ -44,7 +45,7 @@ where entry.clone() }; - entry.upsert_peer(peer); + entry.upsert_peer(peer) } fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -58,15 +59,15 @@ where db.get(key).cloned() } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in self.get_torrents().values() { let stats = entry.lock().expect("it should get a lock").get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -86,7 +87,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut torrents = self.get_torrents_mut(); for (info_hash, completed) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/rw_lock_std_mutex_tokio.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_tokio.rs similarity index 82% rename from packages/torrent-repository/src/repository/rw_lock_std_mutex_tokio.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_tokio.rs index 90451ca9f..9fd451149 100644 --- a/packages/torrent-repository/src/repository/rw_lock_std_mutex_tokio.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_std_mutex_tokio.rs @@ -2,14 +2,13 @@ use std::iter::zip; use std::pin::Pin; use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use futures::future::join_all; use futures::{Future, FutureExt}; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::RepositoryAsync; use crate::entry::peer_list::PeerList; @@ -37,7 +36,14 @@ where EntryMutexTokio: EntryAsync, EntrySingle: Entry, { - async fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + async fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + _opt_persistent_torrent: Option, + ) -> bool { + // todo: load persistent torrent data if provided + let maybe_entry = self.get_torrents().get(info_hash).cloned(); let entry = if let Some(entry) = maybe_entry { @@ -48,7 +54,7 @@ where entry.clone() }; - entry.upsert_peer(peer).await; + entry.upsert_peer(peer).await } async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -79,23 +85,23 @@ where } } - async fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + async fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); let entries: Vec<_> = self.get_torrents().values().cloned().collect(); for entry in entries { let stats = entry.lock().await.get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics } - async fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + async fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut db = self.get_torrents_mut(); for (info_hash, completed) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/rw_lock_tokio.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio.rs similarity index 75% rename from packages/torrent-repository/src/repository/rw_lock_tokio.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio.rs index baaa01232..e85200aeb 100644 --- a/packages/torrent-repository/src/repository/rw_lock_tokio.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio.rs @@ -1,9 +1,8 @@ +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::RepositoryAsync; use crate::entry::peer_list::PeerList; @@ -19,10 +18,7 @@ impl RwLockTokio { pub fn write( &self, ) -> impl std::future::Future< - Output = tokio::sync::RwLockWriteGuard< - '_, - std::collections::BTreeMap, - >, + Output = tokio::sync::RwLockWriteGuard<'_, std::collections::BTreeMap>, > { self.torrents.write() } @@ -50,12 +46,19 @@ impl RepositoryAsync for TorrentsRwLockTokio where EntrySingle: Entry, { - async fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + async fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + _opt_persistent_torrent: Option, + ) -> bool { + // todo: load persistent torrent data if provided + let mut db = self.get_torrents_mut().await; let entry = db.entry(*info_hash).or_insert(EntrySingle::default()); - entry.upsert_peer(peer); + entry.upsert_peer(peer) } async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -81,21 +84,21 @@ where } } - async fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + async fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in self.get_torrents().await.values() { let stats = entry.get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics } - async fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + async fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut torrents = self.get_torrents_mut().await; for (info_hash, completed) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/rw_lock_tokio_mutex_std.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_std.rs similarity index 78% rename from packages/torrent-repository/src/repository/rw_lock_tokio_mutex_std.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_std.rs index 1887f70c7..8d6584713 100644 --- a/packages/torrent-repository/src/repository/rw_lock_tokio_mutex_std.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_std.rs @@ -1,11 +1,10 @@ use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::RepositoryAsync; use crate::entry::peer_list::PeerList; @@ -35,7 +34,14 @@ where EntryMutexStd: EntrySync, EntrySingle: Entry, { - async fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + async fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + _opt_persistent_torrent: Option, + ) -> bool { + // todo: load persistent torrent data if provided + let maybe_entry = self.get_torrents().await.get(info_hash).cloned(); let entry = if let Some(entry) = maybe_entry { @@ -46,7 +52,7 @@ where entry.clone() }; - entry.upsert_peer(peer); + entry.upsert_peer(peer) } async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -72,21 +78,21 @@ where } } - async fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + async fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in self.get_torrents().await.values() { let stats = entry.get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics } - async fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + async fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut torrents = self.get_torrents_mut().await; for (info_hash, completed) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/rw_lock_tokio_mutex_tokio.rs b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_tokio.rs similarity index 79% rename from packages/torrent-repository/src/repository/rw_lock_tokio_mutex_tokio.rs rename to packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_tokio.rs index 6c9c08a73..c8f499e03 100644 --- a/packages/torrent-repository/src/repository/rw_lock_tokio_mutex_tokio.rs +++ b/packages/torrent-repository-benchmarking/src/repository/rw_lock_tokio_mutex_tokio.rs @@ -1,11 +1,10 @@ use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::RepositoryAsync; use crate::entry::peer_list::PeerList; @@ -35,7 +34,14 @@ where EntryMutexTokio: EntryAsync, EntrySingle: Entry, { - async fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + async fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + _opt_persistent_torrent: Option, + ) -> bool { + // todo: load persistent torrent data if provided + let maybe_entry = self.get_torrents().await.get(info_hash).cloned(); let entry = if let Some(entry) = maybe_entry { @@ -46,7 +52,7 @@ where entry.clone() }; - entry.upsert_peer(peer).await; + entry.upsert_peer(peer).await } async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -75,21 +81,21 @@ where } } - async fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + async fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in self.get_torrents().await.values() { let stats = entry.get_swarm_metadata().await; - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics } - async fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + async fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { let mut db = self.get_torrents_mut().await; for (info_hash, completed) in persistent_torrents { diff --git a/packages/torrent-repository/src/repository/skip_map_mutex_std.rs b/packages/torrent-repository-benchmarking/src/repository/skip_map_mutex_std.rs similarity index 70% rename from packages/torrent-repository/src/repository/skip_map_mutex_std.rs rename to packages/torrent-repository-benchmarking/src/repository/skip_map_mutex_std.rs index dd0d9c1b1..0432b13d0 100644 --- a/packages/torrent-repository/src/repository/skip_map_mutex_std.rs +++ b/packages/torrent-repository-benchmarking/src/repository/skip_map_mutex_std.rs @@ -1,12 +1,11 @@ use std::sync::Arc; +use bittorrent_primitives::info_hash::InfoHash; use crossbeam_skiplist::SkipMap; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; use super::Repository; use crate::entry::peer_list::PeerList; @@ -23,9 +22,42 @@ where EntryMutexStd: EntrySync, EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { - let entry = self.torrents.get_or_insert(*info_hash, Arc::default()); - entry.value().upsert_peer(peer); + /// Upsert a peer into the swarm of a torrent. + /// + /// Optionally, it can also preset the number of downloads of the torrent + /// only if it's the first time the torrent is being inserted. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// * `peer` - The peer to upsert. + /// * `opt_persistent_torrent` - The optional persisted data about a torrent + /// (number of downloads for the torrent). + /// + /// # Returns + /// + /// Returns `true` if the number of downloads was increased because the peer + /// completed the download. + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, opt_persistent_torrent: Option) -> bool { + if let Some(existing_entry) = self.torrents.get(info_hash) { + existing_entry.value().upsert_peer(peer) + } else { + let new_entry = if let Some(number_of_downloads) = opt_persistent_torrent { + EntryMutexStd::new( + EntrySingle { + swarm: PeerList::default(), + downloaded: number_of_downloads, + } + .into(), + ) + } else { + EntryMutexStd::default() + }; + + let inserted_entry = self.torrents.get_or_insert(*info_hash, new_entry); + + inserted_entry.value().upsert_peer(peer) + } } fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -37,15 +69,15 @@ where maybe_entry.map(|entry| entry.value().clone()) } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in &self.torrents { let stats = entry.value().lock().expect("it should get a lock").get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -68,7 +100,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { for (info_hash, completed) in persistent_torrents { if self.torrents.contains_key(info_hash) { continue; @@ -114,9 +146,11 @@ where EntryRwLockParkingLot: EntrySync, EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, _opt_persistent_torrent: Option) -> bool { + // todo: load persistent torrent data if provided + let entry = self.torrents.get_or_insert(*info_hash, Arc::default()); - entry.value().upsert_peer(peer); + entry.value().upsert_peer(peer) } fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -128,15 +162,15 @@ where maybe_entry.map(|entry| entry.value().clone()) } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in &self.torrents { let stats = entry.value().read().get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -159,7 +193,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { for (info_hash, completed) in persistent_torrents { if self.torrents.contains_key(info_hash) { continue; @@ -205,9 +239,11 @@ where EntryMutexParkingLot: EntrySync, EntrySingle: Entry, { - fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer, _opt_persistent_torrent: Option) -> bool { + // todo: load persistent torrent data if provided + let entry = self.torrents.get_or_insert(*info_hash, Arc::default()); - entry.value().upsert_peer(peer); + entry.value().upsert_peer(peer) } fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { @@ -219,15 +255,15 @@ where maybe_entry.map(|entry| entry.value().clone()) } - fn get_metrics(&self) -> TorrentsMetrics { - let mut metrics = TorrentsMetrics::default(); + fn get_metrics(&self) -> AggregateActiveSwarmMetadata { + let mut metrics = AggregateActiveSwarmMetadata::default(); for entry in &self.torrents { let stats = entry.value().lock().get_swarm_metadata(); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); - metrics.incomplete += u64::from(stats.incomplete); - metrics.torrents += 1; + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_torrents += 1; } metrics @@ -250,7 +286,7 @@ where } } - fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { for (info_hash, completed) in persistent_torrents { if self.torrents.contains_key(info_hash) { continue; diff --git a/packages/torrent-repository/tests/common/mod.rs b/packages/torrent-repository-benchmarking/tests/common/mod.rs similarity index 100% rename from packages/torrent-repository/tests/common/mod.rs rename to packages/torrent-repository-benchmarking/tests/common/mod.rs diff --git a/packages/torrent-repository/tests/common/repo.rs b/packages/torrent-repository-benchmarking/tests/common/repo.rs similarity index 91% rename from packages/torrent-repository/tests/common/repo.rs rename to packages/torrent-repository-benchmarking/tests/common/repo.rs index f317d0d17..2987240ef 100644 --- a/packages/torrent-repository/tests/common/repo.rs +++ b/packages/torrent-repository-benchmarking/tests/common/repo.rs @@ -1,11 +1,10 @@ +use bittorrent_primitives::info_hash::InfoHash; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, PersistentTorrents}; -use torrust_tracker_torrent_repository::repository::{Repository as _, RepositoryAsync as _}; -use torrust_tracker_torrent_repository::{ +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; +use torrust_tracker_torrent_repository_benchmarking::repository::{Repository as _, RepositoryAsync as _}; +use torrust_tracker_torrent_repository_benchmarking::{ EntrySingle, TorrentsDashMapMutexStd, TorrentsRwLockStd, TorrentsRwLockStdMutexStd, TorrentsRwLockStdMutexTokio, TorrentsRwLockTokio, TorrentsRwLockTokioMutexStd, TorrentsRwLockTokioMutexTokio, TorrentsSkipMapMutexParkingLot, TorrentsSkipMapMutexStd, TorrentsSkipMapRwLockParkingLot, @@ -26,18 +25,23 @@ pub(crate) enum Repo { } impl Repo { - pub(crate) async fn upsert_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { + pub(crate) async fn upsert_peer( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + opt_persistent_torrent: Option, + ) -> bool { match self { - Repo::RwLockStd(repo) => repo.upsert_peer(info_hash, peer), - Repo::RwLockStdMutexStd(repo) => repo.upsert_peer(info_hash, peer), - Repo::RwLockStdMutexTokio(repo) => repo.upsert_peer(info_hash, peer).await, - Repo::RwLockTokio(repo) => repo.upsert_peer(info_hash, peer).await, - Repo::RwLockTokioMutexStd(repo) => repo.upsert_peer(info_hash, peer).await, - Repo::RwLockTokioMutexTokio(repo) => repo.upsert_peer(info_hash, peer).await, - Repo::SkipMapMutexStd(repo) => repo.upsert_peer(info_hash, peer), - Repo::SkipMapMutexParkingLot(repo) => repo.upsert_peer(info_hash, peer), - Repo::SkipMapRwLockParkingLot(repo) => repo.upsert_peer(info_hash, peer), - Repo::DashMapMutexStd(repo) => repo.upsert_peer(info_hash, peer), + Repo::RwLockStd(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), + Repo::RwLockStdMutexStd(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), + Repo::RwLockStdMutexTokio(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent).await, + Repo::RwLockTokio(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent).await, + Repo::RwLockTokioMutexStd(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent).await, + Repo::RwLockTokioMutexTokio(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent).await, + Repo::SkipMapMutexStd(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), + Repo::SkipMapMutexParkingLot(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), + Repo::SkipMapRwLockParkingLot(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), + Repo::DashMapMutexStd(repo) => repo.upsert_peer(info_hash, peer, opt_persistent_torrent), } } @@ -71,7 +75,7 @@ impl Repo { } } - pub(crate) async fn get_metrics(&self) -> TorrentsMetrics { + pub(crate) async fn get_metrics(&self) -> AggregateActiveSwarmMetadata { match self { Repo::RwLockStd(repo) => repo.get_metrics(), Repo::RwLockStdMutexStd(repo) => repo.get_metrics(), @@ -140,7 +144,7 @@ impl Repo { } } - pub(crate) async fn import_persistent(&self, persistent_torrents: &PersistentTorrents) { + pub(crate) async fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { match self { Repo::RwLockStd(repo) => repo.import_persistent(persistent_torrents), Repo::RwLockStdMutexStd(repo) => repo.import_persistent(persistent_torrents), @@ -232,7 +236,7 @@ impl Repo { Repo::DashMapMutexStd(repo) => { repo.torrents.insert(*info_hash, torrent.into()); } - }; + } self.get(info_hash).await } } diff --git a/packages/torrent-repository/tests/common/torrent.rs b/packages/torrent-repository-benchmarking/tests/common/torrent.rs similarity index 96% rename from packages/torrent-repository/tests/common/torrent.rs rename to packages/torrent-repository-benchmarking/tests/common/torrent.rs index 927f13169..02874f9fc 100644 --- a/packages/torrent-repository/tests/common/torrent.rs +++ b/packages/torrent-repository-benchmarking/tests/common/torrent.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use torrust_tracker_configuration::TrackerPolicy; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; -use torrust_tracker_torrent_repository::entry::{Entry as _, EntryAsync as _, EntrySync as _}; -use torrust_tracker_torrent_repository::{ +use torrust_tracker_torrent_repository_benchmarking::entry::{Entry as _, EntryAsync as _, EntrySync as _}; +use torrust_tracker_torrent_repository_benchmarking::{ EntryMutexParkingLot, EntryMutexStd, EntryMutexTokio, EntryRwLockParkingLot, EntrySingle, }; diff --git a/packages/torrent-repository-benchmarking/tests/common/torrent_peer_builder.rs b/packages/torrent-repository-benchmarking/tests/common/torrent_peer_builder.rs new file mode 100644 index 000000000..48aa981cd --- /dev/null +++ b/packages/torrent-repository-benchmarking/tests/common/torrent_peer_builder.rs @@ -0,0 +1,26 @@ +use torrust_tracker_primitives::peer::fixture::PeerBuilder; +use torrust_tracker_primitives::peer::{self}; + +/// A torrent seeder is a peer with 0 bytes left to download which +/// has not announced it has stopped +#[must_use] +pub fn a_completed_peer(id: i32) -> peer::Peer { + let peer_id = peer::Id::new(id); + PeerBuilder::default() + .with_bytes_left_to_download(0) + .with_event_completed() + .with_peer_id(&peer_id) + .into() +} + +/// A torrent leecher is a peer that is not a seeder. +/// Leecher: left > 0 OR event = Stopped +#[must_use] +pub fn a_started_peer(id: i32) -> peer::Peer { + let peer_id = peer::Id::new(id); + PeerBuilder::default() + .with_bytes_left_to_download(1) + .with_event_started() + .with_peer_id(&peer_id) + .into() +} diff --git a/packages/torrent-repository/tests/entry/mod.rs b/packages/torrent-repository-benchmarking/tests/entry/mod.rs similarity index 99% rename from packages/torrent-repository/tests/entry/mod.rs rename to packages/torrent-repository-benchmarking/tests/entry/mod.rs index 43d7f94da..5cbb3b19c 100644 --- a/packages/torrent-repository/tests/entry/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/entry/mod.rs @@ -9,7 +9,7 @@ use torrust_tracker_clock::clock::{self, Time as _}; use torrust_tracker_configuration::{TrackerPolicy, TORRENT_PEERS_LIMIT}; use torrust_tracker_primitives::peer; use torrust_tracker_primitives::peer::Peer; -use torrust_tracker_torrent_repository::{ +use torrust_tracker_torrent_repository_benchmarking::{ EntryMutexParkingLot, EntryMutexStd, EntryMutexTokio, EntryRwLockParkingLot, EntrySingle, }; @@ -368,7 +368,7 @@ async fn it_should_get_peers_excluding_the_client_socket( let peers = torrent.get_peers(None).await; let mut peer = **peers.first().expect("there should be a peer"); - let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8081); + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081); // for this test, we should not already use this socket. assert_ne!(peer.peer_addr, socket); diff --git a/packages/torrent-repository/tests/integration.rs b/packages/torrent-repository-benchmarking/tests/integration.rs similarity index 100% rename from packages/torrent-repository/tests/integration.rs rename to packages/torrent-repository-benchmarking/tests/integration.rs diff --git a/packages/torrent-repository/tests/repository/mod.rs b/packages/torrent-repository-benchmarking/tests/repository/mod.rs similarity index 88% rename from packages/torrent-repository/tests/repository/mod.rs rename to packages/torrent-repository-benchmarking/tests/repository/mod.rs index 05d538582..ec7e68bae 100644 --- a/packages/torrent-repository/tests/repository/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/repository/mod.rs @@ -2,18 +2,18 @@ use std::collections::{BTreeMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; +use bittorrent_primitives::info_hash::InfoHash; use rstest::{fixture, rstest}; use torrust_tracker_configuration::TrackerPolicy; -use torrust_tracker_primitives::info_hash::InfoHash; use torrust_tracker_primitives::pagination::Pagination; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::PersistentTorrents; -use torrust_tracker_torrent_repository::entry::Entry as _; -use torrust_tracker_torrent_repository::repository::dash_map_mutex_std::XacrimonDashMap; -use torrust_tracker_torrent_repository::repository::rw_lock_std::RwLockStd; -use torrust_tracker_torrent_repository::repository::rw_lock_tokio::RwLockTokio; -use torrust_tracker_torrent_repository::repository::skip_map_mutex_std::CrossbeamSkipList; -use torrust_tracker_torrent_repository::EntrySingle; +use torrust_tracker_primitives::NumberOfDownloadsBTreeMap; +use torrust_tracker_torrent_repository_benchmarking::entry::Entry as _; +use torrust_tracker_torrent_repository_benchmarking::repository::dash_map_mutex_std::XacrimonDashMap; +use torrust_tracker_torrent_repository_benchmarking::repository::rw_lock_std::RwLockStd; +use torrust_tracker_torrent_repository_benchmarking::repository::rw_lock_tokio::RwLockTokio; +use torrust_tracker_torrent_repository_benchmarking::repository::skip_map_mutex_std::CrossbeamSkipList; +use torrust_tracker_torrent_repository_benchmarking::EntrySingle; use crate::common::repo::Repo; use crate::common::torrent_peer_builder::{a_completed_peer, a_started_peer}; @@ -167,12 +167,12 @@ fn many_hashed_in_order() -> Entries { } #[fixture] -fn persistent_empty() -> PersistentTorrents { - PersistentTorrents::default() +fn persistent_empty() -> NumberOfDownloadsBTreeMap { + NumberOfDownloadsBTreeMap::default() } #[fixture] -fn persistent_single() -> PersistentTorrents { +fn persistent_single() -> NumberOfDownloadsBTreeMap { let hash = &mut DefaultHasher::default(); hash.write_u8(1); @@ -182,7 +182,7 @@ fn persistent_single() -> PersistentTorrents { } #[fixture] -fn persistent_three() -> PersistentTorrents { +fn persistent_three() -> NumberOfDownloadsBTreeMap { let hash = &mut DefaultHasher::default(); hash.write_u8(1); @@ -364,12 +364,10 @@ async fn it_should_get_paginated( } // it should return the only the second entry if both the limit and the offset are one. - Pagination { limit: 1, offset: 1 } => { - if info_hashes.len() > 1 { - let page = repo.get_paginated(Some(&paginated)).await; - assert_eq!(page.len(), 1); - assert_eq!(page[0].0, info_hashes[1]); - } + Pagination { limit: 1, offset: 1 } if info_hashes.len() > 1 => { + let page = repo.get_paginated(Some(&paginated)).await; + assert_eq!(page.len(), 1); + assert_eq!(page[0].0, info_hashes[1]); } // the other cases are not yet tested. _ => {} @@ -402,19 +400,19 @@ async fn it_should_get_metrics( repo: Repo, #[case] entries: Entries, ) { - use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; + use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; make(&repo, &entries).await; - let mut metrics = TorrentsMetrics::default(); + let mut metrics = AggregateActiveSwarmMetadata::default(); for (_, torrent) in entries { let stats = torrent.get_swarm_metadata(); - metrics.torrents += 1; - metrics.incomplete += u64::from(stats.incomplete); - metrics.complete += u64::from(stats.complete); - metrics.downloaded += u64::from(stats.downloaded); + metrics.total_torrents += 1; + metrics.total_incomplete += u64::from(stats.incomplete); + metrics.total_complete += u64::from(stats.complete); + metrics.total_downloaded += u64::from(stats.downloaded); } assert_eq!(repo.get_metrics().await, metrics); @@ -445,16 +443,18 @@ async fn it_should_import_persistent_torrents( )] repo: Repo, #[case] entries: Entries, - #[values(persistent_empty(), persistent_single(), persistent_three())] persistent_torrents: PersistentTorrents, + #[values(persistent_empty(), persistent_single(), persistent_three())] persistent_torrents: NumberOfDownloadsBTreeMap, ) { make(&repo, &entries).await; - let mut downloaded = repo.get_metrics().await.downloaded; - persistent_torrents.iter().for_each(|(_, d)| downloaded += u64::from(*d)); + let mut downloaded = repo.get_metrics().await.total_downloaded; + for d in persistent_torrents.values() { + downloaded += u64::from(*d); + } repo.import_persistent(&persistent_torrents).await; - assert_eq!(repo.get_metrics().await.downloaded, downloaded); + assert_eq!(repo.get_metrics().await.total_downloaded, downloaded); for (entry, _) in persistent_torrents { assert!(repo.get(&entry).await.is_some()); @@ -497,7 +497,7 @@ async fn it_should_remove_an_entry( assert_eq!(repo.remove(&info_hash).await, None); } - assert_eq!(repo.get_metrics().await.torrents, 0); + assert_eq!(repo.get_metrics().await.total_torrents, 0); } #[rstest] @@ -562,14 +562,14 @@ async fn it_should_remove_inactive_peers( // Insert the infohash and peer into the repository // and verify there is an extra torrent entry. { - repo.upsert_peer(&info_hash, &peer).await; - assert_eq!(repo.get_metrics().await.torrents, entries.len() as u64 + 1); + repo.upsert_peer(&info_hash, &peer, None).await; + assert_eq!(repo.get_metrics().await.total_torrents, entries.len() as u64 + 1); } // Insert the infohash and peer into the repository // and verify the swarm metadata was updated. { - repo.upsert_peer(&info_hash, &peer).await; + repo.upsert_peer(&info_hash, &peer, None).await; let stats = repo.get_swarm_metadata(&info_hash).await; assert_eq!( stats, diff --git a/packages/torrent-repository/tests/common/torrent_peer_builder.rs b/packages/torrent-repository/tests/common/torrent_peer_builder.rs deleted file mode 100644 index 33120180d..000000000 --- a/packages/torrent-repository/tests/common/torrent_peer_builder.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::net::SocketAddr; - -use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; -use torrust_tracker_clock::clock::Time; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; - -use crate::CurrentClock; - -#[derive(Debug, Default)] -struct TorrentPeerBuilder { - peer: peer::Peer, -} - -#[allow(dead_code)] -impl TorrentPeerBuilder { - #[must_use] - fn new() -> Self { - Self { - peer: peer::Peer { - updated: CurrentClock::now(), - ..Default::default() - }, - } - } - - #[must_use] - fn with_event_completed(mut self) -> Self { - self.peer.event = AnnounceEvent::Completed; - self - } - - #[must_use] - fn with_event_started(mut self) -> Self { - self.peer.event = AnnounceEvent::Started; - self - } - - #[must_use] - fn with_peer_address(mut self, peer_addr: SocketAddr) -> Self { - self.peer.peer_addr = peer_addr; - self - } - - #[must_use] - fn with_peer_id(mut self, peer_id: PeerId) -> Self { - self.peer.peer_id = peer_id; - self - } - - #[must_use] - fn with_number_of_bytes_left(mut self, left: i64) -> Self { - self.peer.left = NumberOfBytes::new(left); - self - } - - #[must_use] - fn updated_at(mut self, updated: DurationSinceUnixEpoch) -> Self { - self.peer.updated = updated; - self - } - - #[must_use] - fn into(self) -> peer::Peer { - self.peer - } -} - -/// A torrent seeder is a peer with 0 bytes left to download which -/// has not announced it has stopped -#[must_use] -pub fn a_completed_peer(id: i32) -> peer::Peer { - let peer_id = peer::Id::new(id); - TorrentPeerBuilder::new() - .with_number_of_bytes_left(0) - .with_event_completed() - .with_peer_id(*peer_id) - .into() -} - -/// A torrent leecher is a peer that is not a seeder. -/// Leecher: left > 0 OR event = Stopped -#[must_use] -pub fn a_started_peer(id: i32) -> peer::Peer { - let peer_id = peer::Id::new(id); - TorrentPeerBuilder::new() - .with_number_of_bytes_left(1) - .with_event_started() - .with_peer_id(*peer_id) - .into() -} diff --git a/packages/tracker-client/Cargo.toml b/packages/tracker-client/Cargo.toml new file mode 100644 index 000000000..ef5cccaa2 --- /dev/null +++ b/packages/tracker-client/Cargo.toml @@ -0,0 +1,37 @@ +[package] +description = "A library with the generic tracker clients." +keywords = ["bittorrent", "client", "tracker"] +license = "LGPL-3.0" +name = "bittorrent-tracker-client" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +hyper = "1" +percent-encoding = "2" +reqwest = { version = "0", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_bencode = "0" +serde_bytes = "0" +serde_repr = "0" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +tracing = "0" +zerocopy = "0.7" + +[package.metadata.cargo-machete] +ignored = ["serde_bytes"] diff --git a/packages/tracker-client/README.md b/packages/tracker-client/README.md new file mode 100644 index 000000000..ebd0c4bda --- /dev/null +++ b/packages/tracker-client/README.md @@ -0,0 +1,25 @@ +# BitTorrent Tracker Client + +A library to interact with BitTorrent trackers. + +> **Disclaimer**: This project is actively under development. We’re currently extracting and refining common types from the[Torrust Tracker](https://github.com/torrust/torrust-tracker) to make them available to the BitTorrent community in Rust. While these types are functional, they are not yet ready for use in production or third-party projects. + +## License + +**Copyright (c) 2024 The Torrust Developers.** + +This program is free software: you can redistribute it and/or modify it under the terms of the [GNU Lesser General Public License][LGPL_3_0] as published by the [Free Software Foundation][FSF], version 3. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Lesser General Public License][LGPL_3_0] for more details. + +You should have received a copy of the *GNU Lesser General Public License* along with this program. If not, see . + +Some files include explicit copyright notices and/or license notices. + +### Legacy Exception + +For prosperity, versions of Torrust BitTorrent Tracker Client that are older than five years are automatically granted the [MIT-0][MIT_0] license in addition to the existing [LGPL-3.0-only][LGPL_3_0] license. + +[LGPL_3_0]: ./LICENSE +[MIT_0]: ./docs/licenses/LICENSE-MIT_0 +[FSF]: https://www.fsf.org/ diff --git a/packages/tracker-client/docs/licenses/LICENSE-MIT_0 b/packages/tracker-client/docs/licenses/LICENSE-MIT_0 new file mode 100644 index 000000000..fc06cc4fe --- /dev/null +++ b/packages/tracker-client/docs/licenses/LICENSE-MIT_0 @@ -0,0 +1,14 @@ +MIT No Attribution + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/shared/bit_torrent/tracker/http/client/mod.rs b/packages/tracker-client/src/http/client/mod.rs similarity index 90% rename from src/shared/bit_torrent/tracker/http/client/mod.rs rename to packages/tracker-client/src/http/client/mod.rs index 4c70cd68b..50e979c79 100644 --- a/src/shared/bit_torrent/tracker/http/client/mod.rs +++ b/packages/tracker-client/src/http/client/mod.rs @@ -5,13 +5,13 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; +use derive_more::Display; use hyper::StatusCode; use requests::{announce, scrape}; use reqwest::{Response, Url}; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::core::auth::Key; - #[derive(Debug, Clone, Error)] pub enum Error { #[error("Failed to Build a Http Client: {err:?}")] @@ -23,8 +23,9 @@ pub enum Error { } /// HTTP Tracker Client +#[allow(clippy::struct_field_names)] pub struct Client { - client: reqwest::Client, + http_client: reqwest::Client, base_url: Url, key: Option, } @@ -49,7 +50,7 @@ impl Client { Ok(Self { base_url, - client, + http_client: client, key: None, }) } @@ -68,7 +69,7 @@ impl Client { Ok(Self { base_url, - client, + http_client: client, key: None, }) } @@ -84,7 +85,7 @@ impl Client { Ok(Self { base_url, - client, + http_client: client, key: Some(key), }) } @@ -159,7 +160,7 @@ impl Client { /// /// This method fails if there was an error while sending request. pub async fn get(&self, path: &str) -> Result { - self.client + self.http_client .get(self.build_url(path)) .send() .await @@ -170,7 +171,7 @@ impl Client { /// /// This method fails if there was an error while sending request. pub async fn get_with_header(&self, path: &str, key: &str, value: &str) -> Result { - self.client + self.http_client .get(self.build_url(path)) .header(key, value) .send() @@ -202,3 +203,19 @@ impl Client { self.base_url.to_string() } } + +/// A token used for authentication. +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Display, Hash)] +pub struct Key(String); + +impl Key { + #[must_use] + pub fn new(value: &str) -> Self { + Self(value.to_owned()) + } + + #[must_use] + pub fn value(&self) -> &str { + &self.0 + } +} diff --git a/src/shared/bit_torrent/tracker/http/client/requests/announce.rs b/packages/tracker-client/src/http/client/requests/announce.rs similarity index 95% rename from src/shared/bit_torrent/tracker/http/client/requests/announce.rs rename to packages/tracker-client/src/http/client/requests/announce.rs index 3c6b14222..87bdbad52 100644 --- a/src/shared/bit_torrent/tracker/http/client/requests/announce.rs +++ b/packages/tracker-client/src/http/client/requests/announce.rs @@ -3,10 +3,10 @@ use std::net::{IpAddr, Ipv4Addr}; use std::str::FromStr; use aquatic_udp_protocol::PeerId; +use bittorrent_primitives::info_hash::InfoHash; use serde_repr::Serialize_repr; -use torrust_tracker_primitives::info_hash::InfoHash; -use crate::shared::bit_torrent::tracker::http::{percent_encode_byte_array, ByteArray20}; +use crate::http::{percent_encode_byte_array, ByteArray20}; pub struct Query { pub info_hash: ByteArray20, @@ -53,16 +53,16 @@ pub type BaseTenASCII = u64; pub type PortNumber = u16; pub enum Event { - //Started, - //Stopped, + Started, + Stopped, Completed, } impl fmt::Display for Event { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - //Event::Started => write!(f, "started"), - //Event::Stopped => write!(f, "stopped"), + Event::Started => write!(f, "started"), + Event::Stopped => write!(f, "stopped"), Event::Completed => write!(f, "completed"), } } @@ -95,14 +95,14 @@ impl QueryBuilder { #[must_use] pub fn with_default_values() -> QueryBuilder { let default_announce_query = Query { - info_hash: InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap().0, // # DevSkim: ignore DS173237 + info_hash: InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap().0, // DevSkim: ignore DS173237 peer_addr: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 88)), downloaded: 0, uploaded: 0, peer_id: PeerId(*b"-qB00000000000000001").0, port: 17548, left: 0, - event: Some(Event::Completed), + event: Some(Event::Started), compact: Some(Compact::NotAccepted), }; Self { diff --git a/tests/servers/http/requests/mod.rs b/packages/tracker-client/src/http/client/requests/mod.rs similarity index 100% rename from tests/servers/http/requests/mod.rs rename to packages/tracker-client/src/http/client/requests/mod.rs diff --git a/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs b/packages/tracker-client/src/http/client/requests/scrape.rs similarity index 95% rename from src/shared/bit_torrent/tracker/http/client/requests/scrape.rs rename to packages/tracker-client/src/http/client/requests/scrape.rs index 4d12fc2d2..b25c3c4c7 100644 --- a/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs +++ b/packages/tracker-client/src/http/client/requests/scrape.rs @@ -2,9 +2,9 @@ use std::error::Error; use std::fmt::{self}; use std::str::FromStr; -use torrust_tracker_primitives::info_hash::InfoHash; +use bittorrent_primitives::info_hash::InfoHash; -use crate::shared::bit_torrent::tracker::http::{percent_encode_byte_array, ByteArray20}; +use crate::http::{percent_encode_byte_array, ByteArray20}; pub struct Query { pub info_hash: Vec, @@ -90,7 +90,7 @@ pub struct QueryBuilder { impl Default for QueryBuilder { fn default() -> Self { let default_scrape_query = Query { - info_hash: [InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap().0].to_vec(), // # DevSkim: ignore DS173237 + info_hash: [InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap().0].to_vec(), // DevSkim: ignore DS173237 }; Self { scrape_query: default_scrape_query, diff --git a/src/shared/bit_torrent/tracker/http/client/responses/announce.rs b/packages/tracker-client/src/http/client/responses/announce.rs similarity index 100% rename from src/shared/bit_torrent/tracker/http/client/responses/announce.rs rename to packages/tracker-client/src/http/client/responses/announce.rs diff --git a/tests/servers/http/responses/error.rs b/packages/tracker-client/src/http/client/responses/error.rs similarity index 100% rename from tests/servers/http/responses/error.rs rename to packages/tracker-client/src/http/client/responses/error.rs diff --git a/tests/servers/http/responses/mod.rs b/packages/tracker-client/src/http/client/responses/mod.rs similarity index 100% rename from tests/servers/http/responses/mod.rs rename to packages/tracker-client/src/http/client/responses/mod.rs diff --git a/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs b/packages/tracker-client/src/http/client/responses/scrape.rs similarity index 98% rename from src/shared/bit_torrent/tracker/http/client/responses/scrape.rs rename to packages/tracker-client/src/http/client/responses/scrape.rs index 25a2f0a81..6c0e8800a 100644 --- a/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs +++ b/packages/tracker-client/src/http/client/responses/scrape.rs @@ -6,7 +6,7 @@ use serde::ser::SerializeMap; use serde::{Deserialize, Serialize, Serializer}; use serde_bencode::value::Value; -use crate::shared::bit_torrent::tracker::http::{ByteArray20, InfoHash}; +use crate::http::{ByteArray20, InfoHash}; #[derive(Debug, PartialEq, Default, Deserialize)] pub struct Response { diff --git a/tests/servers/http/mod.rs b/packages/tracker-client/src/http/mod.rs similarity index 52% rename from tests/servers/http/mod.rs rename to packages/tracker-client/src/http/mod.rs index 65affc433..d8f8242e8 100644 --- a/tests/servers/http/mod.rs +++ b/packages/tracker-client/src/http/mod.rs @@ -1,17 +1,10 @@ -pub mod asserts; pub mod client; -pub mod environment; -pub mod requests; -pub mod responses; -pub mod v1; - -pub type Started = environment::Environment; use percent_encoding::NON_ALPHANUMERIC; -use torrust_tracker::servers::http::server; pub type ByteArray20 = [u8; 20]; +#[must_use] pub fn percent_encode_byte_array(bytes: &ByteArray20) -> String { percent_encoding::percent_encode(bytes, NON_ALPHANUMERIC).to_string() } @@ -19,13 +12,31 @@ pub fn percent_encode_byte_array(bytes: &ByteArray20) -> String { pub struct InfoHash(ByteArray20); impl InfoHash { + #[must_use] pub fn new(vec: &[u8]) -> Self { let mut byte_array_20: ByteArray20 = Default::default(); byte_array_20.clone_from_slice(vec); Self(byte_array_20) } + #[must_use] pub fn bytes(&self) -> ByteArray20 { self.0 } } + +#[cfg(test)] +mod tests { + use crate::http::percent_encode_byte_array; + + #[test] + fn it_should_encode_a_20_byte_array() { + assert_eq!( + percent_encode_byte_array(&[ + 0x3b, 0x24, 0x55, 0x04, 0xcf, 0x5f, 0x11, 0xbb, 0xdb, 0xe1, 0x20, 0x1c, 0xea, 0x6a, 0x6b, 0xf4, 0x5a, 0xee, 0x1b, + 0xc0, + ]), + "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0" + ); + } +} diff --git a/src/shared/bit_torrent/tracker/mod.rs b/packages/tracker-client/src/lib.rs similarity index 100% rename from src/shared/bit_torrent/tracker/mod.rs rename to packages/tracker-client/src/lib.rs diff --git a/src/shared/bit_torrent/tracker/udp/client.rs b/packages/tracker-client/src/udp/client.rs similarity index 96% rename from src/shared/bit_torrent/tracker/udp/client.rs rename to packages/tracker-client/src/udp/client.rs index edb8adc85..1c5ffd901 100644 --- a/src/shared/bit_torrent/tracker/udp/client.rs +++ b/packages/tracker-client/src/udp/client.rs @@ -8,10 +8,11 @@ use aquatic_udp_protocol::{ConnectRequest, Request, Response, TransactionId}; use tokio::net::UdpSocket; use tokio::time; use torrust_tracker_configuration::DEFAULT_TIMEOUT; +use torrust_tracker_primitives::service_binding::ServiceBinding; use zerocopy::network_endian::I32; use super::Error; -use crate::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; +use crate::udp::MAX_PACKET_SIZE; pub const UDP_CLIENT_LOG_TARGET: &str = "UDP CLIENT"; @@ -230,10 +231,12 @@ impl UdpTrackerClient { /// /// # Errors /// -pub async fn check(remote_addr: &SocketAddr) -> Result { +pub async fn check(service_binding: &ServiceBinding) -> Result { + let remote_addr = service_binding.bind_address(); + tracing::debug!("Checking Service (detail): {remote_addr:?}."); - match UdpTrackerClient::new(*remote_addr, DEFAULT_TIMEOUT).await { + match UdpTrackerClient::new(remote_addr, DEFAULT_TIMEOUT).await { Ok(client) => { let connect_request = ConnectRequest { transaction_id: TransactionId(I32::new(123)), @@ -243,7 +246,7 @@ pub async fn check(remote_addr: &SocketAddr) -> Result { match client.send(connect_request.into()).await { Ok(_) => (), Err(e) => tracing::debug!("Error: {e:?}."), - }; + } let process = move |response| { if matches!(response, Response::Connect(_connect_response)) { diff --git a/src/shared/bit_torrent/tracker/udp/mod.rs b/packages/tracker-client/src/udp/mod.rs similarity index 100% rename from src/shared/bit_torrent/tracker/udp/mod.rs rename to packages/tracker-client/src/udp/mod.rs diff --git a/packages/tracker-core/.gitignore b/packages/tracker-core/.gitignore new file mode 100644 index 000000000..c5cb1afac --- /dev/null +++ b/packages/tracker-core/.gitignore @@ -0,0 +1 @@ +.coverage \ No newline at end of file diff --git a/packages/tracker-core/Cargo.toml b/packages/tracker-core/Cargo.toml new file mode 100644 index 000000000..dfc83e58e --- /dev/null +++ b/packages/tracker-core/Cargo.toml @@ -0,0 +1,46 @@ +[package] +authors.workspace = true +description = "A library with the core functionality needed to implement a BitTorrent tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["api", "bittorrent", "core", "library", "tracker"] +license.workspace = true +name = "bittorrent-tracker-core" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +chrono = { version = "0", default-features = false, features = ["clock"] } +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +mockall = "0" +r2d2 = "0" +r2d2_mysql = "25" +r2d2_sqlite = { version = "0", features = ["bundled"] } +rand = "0" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +tracing = "0" + +[dev-dependencies] +local-ip-address = "0" +mockall = "0" +testcontainers = "0" +torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "../rest-tracker-api-client" } +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } +url = "2.5.4" diff --git a/packages/tracker-core/LICENSE b/packages/tracker-core/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/tracker-core/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/tracker-core/README.md b/packages/tracker-core/README.md new file mode 100644 index 000000000..f80243d29 --- /dev/null +++ b/packages/tracker-core/README.md @@ -0,0 +1,39 @@ +# BitTorrent Core Tracker library + +A library with the core functionality needed to implement a BitTorrent tracker. + +You usually don’t need to use this library directly. Instead, you should use the [Torrust Tracker](https://github.com/torrust/torrust-tracker). If you want to build your own tracker, you can use this library as the core functionality. In that case, you should add the delivery layer (HTTP or UDP) on top of this library. + +> **Disclaimer**: This library is actively under development. We’re currently extracting and refining common types from the[Torrust Tracker](https://github.com/torrust/torrust-tracker) to make them available to the BitTorrent community in Rust. While these types are functional, they are not yet ready for use in production or third-party projects. + +## Documentation + +[Crate documentation](https://docs.rs/bittorrent-tracker-core). + +## Testing + +Run tests including tests for MySQL driver: + +```console +TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test +``` + +> NOTE: MySQL driver requires docker to run. We don't run them by default because we don't want to run them when we build container images. The Torrust Tracker container build runs unit tests for all dependencies, including this library. + +Show coverage report: + +```console +TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo +stable llvm-cov +``` + +Export coverage report to `lcov` format: + +```console +TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo +stable llvm-cov --lcov --output-path=./.coverage/lcov.info +``` + +If you use Visual Studio Code, you can use the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=semasquare.vscode-coverage-gutters) extension to view the coverage lines. + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/migrations/README.md b/packages/tracker-core/migrations/README.md similarity index 100% rename from migrations/README.md rename to packages/tracker-core/migrations/README.md diff --git a/migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql b/packages/tracker-core/migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql similarity index 93% rename from migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql rename to packages/tracker-core/migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql index 407ae4dd1..ab160bd75 100644 --- a/migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql +++ b/packages/tracker-core/migrations/mysql/20240730183000_torrust_tracker_create_all_tables.sql @@ -4,6 +4,7 @@ CREATE TABLE info_hash VARCHAR(40) NOT NULL UNIQUE ); +# todo: rename to `torrent_metrics` CREATE TABLE IF NOT EXISTS torrents ( id integer PRIMARY KEY AUTO_INCREMENT, diff --git a/migrations/mysql/20240730183500_torrust_tracker_keys_valid_until_nullable.sql b/packages/tracker-core/migrations/mysql/20240730183500_torrust_tracker_keys_valid_until_nullable.sql similarity index 100% rename from migrations/mysql/20240730183500_torrust_tracker_keys_valid_until_nullable.sql rename to packages/tracker-core/migrations/mysql/20240730183500_torrust_tracker_keys_valid_until_nullable.sql diff --git a/packages/tracker-core/migrations/mysql/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql b/packages/tracker-core/migrations/mysql/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql new file mode 100644 index 000000000..36f940cc3 --- /dev/null +++ b/packages/tracker-core/migrations/mysql/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE + IF NOT EXISTS torrent_aggregate_metrics ( + id integer PRIMARY KEY AUTO_INCREMENT, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + ); \ No newline at end of file diff --git a/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql b/packages/tracker-core/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql similarity index 92% rename from migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql rename to packages/tracker-core/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql index bd451bf8b..c5bcad926 100644 --- a/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql +++ b/packages/tracker-core/migrations/sqlite/20240730183000_torrust_tracker_create_all_tables.sql @@ -4,6 +4,7 @@ CREATE TABLE info_hash TEXT NOT NULL UNIQUE ); +# todo: rename to `torrent_metrics` CREATE TABLE IF NOT EXISTS torrents ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/migrations/sqlite/20240730183500_torrust_tracker_keys_valid_until_nullable.sql b/packages/tracker-core/migrations/sqlite/20240730183500_torrust_tracker_keys_valid_until_nullable.sql similarity index 100% rename from migrations/sqlite/20240730183500_torrust_tracker_keys_valid_until_nullable.sql rename to packages/tracker-core/migrations/sqlite/20240730183500_torrust_tracker_keys_valid_until_nullable.sql diff --git a/packages/tracker-core/migrations/sqlite/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql b/packages/tracker-core/migrations/sqlite/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql new file mode 100644 index 000000000..34166903c --- /dev/null +++ b/packages/tracker-core/migrations/sqlite/20250527093000_torrust_tracker_new_torrent_aggregate_metrics_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE + IF NOT EXISTS torrent_aggregate_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric_name TEXT NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + ); \ No newline at end of file diff --git a/packages/tracker-core/src/announce_handler.rs b/packages/tracker-core/src/announce_handler.rs new file mode 100644 index 000000000..0b6bffd31 --- /dev/null +++ b/packages/tracker-core/src/announce_handler.rs @@ -0,0 +1,689 @@ +//! Announce handler. +//! +//! Handling `announce` requests is the most important task for a `BitTorrent` +//! tracker. +//! +//! A `BitTorrent` swarm is a network of peers that are all trying to download +//! the same torrent. When a peer wants to find other peers it announces itself +//! to the swarm via the tracker. The peer sends its data to the tracker so that +//! the tracker can add it to the swarm. The tracker responds to the peer with +//! the list of other peers in the swarm so that the peer can contact them to +//! start downloading pieces of the file from them. +//! +//! Once you have instantiated the `AnnounceHandler` you can `announce` a new [`peer::Peer`](torrust_tracker_primitives) with: +//! +//! ```rust,no_run +//! use std::net::SocketAddr; +//! use std::net::IpAddr; +//! use std::net::Ipv4Addr; +//! use std::str::FromStr; +//! +//! use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; +//! use torrust_tracker_primitives::DurationSinceUnixEpoch; +//! use torrust_tracker_primitives::peer; +//! use bittorrent_primitives::info_hash::InfoHash; +//! +//! let info_hash = InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(); +//! +//! let peer = peer::Peer { +//! peer_id: PeerId(*b"-qB00000000000000001"), +//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), +//! updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), +//! uploaded: NumberOfBytes::new(0), +//! downloaded: NumberOfBytes::new(0), +//! left: NumberOfBytes::new(0), +//! event: AnnounceEvent::Completed, +//! }; +//! +//! let peer_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); +//! ``` +//! +//! ```text +//! let announce_data = announce_handler.announce(&info_hash, &mut peer, &peer_ip).await; +//! ``` +//! +//! The handler returns the list of peers for the torrent with the infohash +//! `3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0`, filtering out the peer that is +//! making the `announce` request. +//! +//! > **NOTICE**: that the peer argument is mutable because the handler can +//! > change the peer IP if the peer is using a loopback IP. +//! +//! The `peer_ip` argument is the resolved peer ip. It's a common practice that +//! trackers ignore the peer ip in the `announce` request params, and resolve +//! the peer ip using the IP of the client making the request. As the tracker is +//! a domain service, the peer IP must be provided for the handler user, which +//! is usually a higher component with access the the request metadata, for +//! example, connection data, proxy headers, etcetera. +//! +//! The returned struct is: +//! +//! ```rust,no_run +//! use torrust_tracker_primitives::peer; +//! use torrust_tracker_configuration::AnnouncePolicy; +//! +//! pub struct AnnounceData { +//! pub peers: Vec, +//! pub swarm_stats: SwarmMetadata, +//! pub policy: AnnouncePolicy, // the tracker announce policy. +//! } +//! +//! pub struct SwarmMetadata { +//! pub completed: u32, // The number of peers that have ever completed downloading +//! pub seeders: u32, // The number of active peers that have completed downloading (seeders) +//! pub leechers: u32, // The number of active peers that have not completed downloading (leechers) +//! } +//! +//! // Core tracker configuration +//! pub struct AnnounceInterval { +//! // ... +//! pub interval: u32, // Interval in seconds that the client should wait between sending regular announce requests to the tracker +//! pub interval_min: u32, // Minimum announce interval. Clients must not reannounce more frequently than this +//! // ... +//! } +//! ``` +//! +//! ## Related BEPs: +//! +//! Refer to `BitTorrent` BEPs and other sites for more information about the `announce` request: +//! +//! - [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) +//! - [BEP 23. Tracker Returns Compact Peer Lists](https://www.bittorrent.org/beps/bep_0023.html) +//! - [Vuze docs](https://wiki.vuze.com/w/Announce) +use std::net::IpAddr; +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_configuration::{Core, TORRENT_PEERS_LIMIT}; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::{peer, NumberOfDownloads}; + +use super::torrent::repository::in_memory::InMemoryTorrentRepository; +use crate::databases; +use crate::error::AnnounceError; +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use crate::whitelist::authorization::WhitelistAuthorization; + +/// Handles `announce` requests from `BitTorrent` clients. +pub struct AnnounceHandler { + /// The tracker configuration. + config: Core, + + /// Service for authorizing access to whitelisted torrents. + whitelist_authorization: Arc, + + /// Repository for in-memory torrent data. + in_memory_torrent_repository: Arc, + + /// Repository for persistent torrent data (database). + db_downloads_metric_repository: Arc, +} + +impl AnnounceHandler { + /// Creates a new `AnnounceHandler`. + #[must_use] + pub fn new( + config: &Core, + whitelist_authorization: &Arc, + in_memory_torrent_repository: &Arc, + db_downloads_metric_repository: &Arc, + ) -> Self { + Self { + whitelist_authorization: whitelist_authorization.clone(), + config: config.clone(), + in_memory_torrent_repository: in_memory_torrent_repository.clone(), + db_downloads_metric_repository: db_downloads_metric_repository.clone(), + } + } + + /// Processes an announce request from a peer. + /// + /// BEP 03: [The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html). + /// + /// # Parameters + /// + /// - `info_hash`: The unique identifier of the torrent. + /// - `peer`: The peer announcing itself (may be updated if IP is adjusted). + /// - `remote_client_ip`: The IP address of the client making the request. + /// - `peers_wanted`: Specifies how many peers the client wants in the response. + /// + /// # Returns + /// + /// An `AnnounceData` struct containing the list of peers, swarm statistics, and tracker policy. + /// + /// # Errors + /// + /// Returns an error if the tracker is running in `listed` mode and the + /// torrent is not whitelisted. + pub async fn handle_announcement( + &self, + info_hash: &InfoHash, + peer: &mut peer::Peer, + remote_client_ip: &IpAddr, + peers_wanted: &PeersWanted, + ) -> Result { + self.whitelist_authorization.authorize(info_hash).await?; + + peer.change_ip(&assign_ip_address_to_peer(remote_client_ip, self.config.net.external_ip)); + + self.in_memory_torrent_repository + .handle_announcement(info_hash, peer, self.load_downloads_metric_if_needed(info_hash)?) + .await; + + Ok(self.build_announce_data(info_hash, peer, peers_wanted).await) + } + + /// Loads the number of downloads for a torrent if needed. + fn load_downloads_metric_if_needed( + &self, + info_hash: &InfoHash, + ) -> Result, databases::error::Error> { + if self.config.tracker_policy.persistent_torrent_completed_stat && !self.in_memory_torrent_repository.contains(info_hash) + { + Ok(self.db_downloads_metric_repository.load_torrent_downloads(info_hash)?) + } else { + Ok(None) + } + } + + /// Builds the announce data for the peer making the request. + async fn build_announce_data(&self, info_hash: &InfoHash, peer: &peer::Peer, peers_wanted: &PeersWanted) -> AnnounceData { + let peers = self + .in_memory_torrent_repository + .get_peers_for(info_hash, peer, peers_wanted.limit()) + .await; + + let swarm_metadata = self + .in_memory_torrent_repository + .get_swarm_metadata_or_default(info_hash) + .await; + + AnnounceData { + peers, + stats: swarm_metadata, + policy: self.config.announce_policy, + } + } +} + +/// Specifies how many peers a client wants in the announce response. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum PeersWanted { + /// Request as many peers as possible (default behavior). + #[default] + AsManyAsPossible, + + /// Request a specific number of peers. + Only { amount: usize }, +} + +impl PeersWanted { + /// Request a specific number of peers. + #[must_use] + pub fn only(limit: u32) -> Self { + limit.into() + } + + /// Returns the maximum number of peers allowed based on the request and tracker limit. + fn limit(&self) -> usize { + match self { + PeersWanted::AsManyAsPossible => TORRENT_PEERS_LIMIT, + PeersWanted::Only { amount } => *amount, + } + } +} + +impl From for PeersWanted { + fn from(value: i32) -> Self { + if value <= 0 { + return PeersWanted::AsManyAsPossible; + } + + // This conversion is safe because `value > 0` + let amount = usize::try_from(value).unwrap(); + + PeersWanted::Only { + amount: amount.min(TORRENT_PEERS_LIMIT), + } + } +} + +impl From for PeersWanted { + fn from(value: u32) -> Self { + if value == 0 { + return PeersWanted::AsManyAsPossible; + } + + let amount = value as usize; + + PeersWanted::Only { + amount: amount.min(TORRENT_PEERS_LIMIT), + } + } +} + +/// Assigns the correct IP address to a peer based on tracker settings. +/// +/// If the client IP is a loopback address and the tracker has an external IP +/// configured, the external IP will be assigned to the peer. +#[must_use] +fn assign_ip_address_to_peer(remote_client_ip: &IpAddr, tracker_external_ip: Option) -> IpAddr { + if let Some(host_ip) = tracker_external_ip.filter(|_| remote_client_ip.is_loopback()) { + host_ip + } else { + *remote_client_ip + } +} + +#[cfg(test)] +mod tests { + mod the_announce_handler { + + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + use std::sync::Arc; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + use torrust_tracker_test_helpers::configuration; + + use crate::announce_handler::AnnounceHandler; + use crate::scrape_handler::ScrapeHandler; + use crate::test_helpers::tests::initialize_handlers; + + fn public_tracker() -> (Arc, Arc) { + let config = configuration::ephemeral_public(); + initialize_handlers(&config) + } + + // The client peer IP + fn peer_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()) + } + + /// Sample peer when for tests that need more than one peer + fn sample_peer_1() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000001"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Completed, + } + } + + /// Sample peer when for tests that need more than one peer + fn sample_peer_2() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000002"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Completed, + } + } + + /// Sample peer when for tests that need more than two peer + fn sample_peer_3() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000003"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 3)), 8082), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Completed, + } + } + + mod for_all_tracker_config_modes { + + mod handling_an_announce_request { + + use std::sync::Arc; + + use crate::announce_handler::tests::the_announce_handler::{ + peer_ip, public_tracker, sample_peer_1, sample_peer_2, sample_peer_3, + }; + use crate::announce_handler::PeersWanted; + use crate::test_helpers::tests::{sample_info_hash, sample_peer}; + + mod should_assign_the_ip_to_the_peer { + + use std::net::{IpAddr, Ipv4Addr}; + + use crate::announce_handler::assign_ip_address_to_peer; + + #[test] + fn using_the_source_ip_instead_of_the_ip_in_the_announce_request() { + let remote_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, None); + + assert_eq!(peer_ip, remote_ip); + } + + mod and_when_the_client_ip_is_a_ipv4_loopback_ip { + + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + use crate::announce_handler::assign_ip_address_to_peer; + + #[test] + fn it_should_use_the_loopback_ip_if_the_tracker_does_not_have_the_external_ip_configuration() { + let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, None); + + assert_eq!(peer_ip, remote_ip); + } + + #[test] + fn it_should_use_the_external_tracker_ip_in_tracker_configuration_if_it_is_defined() { + let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + + let tracker_external_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); + + assert_eq!(peer_ip, tracker_external_ip); + } + + #[test] + fn it_should_use_the_external_ip_in_the_tracker_configuration_if_it_is_defined_even_if_the_external_ip_is_an_ipv6_ip( + ) { + let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + + let tracker_external_ip = + IpAddr::V6(Ipv6Addr::from_str("2345:0425:2CA1:0000:0000:0567:5673:23b5").unwrap()); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); + + assert_eq!(peer_ip, tracker_external_ip); + } + } + + mod and_when_client_ip_is_a_ipv6_loopback_ip { + + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + use crate::announce_handler::assign_ip_address_to_peer; + + #[test] + fn it_should_use_the_loopback_ip_if_the_tracker_does_not_have_the_external_ip_configuration() { + let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, None); + + assert_eq!(peer_ip, remote_ip); + } + + #[test] + fn it_should_use_the_external_ip_in_tracker_configuration_if_it_is_defined() { + let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); + + let tracker_external_ip = + IpAddr::V6(Ipv6Addr::from_str("2345:0425:2CA1:0000:0000:0567:5673:23b5").unwrap()); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); + + assert_eq!(peer_ip, tracker_external_ip); + } + + #[test] + fn it_should_use_the_external_ip_in_the_tracker_configuration_if_it_is_defined_even_if_the_external_ip_is_an_ipv4_ip( + ) { + let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); + + let tracker_external_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); + + let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); + + assert_eq!(peer_ip, tracker_external_ip); + } + } + } + + #[tokio::test] + async fn it_should_return_the_announce_data_with_an_empty_peer_list_when_it_is_the_first_announced_peer() { + let (announce_handler, _scrape_handler) = public_tracker(); + + let mut peer = sample_peer(); + + let announce_data = announce_handler + .handle_announcement(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + assert_eq!(announce_data.peers, vec![]); + } + + #[tokio::test] + async fn it_should_return_the_announce_data_with_the_previously_announced_peers() { + let (announce_handler, _scrape_handler) = public_tracker(); + + let mut previously_announced_peer = sample_peer_1(); + announce_handler + .handle_announcement( + &sample_info_hash(), + &mut previously_announced_peer, + &peer_ip(), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + let mut peer = sample_peer_2(); + let announce_data = announce_handler + .handle_announcement(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + assert_eq!(announce_data.peers, vec![Arc::new(previously_announced_peer)]); + } + + #[tokio::test] + async fn it_should_allow_peers_to_get_only_a_subset_of_the_peers_in_the_swarm() { + let (announce_handler, _scrape_handler) = public_tracker(); + + let mut previously_announced_peer_1 = sample_peer_1(); + announce_handler + .handle_announcement( + &sample_info_hash(), + &mut previously_announced_peer_1, + &peer_ip(), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + let mut previously_announced_peer_2 = sample_peer_2(); + announce_handler + .handle_announcement( + &sample_info_hash(), + &mut previously_announced_peer_2, + &peer_ip(), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + let mut peer = sample_peer_3(); + let announce_data = announce_handler + .handle_announcement(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::only(1)) + .await + .unwrap(); + + // It should return only one peer. There is no guarantee on + // which peer will be returned. + assert!( + announce_data.peers == vec![Arc::new(previously_announced_peer_1)] + || announce_data.peers == vec![Arc::new(previously_announced_peer_2)] + ); + } + + mod it_should_update_the_swarm_stats_for_the_torrent { + + use crate::announce_handler::tests::the_announce_handler::{peer_ip, public_tracker}; + use crate::announce_handler::PeersWanted; + use crate::test_helpers::tests::{completed_peer, leecher, sample_info_hash, seeder, started_peer}; + + #[tokio::test] + async fn when_the_peer_is_a_seeder() { + let (announce_handler, _scrape_handler) = public_tracker(); + + let mut peer = seeder(); + + let announce_data = announce_handler + .handle_announcement(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + assert_eq!(announce_data.stats.complete, 1); + } + + #[tokio::test] + async fn when_the_peer_is_a_leecher() { + let (announce_handler, _scrape_handler) = public_tracker(); + + let mut peer = leecher(); + + let announce_data = announce_handler + .handle_announcement(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + assert_eq!(announce_data.stats.incomplete, 1); + } + + #[tokio::test] + async fn when_a_previously_announced_started_peer_has_completed_downloading() { + let (announce_handler, _scrape_handler) = public_tracker(); + + // We have to announce with "started" event because peer does not count if peer was not previously known + let mut started_peer = started_peer(); + announce_handler + .handle_announcement( + &sample_info_hash(), + &mut started_peer, + &peer_ip(), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + let mut completed_peer = completed_peer(); + let announce_data = announce_handler + .handle_announcement( + &sample_info_hash(), + &mut completed_peer, + &peer_ip(), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + assert_eq!(announce_data.stats.downloaded, 1); + } + } + } + } + + mod should_allow_the_client_peers_to_specified_the_number_of_peers_wanted { + + use torrust_tracker_configuration::TORRENT_PEERS_LIMIT; + + use crate::announce_handler::PeersWanted; + + #[test] + fn it_should_return_the_maximin_number_of_peers_by_default() { + let peers_wanted = PeersWanted::default(); + + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + } + + #[test] + fn it_should_return_74_at_the_most_if_the_client_wants_them_all() { + let peers_wanted = PeersWanted::AsManyAsPossible; + + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + } + + #[test] + fn it_should_allow_limiting_the_peer_list() { + let peers_wanted = PeersWanted::only(10); + + assert_eq!(peers_wanted.limit(), 10); + } + + fn maximum_as_u32() -> u32 { + u32::try_from(TORRENT_PEERS_LIMIT).unwrap() + } + + fn maximum_as_i32() -> i32 { + i32::try_from(TORRENT_PEERS_LIMIT).unwrap() + } + + #[test] + fn it_should_return_the_maximum_when_wanting_more_than_the_maximum() { + let peers_wanted = PeersWanted::only(maximum_as_u32() + 1); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + } + + #[test] + fn it_should_return_the_maximum_when_wanting_only_zero() { + let peers_wanted = PeersWanted::only(0); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + } + + #[test] + fn it_should_convert_the_peers_wanted_number_from_i32() { + // Negative. It should return the maximum + let peers_wanted: PeersWanted = (-1i32).into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // Zero. It should return the maximum + let peers_wanted: PeersWanted = 0i32.into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // Greater than the maximum. It should return the maximum + let peers_wanted: PeersWanted = (maximum_as_i32() + 1).into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // The maximum + let peers_wanted: PeersWanted = (maximum_as_i32()).into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // Smaller than the maximum + let peers_wanted: PeersWanted = (maximum_as_i32() - 1).into(); + assert_eq!(i32::try_from(peers_wanted.limit()).unwrap(), maximum_as_i32() - 1); + } + + #[test] + fn it_should_convert_the_peers_wanted_number_from_u32() { + // Zero. It should return the maximum + let peers_wanted: PeersWanted = 0u32.into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // Greater than the maximum. It should return the maximum + let peers_wanted: PeersWanted = (maximum_as_u32() + 1).into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // The maximum + let peers_wanted: PeersWanted = (maximum_as_u32()).into(); + assert_eq!(peers_wanted.limit(), TORRENT_PEERS_LIMIT); + + // Smaller than the maximum + let peers_wanted: PeersWanted = (maximum_as_u32() - 1).into(); + assert_eq!(i32::try_from(peers_wanted.limit()).unwrap(), maximum_as_i32() - 1); + } + } + } +} diff --git a/packages/tracker-core/src/authentication/handler.rs b/packages/tracker-core/src/authentication/handler.rs new file mode 100644 index 000000000..178895b8d --- /dev/null +++ b/packages/tracker-core/src/authentication/handler.rs @@ -0,0 +1,684 @@ +//! This module implements the `KeysHandler` service +//! +//! It's responsible for managing authentication keys for the `BitTorrent` tracker. +//! +//! The service handles both persistent and in-memory storage of peer keys, and +//! supports adding new keys (either pre-generated or randomly created), +//! removing keys, and loading keys from the database into memory. Keys can be +//! either permanent or expire after a configurable duration per key. +use std::sync::Arc; +use std::time::Duration; + +use torrust_tracker_clock::clock::Time; +use torrust_tracker_located_error::Located; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::key::repository::in_memory::InMemoryKeyRepository; +use super::key::repository::persisted::DatabaseKeyRepository; +use super::{key, CurrentClock, Key, PeerKey}; +use crate::databases; +use crate::error::PeerKeyError; + +/// Contains the information needed to add a new tracker key. +/// +/// A new key can either be a pre-generated key provided by the user or can be +/// randomly generated by the application. Additionally, the key may be set to +/// expire after a certain number of seconds, or be permanent (if no expiration +/// is specified). +#[derive(Debug)] +pub struct AddKeyRequest { + /// The pre-generated key as a string. If `None` the service will generate a + /// random key. + pub opt_key: Option, + + /// The duration (in seconds) for which the key is valid. Use `None` for + /// permanent keys. + pub opt_seconds_valid: Option, +} + +/// The `KeysHandler` service manages the creation, addition, removal, and loading +/// of authentication keys for the tracker. +/// +/// It uses both a persistent (database) repository and an in-memory repository +/// to manage keys. +pub struct KeysHandler { + /// The database repository for storing authentication keys persistently. + db_key_repository: Arc, + + /// The in-memory repository for caching authentication keys. + in_memory_key_repository: Arc, +} + +impl KeysHandler { + /// Creates a new instance of the `KeysHandler` service. + /// + /// # Parameters + /// + /// - `db_key_repository`: A shared reference to the database key repository. + /// - `in_memory_key_repository`: A shared reference to the in-memory key + /// repository. + #[must_use] + pub fn new(db_key_repository: &Arc, in_memory_key_repository: &Arc) -> Self { + Self { + db_key_repository: db_key_repository.clone(), + in_memory_key_repository: in_memory_key_repository.clone(), + } + } + + /// Adds a new peer key to the tracker. + /// + /// The key may be pre-generated or generated on-the-fly. + /// + /// Depending on whether an expiration duration is specified, the key will + /// be either expiring or permanent. + /// + /// # Parameters + /// + /// - `add_key_req`: The request containing options for key creation. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - The provided key duration exceeds the maximum allowed value. + /// - The provided pre-generated key is invalid. + /// - There is an error persisting the key in the database. + pub async fn add_peer_key(&self, add_key_req: AddKeyRequest) -> Result { + if let Some(pre_existing_key) = add_key_req.opt_key { + // Pre-generated key + + if let Some(seconds_valid) = add_key_req.opt_seconds_valid { + // Expiring key + + let Some(valid_until) = CurrentClock::now_add(&Duration::from_secs(seconds_valid)) else { + return Err(PeerKeyError::DurationOverflow { seconds_valid }); + }; + + let key = pre_existing_key.parse::(); + + match key { + Ok(key) => match self.add_expiring_peer_key(key, Some(valid_until)).await { + Ok(auth_key) => Ok(auth_key), + Err(err) => Err(PeerKeyError::DatabaseError { + source: Located(err).into(), + }), + }, + Err(err) => Err(PeerKeyError::InvalidKey { + key: pre_existing_key, + source: Located(err).into(), + }), + } + } else { + // Permanent key + + let key = pre_existing_key.parse::(); + + match key { + Ok(key) => match self.add_permanent_peer_key(key).await { + Ok(auth_key) => Ok(auth_key), + Err(err) => Err(PeerKeyError::DatabaseError { + source: Located(err).into(), + }), + }, + Err(err) => Err(PeerKeyError::InvalidKey { + key: pre_existing_key, + source: Located(err).into(), + }), + } + } + } else { + // New randomly generate key + + if let Some(seconds_valid) = add_key_req.opt_seconds_valid { + // Expiring key + + match self + .generate_expiring_peer_key(Some(Duration::from_secs(seconds_valid))) + .await + { + Ok(auth_key) => Ok(auth_key), + Err(err) => Err(PeerKeyError::DatabaseError { + source: Located(err).into(), + }), + } + } else { + // Permanent key + + match self.generate_permanent_peer_key().await { + Ok(auth_key) => Ok(auth_key), + Err(err) => Err(PeerKeyError::DatabaseError { + source: Located(err).into(), + }), + } + } + } + } + + /// Generates a new permanent authentication key. + /// + /// Permanent keys do not expire. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if the key cannot be persisted in + /// the database. + pub(crate) async fn generate_permanent_peer_key(&self) -> Result { + self.generate_expiring_peer_key(None).await + } + + /// Generates a new authentication key with an optional expiration lifetime. + /// + /// If a `lifetime` is provided, the generated key will expire after that + /// duration. The new key is stored both in the database and in memory. + /// + /// # Parameters + /// + /// - `lifetime`: An optional duration specifying how long the key is valid. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if there is an issue adding the key + /// to the database. + pub async fn generate_expiring_peer_key(&self, lifetime: Option) -> Result { + let peer_key = key::generate_key(lifetime); + + self.db_key_repository.add(&peer_key)?; + + self.in_memory_key_repository.insert(&peer_key).await; + + Ok(peer_key) + } + + /// Adds a pre-generated permanent authentication key. + /// + /// Internally, this calls `add_expiring_peer_key` with no expiration. + /// + /// # Parameters + /// + /// - `key`: The pre-generated key. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if there is an issue persisting the + /// key. + pub(crate) async fn add_permanent_peer_key(&self, key: Key) -> Result { + self.add_expiring_peer_key(key, None).await + } + + /// Adds a pre-generated authentication key with an optional expiration. + /// + /// The key is stored in both the database and the in-memory repository. + /// + /// # Parameters + /// + /// - `key`: The pre-generated key. + /// - `valid_until`: An optional timestamp (as a duration since the Unix + /// epoch) after which the key expires. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if there is an issue adding the key + /// to the database. + pub(crate) async fn add_expiring_peer_key( + &self, + key: Key, + valid_until: Option, + ) -> Result { + let peer_key = PeerKey { key, valid_until }; + + // code-review: should we return a friendly error instead of the DB + // constrain error when the key already exist? For now, it's returning + // the specif error for each DB driver when a UNIQUE constrain fails. + self.db_key_repository.add(&peer_key)?; + + self.in_memory_key_repository.insert(&peer_key).await; + + Ok(peer_key) + } + + /// Removes an authentication key. + /// + /// The key is removed from both the database and the in-memory repository. + /// + /// # Parameters + /// + /// - `key`: A reference to the key to be removed. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if the key cannot be removed from + /// the database. + pub async fn remove_peer_key(&self, key: &Key) -> Result<(), databases::error::Error> { + self.db_key_repository.remove(key)?; + + self.remove_in_memory_auth_key(key).await; + + Ok(()) + } + + /// Removes an authentication key from the in-memory repository. + /// + /// This function does not interact with the database. + /// + /// # Parameters + /// + /// - `key`: A reference to the key to be removed. + pub(crate) async fn remove_in_memory_auth_key(&self, key: &Key) { + self.in_memory_key_repository.remove(key).await; + } + + /// Loads all authentication keys from the database into the in-memory + /// repository. + /// + /// This is useful during tracker startup to ensure that all persisted keys + /// are available in memory. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if there is an issue loading the keys from the database. + pub async fn load_peer_keys_from_database(&self) -> Result<(), databases::error::Error> { + let keys_from_database = self.db_key_repository.load_keys()?; + + self.in_memory_key_repository.reset_with(keys_from_database).await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + mod the_keys_handler_when_the_tracker_is_configured_as_private { + + use std::sync::Arc; + + use torrust_tracker_configuration::Configuration; + use torrust_tracker_test_helpers::configuration; + + use crate::authentication::handler::KeysHandler; + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::key::repository::persisted::DatabaseKeyRepository; + use crate::databases::setup::initialize_database; + use crate::databases::Database; + + fn instantiate_keys_handler() -> KeysHandler { + let config = configuration::ephemeral_private(); + + instantiate_keys_handler_with_configuration(&config) + } + + fn instantiate_keys_handler_with_database(database: &Arc>) -> KeysHandler { + let db_key_repository = Arc::new(DatabaseKeyRepository::new(database)); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + KeysHandler::new(&db_key_repository, &in_memory_key_repository) + } + + fn instantiate_keys_handler_with_configuration(config: &Configuration) -> KeysHandler { + // todo: pass only Core configuration + + let database = initialize_database(&config.core); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + KeysHandler::new(&db_key_repository, &in_memory_key_repository) + } + + mod handling_expiring_peer_keys { + + use std::time::Duration; + + use torrust_tracker_clock::clock::Time; + + use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::instantiate_keys_handler; + use crate::CurrentClock; + + #[tokio::test] + async fn it_should_generate_the_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler + .generate_expiring_peer_key(Some(Duration::from_secs(100))) + .await + .unwrap(); + + assert_eq!( + peer_key.valid_until, + Some(CurrentClock::now_add(&Duration::from_secs(100)).unwrap()) + ); + } + + mod randomly_generated { + use std::panic::Location; + use std::sync::Arc; + use std::time::Duration; + + use mockall::predicate::function; + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + + use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ + instantiate_keys_handler, instantiate_keys_handler_with_database, + }; + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::PeerKey; + use crate::databases::driver::Driver; + use crate::databases::{self, Database, MockDatabase}; + use crate::error::PeerKeyError; + use crate::CurrentClock; + + #[tokio::test] + async fn it_should_add_a_randomly_generated_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: None, + opt_seconds_valid: Some(100), + }) + .await + .unwrap(); + + assert_eq!( + peer_key.valid_until, + Some(CurrentClock::now_add(&Duration::from_secs(100)).unwrap()) + ); + } + + #[tokio::test] + async fn it_should_fail_adding_a_randomly_generated_key_when_there_is_a_database_error() { + clock::Stopped::local_set(&Duration::from_secs(0)); + + // The key should be valid the next 60 seconds. + let expected_valid_until = clock::Stopped::now_add(&Duration::from_secs(60)).unwrap(); + + let mut database_mock = MockDatabase::default(); + database_mock + .expect_add_key_to_keys() + .with(function(move |peer_key: &PeerKey| { + peer_key.valid_until == Some(expected_valid_until) + })) + .times(1) + .returning(|_peer_key| { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) + }); + let database_mock: Arc> = Arc::new(Box::new(database_mock)); + + let keys_handler = instantiate_keys_handler_with_database(&database_mock); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: None, + opt_seconds_valid: Some(60), // The key is valid for 60 seconds. + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::DatabaseError { .. })); + } + } + + mod pre_generated { + use std::panic::Location; + use std::sync::Arc; + use std::time::Duration; + + use mockall::predicate; + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self, Time}; + + use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ + instantiate_keys_handler, instantiate_keys_handler_with_database, + }; + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::{Key, PeerKey}; + use crate::databases::driver::Driver; + use crate::databases::{self, Database, MockDatabase}; + use crate::error::PeerKeyError; + use crate::CurrentClock; + + #[tokio::test] + async fn it_should_add_a_pre_generated_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: Some(100), + }) + .await + .unwrap(); + + assert_eq!( + peer_key, + PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(CurrentClock::now_add(&Duration::from_secs(100)).unwrap()), + } + ); + } + + #[tokio::test] + async fn it_should_fail_adding_a_pre_generated_key_when_the_key_duration_exceeds_the_maximum_duration() { + let keys_handler = instantiate_keys_handler(); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: Some(u64::MAX), + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::DurationOverflow { .. })); + } + + #[tokio::test] + async fn it_should_fail_adding_a_pre_generated_key_when_the_key_is_invalid() { + let keys_handler = instantiate_keys_handler(); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some("INVALID KEY".to_string()), + opt_seconds_valid: Some(100), + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::InvalidKey { .. })); + } + + #[tokio::test] + async fn it_should_fail_adding_a_pre_generated_key_when_there_is_a_database_error() { + clock::Stopped::local_set(&Duration::from_secs(0)); + + // The key should be valid the next 60 seconds. + let expected_valid_until = clock::Stopped::now_add(&Duration::from_secs(60)).unwrap(); + let expected_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(expected_valid_until), + }; + + let mut database_mock = MockDatabase::default(); + database_mock + .expect_add_key_to_keys() + .with(predicate::eq(expected_peer_key)) + .times(1) + .returning(|_peer_key| { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) + }); + let database_mock: Arc> = Arc::new(Box::new(database_mock)); + + let keys_handler = instantiate_keys_handler_with_database(&database_mock); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: Some(60), // The key is valid for 60 seconds. + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::DatabaseError { .. })); + } + } + } + + mod handling_permanent_peer_keys { + + mod randomly_generated { + + use std::panic::Location; + use std::sync::Arc; + + use mockall::predicate::function; + + use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ + instantiate_keys_handler, instantiate_keys_handler_with_database, + }; + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::PeerKey; + use crate::databases::driver::Driver; + use crate::databases::{self, Database, MockDatabase}; + use crate::error::PeerKeyError; + + #[tokio::test] + async fn it_should_generate_the_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler.generate_permanent_peer_key().await.unwrap(); + + assert_eq!(peer_key.valid_until, None); + } + + #[tokio::test] + async fn it_should_add_a_randomly_generated_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: None, + opt_seconds_valid: None, + }) + .await + .unwrap(); + + assert_eq!(peer_key.valid_until, None); + } + + #[tokio::test] + async fn it_should_fail_adding_a_randomly_generated_key_when_there_is_a_database_error() { + let mut database_mock = MockDatabase::default(); + database_mock + .expect_add_key_to_keys() + .with(function(move |peer_key: &PeerKey| peer_key.valid_until.is_none())) + .times(1) + .returning(|_peer_key| { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) + }); + let database_mock: Arc> = Arc::new(Box::new(database_mock)); + + let keys_handler = instantiate_keys_handler_with_database(&database_mock); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: None, + opt_seconds_valid: None, + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::DatabaseError { .. })); + } + } + + mod pre_generated_keys { + + use std::panic::Location; + use std::sync::Arc; + + use mockall::predicate; + + use crate::authentication::handler::tests::the_keys_handler_when_the_tracker_is_configured_as_private::{ + instantiate_keys_handler, instantiate_keys_handler_with_database, + }; + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::{Key, PeerKey}; + use crate::databases::driver::Driver; + use crate::databases::{self, Database, MockDatabase}; + use crate::error::PeerKeyError; + + #[tokio::test] + async fn it_should_add_a_pre_generated_key() { + let keys_handler = instantiate_keys_handler(); + + let peer_key = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: None, + }) + .await + .unwrap(); + + assert_eq!( + peer_key, + PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: None, + } + ); + } + + #[tokio::test] + async fn it_should_fail_adding_a_pre_generated_key_when_the_key_is_invalid() { + let keys_handler = instantiate_keys_handler(); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some("INVALID KEY".to_string()), + opt_seconds_valid: None, + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::InvalidKey { .. })); + } + + #[tokio::test] + async fn it_should_fail_adding_a_pre_generated_key_when_there_is_a_database_error() { + let expected_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: None, + }; + + let mut database_mock = MockDatabase::default(); + database_mock + .expect_add_key_to_keys() + .with(predicate::eq(expected_peer_key)) + .times(1) + .returning(|_peer_key| { + Err(databases::error::Error::InsertFailed { + location: Location::caller(), + driver: Driver::Sqlite3, + }) + }); + let database_mock: Arc> = Arc::new(Box::new(database_mock)); + + let keys_handler = instantiate_keys_handler_with_database(&database_mock); + + let result = keys_handler + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: None, + }) + .await; + + assert!(matches!(result.unwrap_err(), PeerKeyError::DatabaseError { .. })); + } + } + } + } +} diff --git a/packages/tracker-core/src/authentication/key/mod.rs b/packages/tracker-core/src/authentication/key/mod.rs new file mode 100644 index 000000000..44bbd0688 --- /dev/null +++ b/packages/tracker-core/src/authentication/key/mod.rs @@ -0,0 +1,306 @@ +//! Tracker authentication services and types. +//! +//! This module provides functions and data structures for handling tracker keys. +//! Tracker keys are tokens used to authenticate tracker clients when the +//! tracker is running in `private` mode. +//! +//! Authentication keys are used exclusively by HTTP trackers. Every key has an +//! expiration time, meaning that it is only valid for a predetermined period. +//! Once the expiration time is reached, an expiring key will be rejected. +//! +//! The primary key structure is [`PeerKey`], which couples a randomly generated +//! [`Key`] (a 32-character alphanumeric string) with an optional expiration +//! timestamp. +//! +//! # Examples +//! +//! Generating a new key valid for `9999` seconds: +//! +//! ```rust +//! use bittorrent_tracker_core::authentication; +//! use std::time::Duration; +//! +//! let expiring_key = authentication::key::generate_key(Some(Duration::new(9999, 0))); +//! +//! // Later, verify that the key is still valid. +//! assert!(authentication::key::verify_key_expiration(&expiring_key).is_ok()); +//! ``` +//! +//! The core key types are defined as follows: +//! +//! ```rust +//! use bittorrent_tracker_core::authentication::Key; +//! use torrust_tracker_primitives::DurationSinceUnixEpoch; +//! +//! pub struct PeerKey { +//! /// A random 32-character authentication token (e.g., `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ`) +//! pub key: Key, +//! +//! /// The timestamp after which the key expires. If `None`, the key is permanent. +//! pub valid_until: Option, +//! } +//! ``` +pub mod peer_key; +pub mod repository; + +use std::panic::Location; +use std::sync::Arc; +use std::time::Duration; + +use thiserror::Error; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_located_error::{DynError, LocatedError}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::CurrentClock; + +pub type PeerKey = peer_key::PeerKey; +pub type Key = peer_key::Key; +pub type ParseKeyError = peer_key::ParseKeyError; + +/// HTTP tracker authentication key length. +/// +/// For more information see function [`generate_key`](crate::authentication::key::generate_key) to generate the +/// [`PeerKey`](crate::authentication::PeerKey). +pub(crate) const AUTH_KEY_LENGTH: usize = 32; + +/// It generates a new permanent random key [`PeerKey`]. +#[cfg(test)] +#[must_use] +pub(crate) fn generate_permanent_key() -> PeerKey { + generate_key(None) +} + +/// It generates a new expiring random key [`PeerKey`]. +#[cfg(test)] +#[must_use] +pub(crate) fn generate_expiring_key(lifetime: Duration) -> PeerKey { + generate_key(Some(lifetime)) +} + +/// Generates a new random 32-character authentication key (`PeerKey`). +/// +/// If a lifetime is provided, the generated key will expire after the specified +/// duration; otherwise, the key is permanent (i.e., it never expires). +/// +/// # Panics +/// +/// Panics if the addition of the lifetime to the current time overflows +/// (an extremely unlikely event). +/// +/// # Arguments +/// +/// * `lifetime`: An optional duration specifying how long the key is valid. +/// If `None`, the key is permanent. +/// +/// # Examples +/// +/// ```rust +/// use bittorrent_tracker_core::authentication::key; +/// use std::time::Duration; +/// +/// // Generate an expiring key valid for 3600 seconds. +/// let expiring_key = key::generate_key(Some(Duration::from_secs(3600))); +/// +/// // Generate a permanent key. +/// let permanent_key = key::generate_key(None); +/// ``` +#[must_use] +pub fn generate_key(lifetime: Option) -> PeerKey { + let random_key = Key::random(); + + if let Some(lifetime) = lifetime { + tracing::debug!("Generated key: {}, valid for: {:?} seconds", random_key, lifetime); + + PeerKey { + key: random_key, + valid_until: Some(CurrentClock::now_add(&lifetime).unwrap()), + } + } else { + tracing::debug!("Generated key: {}, permanent", random_key); + + PeerKey { + key: random_key, + valid_until: None, + } + } +} + +/// Verifies whether a given authentication key (`PeerKey`) is still valid. +/// +/// For expiring keys, this function compares the key's expiration timestamp +/// against the current time. Permanent keys (with `None` as their expiration) +/// are always valid. +/// +/// # Errors +/// +/// Returns a verification error of type [`enum@Error`] if the key has expired. +/// +/// # Examples +/// +/// ```rust +/// use bittorrent_tracker_core::authentication::key; +/// use std::time::Duration; +/// +/// let expiring_key = key::generate_key(Some(Duration::from_secs(100))); +/// +/// // If the key's expiration time has passed, the verification will fail. +/// assert!(key::verify_key_expiration(&expiring_key).is_ok()); +/// ``` +pub fn verify_key_expiration(auth_key: &PeerKey) -> Result<(), Error> { + let current_time: DurationSinceUnixEpoch = CurrentClock::now(); + + match auth_key.valid_until { + Some(valid_until) => { + if valid_until < current_time { + Err(Error::KeyExpired { + location: Location::caller(), + }) + } else { + Ok(()) + } + } + None => Ok(()), // Permanent key + } +} + +/// Verification error. Error returned when an [`PeerKey`] cannot be +/// verified with the [`crate::authentication::key::verify_key_expiration`] function. +#[derive(Debug, Error, Clone)] +#[allow(dead_code)] +pub enum Error { + /// Wraps an underlying error encountered during key verification. + #[error("Key could not be verified: {source}")] + KeyVerificationError { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + }, + + /// Indicates that the key could not be read or found. + #[error("Failed to read key: {key}, {location}")] + UnableToReadKey { + location: &'static Location<'static>, + key: Box, + }, + + /// Indicates that the key has expired. + #[error("Key has expired, {location}")] + KeyExpired { location: &'static Location<'static> }, + + /// Indicates that the required key for authentication was not provided. + #[error("Missing authentication key, {location}")] + MissingAuthKey { location: &'static Location<'static> }, +} + +impl From for Error { + fn from(e: r2d2_sqlite::rusqlite::Error) -> Self { + Error::KeyVerificationError { + source: (Arc::new(e) as DynError).into(), + } + } +} + +#[cfg(test)] +mod tests { + + mod the_expiring_peer_key { + + use std::time::Duration; + + use torrust_tracker_clock::clock; + use torrust_tracker_clock::clock::stopped::Stopped as _; + + use crate::authentication; + + #[test] + fn should_be_displayed() { + // Set the time to the current time. + clock::Stopped::local_set_to_unix_epoch(); + + let expiring_key = authentication::key::generate_key(Some(Duration::from_secs(0))); + + assert_eq!( + expiring_key.to_string(), + format!("key: `{}`, valid until `1970-01-01 00:00:00 UTC`", expiring_key.key) // cspell:disable-line + ); + } + + #[test] + fn should_be_generated_with_a_expiration_time() { + let expiring_key = authentication::key::generate_key(Some(Duration::new(9999, 0))); + + assert!(authentication::key::verify_key_expiration(&expiring_key).is_ok()); + } + + #[test] + fn expiration_verification_should_fail_when_the_key_has_expired() { + // Set the time to the current time. + clock::Stopped::local_set_to_system_time_now(); + + // Make key that is valid for 19 seconds. + let expiring_key = authentication::key::generate_key(Some(Duration::from_secs(19))); + + // Mock the time has passed 10 sec. + clock::Stopped::local_add(&Duration::from_secs(10)).unwrap(); + + assert!(authentication::key::verify_key_expiration(&expiring_key).is_ok()); + + // Mock the time has passed another 10 sec. + clock::Stopped::local_add(&Duration::from_secs(10)).unwrap(); + + assert!(authentication::key::verify_key_expiration(&expiring_key).is_err()); + } + } + + mod the_permanent_peer_key { + + use std::time::Duration; + + use torrust_tracker_clock::clock; + use torrust_tracker_clock::clock::stopped::Stopped as _; + + use crate::authentication; + + #[test] + fn should_be_displayed() { + // Set the time to the current time. + clock::Stopped::local_set_to_unix_epoch(); + + let expiring_key = authentication::key::generate_key(Some(Duration::from_secs(0))); + + assert_eq!( + expiring_key.to_string(), + format!("key: `{}`, valid until `1970-01-01 00:00:00 UTC`", expiring_key.key) // cspell:disable-line + ); + } + + #[test] + fn should_be_generated_without_expiration_time() { + let expiring_key = authentication::key::generate_permanent_key(); + + assert!(authentication::key::verify_key_expiration(&expiring_key).is_ok()); + } + + #[test] + fn expiration_verification_should_always_succeed() { + let expiring_key = authentication::key::generate_permanent_key(); + + // Mock the time has passed 10 years. + clock::Stopped::local_add(&Duration::from_secs(10 * 365 * 24 * 60 * 60)).unwrap(); + + assert!(authentication::key::verify_key_expiration(&expiring_key).is_ok()); + } + } + + mod the_key_verification_error { + use crate::authentication::key; + + #[test] + fn could_be_a_database_error() { + let err = r2d2_sqlite::rusqlite::Error::InvalidQuery; + + let err: key::Error = err.into(); + + assert!(matches!(err, key::Error::KeyVerificationError { .. })); + } + } +} diff --git a/packages/tracker-core/src/authentication/key/peer_key.rs b/packages/tracker-core/src/authentication/key/peer_key.rs new file mode 100644 index 000000000..ba648ad2f --- /dev/null +++ b/packages/tracker-core/src/authentication/key/peer_key.rs @@ -0,0 +1,321 @@ +//! Authentication keys for private trackers. +//! +//! This module defines the types and functionality for managing authentication +//! keys used by the tracker. These keys, represented by the `Key` and `PeerKey` +//! types, are essential for authenticating peers in private tracker +//! environments. +//! +//! A `Key` is a 32-character alphanumeric token, while a `PeerKey` couples a +//! `Key` with an optional expiration timestamp. If the expiration is set (via +//! `valid_until`), the key will become invalid after that time. +use std::str::FromStr; +use std::time::Duration; + +use derive_more::Display; +use rand::distr::Alphanumeric; +use rand::{rng, RngExt}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use torrust_tracker_clock::conv::convert_from_timestamp_to_datetime_utc; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::AUTH_KEY_LENGTH; + +/// A peer authentication key with an optional expiration time. +/// +/// A `PeerKey` associates a generated `Key` (a 32-character alphanumeric string) +/// with an optional expiration timestamp (`valid_until`). If `valid_until` is +/// `None`, the key is considered permanent. +/// +/// # Example +/// +/// ```rust +/// use std::time::Duration; +/// use bittorrent_tracker_core::authentication::key::peer_key::{Key, PeerKey}; +/// +/// let expiring_key = PeerKey { +/// key: Key::random(), +/// valid_until: Some(Duration::from_secs(3600)), // Expires in 1 hour +/// }; +/// +/// let permanent_key = PeerKey { +/// key: Key::random(), +/// valid_until: None, +/// }; +/// ``` +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PeerKey { + /// A 32-character authentication key. For example: `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ` + pub key: Key, + + /// An optional expiration timestamp. If set, the key becomes invalid after + /// this time. A value of `None` indicates a permanent key. + pub valid_until: Option, +} + +impl PartialEq for PeerKey { + fn eq(&self, other: &Self) -> bool { + // When comparing two PeerKeys, ignore fractions of seconds since only + // whole seconds are stored in the database. + self.key == other.key + && match (&self.valid_until, &other.valid_until) { + (Some(a), Some(b)) => a.as_secs() == b.as_secs(), + (None, None) => true, + _ => false, + } + } +} + +impl Eq for PeerKey {} + +impl std::fmt::Display for PeerKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.expiry_time() { + Some(expire_time) => write!(f, "key: `{}`, valid until `{}`", self.key, expire_time), + None => write!(f, "key: `{}`, permanent", self.key), + } + } +} + +impl PeerKey { + #[must_use] + pub fn key(&self) -> Key { + self.key.clone() + } + + /// Computes and returns the expiration time as a UTC `DateTime`, if one + /// exists. + /// + /// The returned time is derived from the stored seconds since the Unix + /// epoch. Note that any fractional seconds are discarded since only whole + /// seconds are stored in the database. + /// + /// # Panics + /// + /// Panics if the key's timestamp overflows the internal `i64` type (this is + /// extremely unlikely, happening roughly 292.5 billion years from now). + #[must_use] + pub fn expiry_time(&self) -> Option> { + // We remove the fractions of seconds because we only store the seconds + // in the database. + self.valid_until + .map(|valid_until| convert_from_timestamp_to_datetime_utc(Duration::from_secs(valid_until.as_secs()))) + } +} + +/// A token used for authentication. +/// +/// The `Key` type encapsulates a 32-character string that must consist solely +/// of ASCII alphanumeric characters (0-9, a-z, A-Z). This key is used by the +/// tracker to authenticate peers. +/// +/// # Examples +/// +/// Creating a key from a valid string: +/// +/// ``` +/// use bittorrent_tracker_core::authentication::key::peer_key::Key; +/// let key = Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); +/// ``` +/// +/// Generating a random key: +/// +/// ``` +/// use bittorrent_tracker_core::authentication::key::peer_key::Key; +/// let random_key = Key::random(); +/// ``` +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Display, Hash)] +pub struct Key(String); + +impl Key { + /// Constructs a new `Key` from the given string. + /// + /// # Errors + /// + /// Returns a `ParseKeyError` if: + /// + /// - The input string does not have exactly 32 characters. + /// - The input string contains characters that are not ASCII alphanumeric. + pub fn new(value: &str) -> Result { + if value.len() != AUTH_KEY_LENGTH { + return Err(ParseKeyError::InvalidKeyLength); + } + + if !value.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(ParseKeyError::InvalidChars); + } + + Ok(Self(value.to_owned())) + } + + /// Generates a new random authentication key. + /// + /// The random key is generated by sampling 32 ASCII alphanumeric characters. + /// + /// # Panics + /// + /// Panics if the random number generator fails to produce a valid key + /// (extremely unlikely). + pub fn random() -> Self { + let random_id: String = rng() + .sample_iter(&Alphanumeric) + .take(AUTH_KEY_LENGTH) + .map(char::from) + .collect(); + random_id.parse::().expect("Failed to generate a valid random key") + } + + #[must_use] + pub fn value(&self) -> &str { + &self.0 + } +} + +/// Errors that can occur when parsing a string into a `Key`. +/// +/// # Examples +/// +/// ```rust +/// use bittorrent_tracker_core::authentication::Key; +/// use std::str::FromStr; +/// +/// let key_string = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"; +/// let key = Key::from_str(key_string); +/// +/// assert!(key.is_ok()); +/// assert_eq!(key.unwrap().to_string(), key_string); +/// ``` +/// +/// If the string does not contains a valid key, the parser function will return +/// this error. +#[derive(Debug, Error)] +pub enum ParseKeyError { + /// The provided key does not have exactly 32 characters. + #[error("Invalid key length. Key must be have 32 chars")] + InvalidKeyLength, + + /// The provided key contains invalid characters. Only ASCII alphanumeric + /// characters are allowed. + #[error("Invalid chars for key. Key can only alphanumeric chars (0-9, a-z, A-Z)")] + InvalidChars, +} + +impl FromStr for Key { + type Err = ParseKeyError; + + fn from_str(s: &str) -> Result { + Key::new(s)?; + Ok(Self(s.to_string())) + } +} + +#[cfg(test)] +mod tests { + + mod key { + use std::str::FromStr; + + use crate::authentication::Key; + + #[test] + fn should_be_parsed_from_an_string() { + let key_string = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"; + let key = Key::from_str(key_string); + + assert!(key.is_ok()); + assert_eq!(key.unwrap().to_string(), key_string); + } + + #[test] + fn should_be_generated_randomly() { + let _key = Key::random(); + } + + #[test] + fn length_should_be_32() { + let key = Key::new(""); + assert!(key.is_err()); + + let string_longer_than_32 = "012345678901234567890123456789012"; // DevSkim: ignore DS173237 + let key = Key::new(string_longer_than_32); + assert!(key.is_err()); + } + + #[test] + fn should_only_include_alphanumeric_chars() { + let key = Key::new("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); + assert!(key.is_err()); + } + + #[test] + fn should_return_a_reference_to_the_inner_string() { + let key = Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); // DevSkim: ignore DS173237 + + assert_eq!(key.value(), "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"); // DevSkim: ignore DS173237 + } + } + + mod peer_key { + + use std::time::Duration; + + use crate::authentication::key::peer_key::{Key, PeerKey}; + + #[test] + fn could_have_an_expiration_time() { + let expiring_key = PeerKey { + key: Key::random(), + valid_until: Some(Duration::from_secs(100)), + }; + + assert_eq!(expiring_key.expiry_time().unwrap().to_string(), "1970-01-01 00:01:40 UTC"); + } + + #[test] + fn could_be_permanent() { + let permanent_key = PeerKey { + key: Key::random(), + valid_until: None, + }; + + assert_eq!(permanent_key.expiry_time(), None); + } + + mod expiring { + use std::time::Duration; + + use crate::authentication::key::peer_key::{Key, PeerKey}; + + #[test] + fn should_be_displayed_when_it_is_expiring() { + let expiring_key = PeerKey { + key: Key::random(), + valid_until: Some(Duration::from_secs(100)), + }; + + assert_eq!( + expiring_key.to_string(), + format!("key: `{}`, valid until `1970-01-01 00:01:40 UTC`", expiring_key.key) // cspell:disable-line + ); + } + } + + mod permanent { + + use crate::authentication::key::peer_key::{Key, PeerKey}; + + #[test] + fn should_be_displayed_when_it_is_permanent() { + let permanent_key = PeerKey { + key: Key::random(), + valid_until: None, + }; + + assert_eq!( + permanent_key.to_string(), + format!("key: `{}`, permanent", permanent_key.key) // cspell:disable-line + ); + } + } + } +} diff --git a/packages/tracker-core/src/authentication/key/repository/in_memory.rs b/packages/tracker-core/src/authentication/key/repository/in_memory.rs new file mode 100644 index 000000000..5911771d4 --- /dev/null +++ b/packages/tracker-core/src/authentication/key/repository/in_memory.rs @@ -0,0 +1,188 @@ +//! In-memory implementation of the authentication key repository. +use crate::authentication::key::{Key, PeerKey}; + +/// An in-memory repository for storing authentication keys. +/// +/// This repository maintains a mapping between a peer's [`Key`] and its +/// corresponding [`PeerKey`]. It is designed for use in private tracker +/// environments where keys are maintained in memory. +#[derive(Debug, Default)] +pub struct InMemoryKeyRepository { + /// Tracker users' keys. Only for private trackers. + keys: tokio::sync::RwLock>, +} + +impl InMemoryKeyRepository { + /// Inserts a new authentication key into the repository. + /// + /// This function acquires a write lock on the internal storage and inserts + /// the provided [`PeerKey`], using its inner [`Key`] as the map key. + /// + /// # Arguments + /// + /// * `auth_key` - A reference to the [`PeerKey`] to be inserted. + pub(crate) async fn insert(&self, auth_key: &PeerKey) { + self.keys.write().await.insert(auth_key.key.clone(), auth_key.clone()); + } + + /// Removes an authentication key from the repository. + /// + /// This function acquires a write lock on the internal storage and removes + /// the key that matches the provided [`Key`]. + /// + /// # Arguments + /// + /// * `key` - A reference to the [`Key`] corresponding to the key to be removed. + pub(crate) async fn remove(&self, key: &Key) { + self.keys.write().await.remove(key); + } + + /// Retrieves an authentication key from the repository. + /// + /// This function acquires a read lock on the internal storage and returns a + /// cloned [`PeerKey`] if the provided [`Key`] exists. + /// + /// # Arguments + /// + /// * `key` - A reference to the [`Key`] to look up. + /// + /// # Returns + /// + /// An `Option` containing the matching key if found, or `None` + /// otherwise. + pub(crate) async fn get(&self, key: &Key) -> Option { + self.keys.read().await.get(key).cloned() + } + + /// Clears all authentication keys from the repository. + /// + /// This function acquires a write lock on the internal storage and removes + /// all entries. + #[allow(dead_code)] + pub(crate) async fn clear(&self) { + let mut keys = self.keys.write().await; + keys.clear(); + } + + /// Resets the repository with a new list of authentication keys. + /// + /// This function clears all existing keys and then inserts each key from + /// the provided vector. + /// + /// # Arguments + /// + /// * `peer_keys` - A vector of [`PeerKey`] instances that will replace the + /// current set of keys. + pub async fn reset_with(&self, peer_keys: Vec) { + let mut keys_lock = self.keys.write().await; + + keys_lock.clear(); + + for key in peer_keys { + keys_lock.insert(key.key.clone(), key.clone()); + } + } +} + +#[cfg(test)] +mod tests { + + mod the_in_memory_key_repository_should { + use std::time::Duration; + + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::key::Key; + use crate::authentication::PeerKey; + + #[tokio::test] + async fn insert_a_new_peer_key() { + let repository = InMemoryKeyRepository::default(); + + let new_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.insert(&new_peer_key).await; + + let peer_key = repository.get(&new_peer_key.key).await; + + assert_eq!(peer_key, Some(new_peer_key)); + } + + #[tokio::test] + async fn remove_a_new_peer_key() { + let repository = InMemoryKeyRepository::default(); + + let new_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.insert(&new_peer_key).await; + + repository.remove(&new_peer_key.key).await; + + let peer_key = repository.get(&new_peer_key.key).await; + + assert_eq!(peer_key, None); + } + + #[tokio::test] + async fn get_a_new_peer_key_by_its_internal_key() { + let repository = InMemoryKeyRepository::default(); + + let expected_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.insert(&expected_peer_key).await; + + let peer_key = repository.get(&expected_peer_key.key).await; + + assert_eq!(peer_key, Some(expected_peer_key)); + } + + #[tokio::test] + async fn clear_all_peer_keys() { + let repository = InMemoryKeyRepository::default(); + + let new_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.insert(&new_peer_key).await; + + repository.clear().await; + + let peer_key = repository.get(&new_peer_key.key).await; + + assert_eq!(peer_key, None); + } + + #[tokio::test] + async fn reset_the_peer_keys_with_a_new_list_of_keys() { + let repository = InMemoryKeyRepository::default(); + + let old_peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.insert(&old_peer_key).await; + + let new_peer_key = PeerKey { + key: Key::new("kqdVKHlKKWXzAideqI5gvjBP4jdbe5dW").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + repository.reset_with(vec![new_peer_key.clone()]).await; + + let peer_key = repository.get(&new_peer_key.key).await; + + assert_eq!(peer_key, Some(new_peer_key)); + } + } +} diff --git a/packages/tracker-core/src/authentication/key/repository/mod.rs b/packages/tracker-core/src/authentication/key/repository/mod.rs new file mode 100644 index 000000000..3df783622 --- /dev/null +++ b/packages/tracker-core/src/authentication/key/repository/mod.rs @@ -0,0 +1,3 @@ +//! Key repository implementations. +pub mod in_memory; +pub mod persisted; diff --git a/packages/tracker-core/src/authentication/key/repository/persisted.rs b/packages/tracker-core/src/authentication/key/repository/persisted.rs new file mode 100644 index 000000000..e84a23c9b --- /dev/null +++ b/packages/tracker-core/src/authentication/key/repository/persisted.rs @@ -0,0 +1,159 @@ +//! The database repository for the authentication keys. +use std::sync::Arc; + +use crate::authentication::key::{Key, PeerKey}; +use crate::databases::{self, Database}; + +/// A repository for storing authentication keys in a persistent database. +/// +/// This repository provides methods to add, remove, and load authentication +/// keys from the underlying database. It wraps an instance of a type +/// implementing the [`Database`] trait. +pub struct DatabaseKeyRepository { + database: Arc>, +} + +impl DatabaseKeyRepository { + /// Creates a new `DatabaseKeyRepository` instance. + /// + /// # Arguments + /// + /// * `database` - A shared reference to a boxed database implementation. + /// + /// # Returns + /// + /// A new instance of `DatabaseKeyRepository` + #[must_use] + pub fn new(database: &Arc>) -> Self { + Self { + database: database.clone(), + } + } + + /// Adds a new authentication key to the database. + /// + /// # Arguments + /// + /// * `peer_key` - A reference to the [`PeerKey`] to be persisted. + /// + /// # Errors + /// + /// Returns a [`databases::error::Error`] if the key cannot be added. + pub(crate) fn add(&self, peer_key: &PeerKey) -> Result<(), databases::error::Error> { + self.database.add_key_to_keys(peer_key)?; + Ok(()) + } + + /// Removes an authentication key from the database. + /// + /// # Arguments + /// + /// * `key` - A reference to the [`Key`] corresponding to the key to remove. + /// + /// # Errors + /// + /// Returns a [`databases::error::Error`] if the key cannot be removed. + pub(crate) fn remove(&self, key: &Key) -> Result<(), databases::error::Error> { + self.database.remove_key_from_keys(key)?; + Ok(()) + } + + /// Loads all authentication keys from the database. + /// + /// # Errors + /// + /// Returns a [`databases::error::Error`] if the keys cannot be loaded. + /// + /// # Returns + /// + /// A vector containing all persisted [`PeerKey`] entries. + pub(crate) fn load_keys(&self) -> Result, databases::error::Error> { + let keys = self.database.load_keys()?; + Ok(keys) + } +} + +#[cfg(test)] +mod tests { + + mod the_persisted_key_repository_should { + + use std::time::Duration; + + use torrust_tracker_configuration::Core; + use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + + use crate::authentication::key::repository::persisted::DatabaseKeyRepository; + use crate::authentication::{Key, PeerKey}; + use crate::databases::setup::initialize_database; + + fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + config + } + + #[test] + fn persist_a_new_peer_key() { + let configuration = ephemeral_configuration(); + + let database = initialize_database(&configuration); + + let repository = DatabaseKeyRepository::new(&database); + + let peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + let result = repository.add(&peer_key); + assert!(result.is_ok()); + + let keys = repository.load_keys().unwrap(); + assert_eq!(keys, vec!(peer_key)); + } + + #[test] + fn remove_a_persisted_peer_key() { + let configuration = ephemeral_configuration(); + + let database = initialize_database(&configuration); + + let repository = DatabaseKeyRepository::new(&database); + + let peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + let _unused = repository.add(&peer_key); + + let result = repository.remove(&peer_key.key); + assert!(result.is_ok()); + + let keys = repository.load_keys().unwrap(); + assert!(keys.is_empty()); + } + + #[test] + fn load_all_persisted_peer_keys() { + let configuration = ephemeral_configuration(); + + let database = initialize_database(&configuration); + + let repository = DatabaseKeyRepository::new(&database); + + let peer_key = PeerKey { + key: Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), + valid_until: Some(Duration::new(9999, 0)), + }; + + let _unused = repository.add(&peer_key); + + let keys = repository.load_keys().unwrap(); + + assert_eq!(keys, vec!(peer_key)); + } + } +} diff --git a/packages/tracker-core/src/authentication/mod.rs b/packages/tracker-core/src/authentication/mod.rs new file mode 100644 index 000000000..12b742b8b --- /dev/null +++ b/packages/tracker-core/src/authentication/mod.rs @@ -0,0 +1,242 @@ +//! Tracker authentication services and structs. +//! +//! One of the crate responsibilities is to create and keep authentication keys. +//! Auth keys are used by HTTP trackers when the tracker is running in `private` +//! mode. +//! +//! HTTP tracker's clients need to obtain an authentication key before starting +//! requesting the tracker. Once they get one they have to include a `PATH` +//! param with the key in all the HTTP requests. For example, when a peer wants +//! to `announce` itself it has to use the HTTP tracker endpoint: +//! +//! `GET /announce/:key` +//! +//! The common way to obtain the keys is by using the tracker API directly or +//! via other applications like the [Torrust Index](https://github.com/torrust/torrust-index). +use crate::CurrentClock; + +pub mod handler; +pub mod key; +pub mod service; + +pub type PeerKey = key::PeerKey; +pub type Key = key::Key; +pub type Error = key::Error; + +#[cfg(test)] +mod tests { + + // Integration tests for authentication. + + mod the_tracker_configured_as_private { + + use std::sync::Arc; + use std::time::Duration; + + use torrust_tracker_configuration::v2_0_0::core::PrivateMode; + use torrust_tracker_configuration::Configuration; + use torrust_tracker_test_helpers::configuration; + + use crate::authentication::handler::KeysHandler; + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::key::repository::persisted::DatabaseKeyRepository; + use crate::authentication::service; + use crate::authentication::service::AuthenticationService; + use crate::databases::setup::initialize_database; + + fn instantiate_keys_manager_and_authentication() -> (Arc, Arc) { + let config = configuration::ephemeral_private(); + + instantiate_keys_manager_and_authentication_with_configuration(&config) + } + + fn instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled( + ) -> (Arc, Arc) { + let mut config = configuration::ephemeral_private(); + + config.core.private_mode = Some(PrivateMode { + check_keys_expiration: false, + }); + + instantiate_keys_manager_and_authentication_with_configuration(&config) + } + + fn instantiate_keys_manager_and_authentication_with_configuration( + config: &Configuration, + ) -> (Arc, Arc) { + let database = initialize_database(&config.core); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(service::AuthenticationService::new(&config.core, &in_memory_key_repository)); + let keys_handler = Arc::new(KeysHandler::new( + &db_key_repository.clone(), + &in_memory_key_repository.clone(), + )); + + (keys_handler, authentication_service) + } + + #[tokio::test] + async fn it_should_remove_an_authentication_key() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let expiring_key = keys_manager + .generate_expiring_peer_key(Some(Duration::from_secs(100))) + .await + .unwrap(); + + let result = keys_manager.remove_peer_key(&expiring_key.key()).await; + + assert!(result.is_ok()); + + // The key should no longer be valid + assert!(authentication_service.authenticate(&expiring_key.key()).await.is_err()); + } + + #[tokio::test] + async fn it_should_load_authentication_keys_from_the_database() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let expiring_key = keys_manager + .generate_expiring_peer_key(Some(Duration::from_secs(100))) + .await + .unwrap(); + + // Remove the newly generated key in memory + keys_manager.remove_in_memory_auth_key(&expiring_key.key()).await; + + let result = keys_manager.load_peer_keys_from_database().await; + + assert!(result.is_ok()); + + // The key should no longer be valid + assert!(authentication_service.authenticate(&expiring_key.key()).await.is_ok()); + } + + mod with_expiring_and { + + mod randomly_generated_keys { + use std::time::Duration; + + use crate::authentication::tests::the_tracker_configured_as_private::{ + instantiate_keys_manager_and_authentication, + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled, + }; + use crate::authentication::Key; + + #[tokio::test] + async fn it_should_authenticate_a_peer_with_the_key() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let peer_key = keys_manager + .generate_expiring_peer_key(Some(Duration::from_secs(100))) + .await + .unwrap(); + + let result = authentication_service.authenticate(&peer_key.key()).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { + let (keys_manager, authentication_service) = + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled(); + + let past_timestamp = Duration::ZERO; + + let peer_key = keys_manager + .add_expiring_peer_key(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), Some(past_timestamp)) + .await + .unwrap(); + + assert!(authentication_service.authenticate(&peer_key.key()).await.is_ok()); + } + } + + mod pre_generated_keys { + + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::tests::the_tracker_configured_as_private::{ + instantiate_keys_manager_and_authentication, + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled, + }; + use crate::authentication::Key; + + #[tokio::test] + async fn it_should_authenticate_a_peer_with_the_key() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let peer_key = keys_manager + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: Some(100), + }) + .await + .unwrap(); + + let result = authentication_service.authenticate(&peer_key.key()).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { + let (keys_manager, authentication_service) = + instantiate_keys_manager_and_authentication_with_checking_keys_expiration_disabled(); + + let peer_key = keys_manager + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: Some(0), + }) + .await + .unwrap(); + + assert!(authentication_service.authenticate(&peer_key.key()).await.is_ok()); + } + } + } + + mod with_permanent_and { + + mod randomly_generated_keys { + use crate::authentication::tests::the_tracker_configured_as_private::instantiate_keys_manager_and_authentication; + + #[tokio::test] + async fn it_should_authenticate_a_peer_with_the_key() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let peer_key = keys_manager.generate_permanent_peer_key().await.unwrap(); + + let result = authentication_service.authenticate(&peer_key.key()).await; + + assert!(result.is_ok()); + } + } + + mod pre_generated_keys { + use crate::authentication::handler::AddKeyRequest; + use crate::authentication::tests::the_tracker_configured_as_private::instantiate_keys_manager_and_authentication; + use crate::authentication::Key; + + #[tokio::test] + async fn it_should_authenticate_a_peer_with_the_key() { + let (keys_manager, authentication_service) = instantiate_keys_manager_and_authentication(); + + let peer_key = keys_manager + .add_peer_key(AddKeyRequest { + opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), + opt_seconds_valid: None, + }) + .await + .unwrap(); + + let result = authentication_service.authenticate(&peer_key.key()).await; + + assert!(result.is_ok()); + } + } + } + } +} diff --git a/packages/tracker-core/src/authentication/service.rs b/packages/tracker-core/src/authentication/service.rs new file mode 100644 index 000000000..75b28944f --- /dev/null +++ b/packages/tracker-core/src/authentication/service.rs @@ -0,0 +1,313 @@ +//! Authentication service. +use std::panic::Location; +use std::sync::Arc; + +use torrust_tracker_configuration::Core; + +use super::key::repository::in_memory::InMemoryKeyRepository; +use super::{key, Error, Key}; + +/// The authentication service responsible for validating peer keys. +/// +/// The service uses an in-memory key repository along with the tracker +/// configuration to determine whether a given peer key is valid. In a private +/// tracker, only registered keys (and optionally unexpired keys) are allowed. +#[derive(Debug)] +pub struct AuthenticationService { + /// The tracker configuration. + config: Core, + + /// In-memory implementation of the authentication key repository. + in_memory_key_repository: Arc, +} + +impl AuthenticationService { + /// Creates a new instance of the `AuthenticationService`. + /// + /// # Parameters + /// + /// - `config`: A reference to the tracker core configuration. + /// - `in_memory_key_repository`: A shared reference to an in-memory key + /// repository. + /// + /// # Returns + /// + /// An `AuthenticationService` instance initialized with the given + /// configuration and repository. + #[must_use] + pub fn new(config: &Core, in_memory_key_repository: &Arc) -> Self { + Self { + config: config.clone(), + in_memory_key_repository: in_memory_key_repository.clone(), + } + } + + /// Authenticates a peer key against the tracker's authentication key list. + /// + /// For private trackers, the key must be registered (and optionally not + /// expired) to be considered valid. For public trackers, authentication + /// always succeeds. + /// + /// # Parameters + /// + /// - `key`: A reference to the peer key that needs to be authenticated. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - The tracker is in private mode and the key cannot be found in the + /// repository. + /// - The key is found but fails the expiration check (if expiration is enforced). + pub async fn authenticate(&self, key: &Key) -> Result<(), Error> { + if self.tracker_is_private() { + self.verify_auth_key(key).await + } else { + Ok(()) + } + } + + /// Returns `true` is the tracker is in private mode. + #[must_use] + fn tracker_is_private(&self) -> bool { + self.config.private + } + + /// Verifies the authentication key against the in-memory repository. + /// + /// This function retrieves the key from the repository. If the key is not + /// found, it returns an error with the caller's location. If the key is + /// found, the function then checks the key's expiration based on the + /// tracker configuration. The behavior differs depending on whether a + /// `private` configuration is provided and whether key expiration checking + /// is enabled. + /// + /// # Parameters + /// + /// - `key`: A reference to the peer key that needs to be verified. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - The key is not found in the repository. + /// - The key fails the expiration check when such verification is required. + async fn verify_auth_key(&self, key: &Key) -> Result<(), Error> { + match self.in_memory_key_repository.get(key).await { + None => Err(Error::UnableToReadKey { + location: Location::caller(), + key: Box::new(key.clone()), + }), + Some(key) => match self.config.private_mode { + Some(private_mode) => { + if private_mode.check_keys_expiration { + return key::verify_key_expiration(&key); + } + + Ok(()) + } + None => key::verify_key_expiration(&key), + }, + } + } +} + +#[cfg(test)] +mod tests { + + mod the_authentication_service { + + mod when_the_tracker_is_public { + + use std::str::FromStr; + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::service::AuthenticationService; + use crate::authentication::{self}; + + fn instantiate_authentication_for_public_tracker() -> AuthenticationService { + let config = Core { + private: false, + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + AuthenticationService::new(&config, &in_memory_key_repository.clone()) + } + + #[tokio::test] + async fn it_should_always_authenticate_when_the_tracker_is_public() { + let authentication = instantiate_authentication_for_public_tracker(); + + let unregistered_key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + let result = authentication.authenticate(&unregistered_key).await; + + assert!(result.is_ok()); + } + } + + mod when_the_tracker_is_private { + + use std::str::FromStr; + use std::sync::Arc; + use std::time::Duration; + + use torrust_tracker_configuration::v2_0_0::core::PrivateMode; + use torrust_tracker_configuration::Core; + + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::service::AuthenticationService; + use crate::authentication::{self, PeerKey}; + + fn instantiate_authentication_for_private_tracker() -> AuthenticationService { + let config = Core { + private: true, + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + AuthenticationService::new(&config, &in_memory_key_repository.clone()) + } + + #[tokio::test] + async fn it_should_authenticate_a_registered_key() { + let config = Core { + private: true, + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + let key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + in_memory_key_repository + .insert(&PeerKey { + key: key.clone(), + valid_until: None, + }) + .await; + + let authentication = AuthenticationService::new(&config, &in_memory_key_repository.clone()); + + let result = authentication.authenticate(&key).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn it_should_not_authenticate_an_unregistered_key() { + let authentication = instantiate_authentication_for_private_tracker(); + + let unregistered_key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + let result = authentication.authenticate(&unregistered_key).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn it_should_not_authenticate_a_registered_but_expired_key_by_default() { + let config = Core { + private: true, + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + let key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + // Register the key with an immediate expiration date. + in_memory_key_repository + .insert(&PeerKey { + key: key.clone(), + valid_until: Some(Duration::from_secs(0)), + }) + .await; + + let authentication = AuthenticationService::new(&config, &in_memory_key_repository.clone()); + + let result = authentication.authenticate(&key).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn it_should_not_authenticate_a_registered_but_expired_key_when_the_tracker_is_explicitly_configured_to_check_keys_expiration( + ) { + let config = Core { + private: true, + private_mode: Some(PrivateMode { + check_keys_expiration: true, + }), + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + let key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + // Register the key with an immediate expiration date. + in_memory_key_repository + .insert(&PeerKey { + key: key.clone(), + valid_until: Some(Duration::from_secs(0)), + }) + .await; + + let authentication = AuthenticationService::new(&config, &in_memory_key_repository.clone()); + + let result = authentication.authenticate(&key).await; + + assert!(result.is_err()); + } + + mod but_the_key_expiration_check_is_disabled_by_configuration { + use std::str::FromStr; + use std::sync::Arc; + use std::time::Duration; + + use torrust_tracker_configuration::v2_0_0::core::PrivateMode; + use torrust_tracker_configuration::Core; + + use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; + use crate::authentication::service::AuthenticationService; + use crate::authentication::{self, PeerKey}; + + #[tokio::test] + async fn it_should_authenticate_an_expired_registered_key() { + let config = Core { + private: true, + private_mode: Some(PrivateMode { + check_keys_expiration: false, + }), + ..Default::default() + }; + + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + + let key = authentication::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); + + // Register the key with an immediate expiration date. + in_memory_key_repository + .insert(&PeerKey { + key: key.clone(), + valid_until: Some(Duration::from_secs(0)), + }) + .await; + + let authentication = AuthenticationService::new(&config, &in_memory_key_repository.clone()); + + let result = authentication.authenticate(&key).await; + + assert!(result.is_ok()); + } + } + } + } +} diff --git a/packages/tracker-core/src/container.rs b/packages/tracker-core/src/container.rs new file mode 100644 index 000000000..93b8efd7e --- /dev/null +++ b/packages/tracker-core/src/container.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; + +use torrust_tracker_configuration::Core; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +use crate::announce_handler::AnnounceHandler; +use crate::authentication::handler::KeysHandler; +use crate::authentication::key::repository::in_memory::InMemoryKeyRepository; +use crate::authentication::key::repository::persisted::DatabaseKeyRepository; +use crate::authentication::service::AuthenticationService; +use crate::databases::setup::initialize_database; +use crate::databases::Database; +use crate::scrape_handler::ScrapeHandler; +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use crate::torrent::manager::TorrentsManager; +use crate::torrent::repository::in_memory::InMemoryTorrentRepository; +use crate::whitelist::authorization::WhitelistAuthorization; +use crate::whitelist::manager::WhitelistManager; +use crate::whitelist::repository::in_memory::InMemoryWhitelist; +use crate::whitelist::setup::initialize_whitelist_manager; +use crate::{statistics, whitelist}; + +pub struct TrackerCoreContainer { + pub core_config: Arc, + pub database: Arc>, + pub announce_handler: Arc, + pub scrape_handler: Arc, + pub keys_handler: Arc, + pub authentication_service: Arc, + pub in_memory_whitelist: Arc, + pub whitelist_authorization: Arc, + pub whitelist_manager: Arc, + pub in_memory_torrent_repository: Arc, + pub db_downloads_metric_repository: Arc, + pub torrents_manager: Arc, + pub stats_repository: Arc, +} + +impl TrackerCoreContainer { + #[must_use] + pub fn initialize_from( + core_config: &Arc, + swarm_coordination_registry_container: &Arc, + ) -> Self { + let database = initialize_database(core_config); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(core_config, &in_memory_whitelist.clone())); + let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); + let db_key_repository = Arc::new(DatabaseKeyRepository::new(&database)); + let in_memory_key_repository = Arc::new(InMemoryKeyRepository::default()); + let authentication_service = Arc::new(AuthenticationService::new(core_config, &in_memory_key_repository)); + let keys_handler = Arc::new(KeysHandler::new( + &db_key_repository.clone(), + &in_memory_key_repository.clone(), + )); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new( + swarm_coordination_registry_container.swarms.clone(), + )); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + + let torrents_manager = Arc::new(TorrentsManager::new( + core_config, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + let stats_repository = Arc::new(statistics::repository::Repository::new()); + + let announce_handler = Arc::new(AnnounceHandler::new( + core_config, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + let scrape_handler = Arc::new(ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository)); + + Self { + core_config: core_config.clone(), + database, + announce_handler, + scrape_handler, + keys_handler, + authentication_service, + in_memory_whitelist, + whitelist_authorization, + whitelist_manager, + in_memory_torrent_repository, + db_downloads_metric_repository, + torrents_manager, + stats_repository, + } + } +} diff --git a/packages/tracker-core/src/databases/driver/mod.rs b/packages/tracker-core/src/databases/driver/mod.rs new file mode 100644 index 000000000..6c849bb70 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mod.rs @@ -0,0 +1,344 @@ +//! Database driver factory. +use mysql::Mysql; +use serde::{Deserialize, Serialize}; +use sqlite::Sqlite; + +use super::error::Error; +use super::Database; + +/// Metric name in DB for the total number of downloads across all torrents. +const TORRENTS_DOWNLOADS_TOTAL: &str = "torrents_downloads_total"; + +/// The database management system used by the tracker. +/// +/// Refer to: +/// +/// - [Torrust Tracker Configuration](https://docs.rs/torrust-tracker-configuration). +/// - [Torrust Tracker](https://docs.rs/torrust-tracker). +/// +/// For more information about persistence. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, derive_more::Display, Clone)] +pub enum Driver { + /// The Sqlite3 database driver. + Sqlite3, + /// The `MySQL` database driver. + MySQL, +} + +/// It builds a new database driver. +/// +/// Example for `SQLite3`: +/// +/// ```text +/// use bittorrent_tracker_core::databases; +/// use bittorrent_tracker_core::databases::driver::Driver; +/// +/// let db_driver = Driver::Sqlite3; +/// let db_path = "./storage/tracker/lib/database/sqlite3.db".to_string(); +/// let database = databases::driver::build(&db_driver, &db_path); +/// ``` +/// +/// Example for `MySQL`: +/// +/// ```text +/// use bittorrent_tracker_core::databases; +/// use bittorrent_tracker_core::databases::driver::Driver; +/// +/// let db_driver = Driver::MySQL; +/// let db_path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker".to_string(); +/// let database = databases::driver::build(&db_driver, &db_path); +/// ``` +/// +/// Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) +/// for more information about the database configuration. +/// +/// > **WARNING**: The driver instantiation runs database migrations. +/// +/// # Errors +/// +/// This function will return an error if unable to connect to the database. +/// +/// # Panics +/// +/// This function will panic if unable to create database tables. +pub mod mysql; +pub mod sqlite; + +/// It builds a new database driver. +/// +/// # Panics +/// +/// Will panic if unable to create database tables. +/// +/// # Errors +/// +/// Will return `Error` if unable to build the driver. +pub(crate) fn build(driver: &Driver, db_path: &str) -> Result, Error> { + let database: Box = match driver { + Driver::Sqlite3 => Box::new(Sqlite::new(db_path)?), + Driver::MySQL => Box::new(Mysql::new(db_path)?), + }; + + database.create_database_tables().expect("Could not create database tables."); + + Ok(database) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + use std::time::Duration; + + use crate::databases::Database; + + pub async fn run_tests(driver: &Arc>) { + // Since the interface is very simple and there are no conflicts between + // tests, we share the same database. If we want to isolate the tests in + // the future, we can create a new database for each test. + + database_setup(driver).await; + + // Persistent torrents (stats) + + // Torrent metrics + handling_torrent_persistence::it_should_save_and_load_persistent_torrents(driver); + handling_torrent_persistence::it_should_load_all_persistent_torrents(driver); + handling_torrent_persistence::it_should_increase_the_number_of_downloads_for_a_given_torrent(driver); + // Aggregate metrics for all torrents + handling_torrent_persistence::it_should_save_and_load_the_global_number_of_downloads(driver); + handling_torrent_persistence::it_should_load_the_global_number_of_downloads(driver); + handling_torrent_persistence::it_should_increase_the_global_number_of_downloads(driver); + + // Authentication keys (for private trackers) + + handling_authentication_keys::it_should_load_the_keys(driver); + + // Permanent keys + handling_authentication_keys::it_should_save_and_load_permanent_authentication_keys(driver); + handling_authentication_keys::it_should_remove_a_permanent_authentication_key(driver); + + // Expiring keys + handling_authentication_keys::it_should_save_and_load_expiring_authentication_keys(driver); + handling_authentication_keys::it_should_remove_an_expiring_authentication_key(driver); + + // Whitelist (for listed trackers) + + handling_the_whitelist::it_should_load_the_whitelist(driver); + handling_the_whitelist::it_should_add_and_get_infohashes(driver); + handling_the_whitelist::it_should_remove_an_infohash_from_the_whitelist(driver); + handling_the_whitelist::it_should_fail_trying_to_add_the_same_infohash_twice(driver); + } + + /// It initializes the database schema. + /// + /// Since the drop SQL queries don't check if the tables already exist, + /// we have to create them first, and then drop them. + /// + /// The method to drop tables does not use "DROP TABLE IF EXISTS". We can + /// change this function when we update the `Database::drop_database_tables` + /// method to use "DROP TABLE IF EXISTS". + async fn database_setup(driver: &Arc>) { + create_database_tables(driver).await.expect("database tables creation failed"); + driver.drop_database_tables().expect("old database tables deletion failed"); + create_database_tables(driver) + .await + .expect("database tables creation from empty schema failed"); + } + + async fn create_database_tables(driver: &Arc>) -> Result<(), Box> { + for _ in 0..5 { + if driver.create_database_tables().is_ok() { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err("Database is not ready after retries.".into()) + } + + mod handling_torrent_persistence { + + use std::sync::Arc; + + use crate::databases::Database; + use crate::test_helpers::tests::sample_info_hash; + + // Metrics per torrent + + pub fn it_should_save_and_load_persistent_torrents(driver: &Arc>) { + let infohash = sample_info_hash(); + + let number_of_downloads = 1; + + driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + + let number_of_downloads = driver.load_torrent_downloads(&infohash).unwrap().unwrap(); + + assert_eq!(number_of_downloads, 1); + } + + pub fn it_should_load_all_persistent_torrents(driver: &Arc>) { + let infohash = sample_info_hash(); + + let number_of_downloads = 1; + + driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + + let torrents = driver.load_all_torrents_downloads().unwrap(); + + assert_eq!(torrents.len(), 1); + assert_eq!(torrents.get(&infohash), Some(number_of_downloads).as_ref()); + } + + pub fn it_should_increase_the_number_of_downloads_for_a_given_torrent(driver: &Arc>) { + let infohash = sample_info_hash(); + + let number_of_downloads = 1; + + driver.save_torrent_downloads(&infohash, number_of_downloads).unwrap(); + + driver.increase_downloads_for_torrent(&infohash).unwrap(); + + let number_of_downloads = driver.load_torrent_downloads(&infohash).unwrap().unwrap(); + + assert_eq!(number_of_downloads, 2); + } + + // Aggregate metrics for all torrents + + pub fn it_should_save_and_load_the_global_number_of_downloads(driver: &Arc>) { + let number_of_downloads = 1; + + driver.save_global_downloads(number_of_downloads).unwrap(); + + let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + + assert_eq!(number_of_downloads, 1); + } + + pub fn it_should_load_the_global_number_of_downloads(driver: &Arc>) { + let number_of_downloads = 1; + + driver.save_global_downloads(number_of_downloads).unwrap(); + + let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + + assert_eq!(number_of_downloads, 1); + } + + pub fn it_should_increase_the_global_number_of_downloads(driver: &Arc>) { + let number_of_downloads = 1; + + driver.save_global_downloads(number_of_downloads).unwrap(); + + driver.increase_global_downloads().unwrap(); + + let number_of_downloads = driver.load_global_downloads().unwrap().unwrap(); + + assert_eq!(number_of_downloads, 2); + } + } + + mod handling_authentication_keys { + + use std::sync::Arc; + use std::time::Duration; + + use crate::authentication::key::{generate_expiring_key, generate_permanent_key}; + use crate::databases::Database; + + pub fn it_should_load_the_keys(driver: &Arc>) { + let permanent_peer_key = generate_permanent_key(); + driver.add_key_to_keys(&permanent_peer_key).unwrap(); + + let expiring_peer_key = generate_expiring_key(Duration::from_secs(120)); + driver.add_key_to_keys(&expiring_peer_key).unwrap(); + + let keys = driver.load_keys().unwrap(); + + assert!(keys.contains(&permanent_peer_key)); + assert!(keys.contains(&expiring_peer_key)); + } + + pub fn it_should_save_and_load_permanent_authentication_keys(driver: &Arc>) { + let peer_key = generate_permanent_key(); + driver.add_key_to_keys(&peer_key).unwrap(); + + let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).unwrap().unwrap(); + + assert_eq!(stored_peer_key, peer_key); + } + + pub fn it_should_save_and_load_expiring_authentication_keys(driver: &Arc>) { + let peer_key = generate_expiring_key(Duration::from_secs(120)); + driver.add_key_to_keys(&peer_key).unwrap(); + + let stored_peer_key = driver.get_key_from_keys(&peer_key.key()).unwrap().unwrap(); + + assert_eq!(stored_peer_key, peer_key); + assert_eq!(stored_peer_key.expiry_time(), peer_key.expiry_time()); + } + + pub fn it_should_remove_a_permanent_authentication_key(driver: &Arc>) { + let peer_key = generate_permanent_key(); + driver.add_key_to_keys(&peer_key).unwrap(); + + driver.remove_key_from_keys(&peer_key.key()).unwrap(); + + assert!(driver.get_key_from_keys(&peer_key.key()).unwrap().is_none()); + } + + pub fn it_should_remove_an_expiring_authentication_key(driver: &Arc>) { + let peer_key = generate_expiring_key(Duration::from_secs(120)); + driver.add_key_to_keys(&peer_key).unwrap(); + + driver.remove_key_from_keys(&peer_key.key()).unwrap(); + + assert!(driver.get_key_from_keys(&peer_key.key()).unwrap().is_none()); + } + } + + mod handling_the_whitelist { + + use std::sync::Arc; + + use crate::databases::Database; + use crate::test_helpers::tests::random_info_hash; + + pub fn it_should_load_the_whitelist(driver: &Arc>) { + let infohash = random_info_hash(); + driver.add_info_hash_to_whitelist(infohash).unwrap(); + + let whitelist = driver.load_whitelist().unwrap(); + + assert!(whitelist.contains(&infohash)); + } + + pub fn it_should_add_and_get_infohashes(driver: &Arc>) { + let infohash = random_info_hash(); + + driver.add_info_hash_to_whitelist(infohash).unwrap(); + + let stored_infohash = driver.get_info_hash_from_whitelist(infohash).unwrap().unwrap(); + + assert_eq!(stored_infohash, infohash); + } + + pub fn it_should_remove_an_infohash_from_the_whitelist(driver: &Arc>) { + let infohash = random_info_hash(); + driver.add_info_hash_to_whitelist(infohash).unwrap(); + + driver.remove_info_hash_from_whitelist(infohash).unwrap(); + + assert!(driver.get_info_hash_from_whitelist(infohash).unwrap().is_none()); + } + + pub fn it_should_fail_trying_to_add_the_same_infohash_twice(driver: &Arc>) { + let infohash = random_info_hash(); + + driver.add_info_hash_to_whitelist(infohash).unwrap(); + let result = driver.add_info_hash_to_whitelist(infohash); + + assert!(result.is_err()); + } + } +} diff --git a/packages/tracker-core/src/databases/driver/mysql.rs b/packages/tracker-core/src/databases/driver/mysql.rs new file mode 100644 index 000000000..da2f86ce8 --- /dev/null +++ b/packages/tracker-core/src/databases/driver/mysql.rs @@ -0,0 +1,483 @@ +//! The `MySQL` database driver. +//! +//! This module provides an implementation of the [`Database`] trait for `MySQL` +//! using the `r2d2_mysql` connection pool. It configures the MySQL connection +//! based on a URL, creates the necessary tables (for torrent metrics, torrent +//! whitelist, and authentication keys), and implements all CRUD operations +//! required by the persistence layer. +use std::str::FromStr; +use std::time::Duration; + +use bittorrent_primitives::info_hash::InfoHash; +use r2d2::Pool; +use r2d2_mysql::mysql::prelude::Queryable; +use r2d2_mysql::mysql::{params, Opts, OptsBuilder}; +use r2d2_mysql::MySqlConnectionManager; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use super::{Database, Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; +use crate::authentication::key::AUTH_KEY_LENGTH; +use crate::authentication::{self, Key}; + +const DRIVER: Driver = Driver::MySQL; + +/// `MySQL` driver implementation. +/// +/// This struct encapsulates a connection pool for `MySQL`, built using the +/// `r2d2_mysql` connection manager. It implements the [`Database`] trait to +/// provide persistence operations. +pub(crate) struct Mysql { + pool: Pool, +} + +impl Mysql { + /// It instantiates a new `MySQL` database driver. + /// + /// Refer to [`databases::Database::new`](crate::core::databases::Database::new). + /// + /// # Errors + /// + /// Will return `r2d2::Error` if `db_path` is not able to create `MySQL` database. + pub fn new(db_path: &str) -> Result { + let opts = Opts::from_url(db_path)?; + let builder = OptsBuilder::from_opts(opts); + let manager = MySqlConnectionManager::new(builder); + let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; + + Ok(Self { pool }) + } + + fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::( + "SELECT value FROM torrent_aggregate_metrics WHERE metric_name = :metric_name", + params! { "metric_name" => metric_name }, + ); + + let persistent_torrent = query?; + + Ok(persistent_torrent) + } + + fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + const COMMAND : &str = "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (:metric_name, :completed) ON DUPLICATE KEY UPDATE value = VALUES(value)"; + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + Ok(conn.exec_drop(COMMAND, params! { metric_name, completed })?) + } +} + +impl Database for Mysql { + /// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables). + fn create_database_tables(&self) -> Result<(), Error> { + let create_whitelist_table = " + CREATE TABLE IF NOT EXISTS whitelist ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE + );" + .to_string(); + + let create_torrents_table = " + CREATE TABLE IF NOT EXISTS torrents ( + id integer PRIMARY KEY AUTO_INCREMENT, + info_hash VARCHAR(40) NOT NULL UNIQUE, + completed INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id integer PRIMARY KEY AUTO_INCREMENT, + metric_name VARCHAR(50) NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + + let create_keys_table = format!( + " + CREATE TABLE IF NOT EXISTS `keys` ( + `id` INT NOT NULL AUTO_INCREMENT, + `key` VARCHAR({}) NOT NULL, + `valid_until` INT(10), + PRIMARY KEY (`id`), + UNIQUE (`key`) + );", + i8::try_from(AUTH_KEY_LENGTH).expect("authentication key length should fit within a i8!") + ); + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.query_drop(&create_torrents_table) + .expect("Could not create torrents table."); + conn.query_drop(&create_torrent_aggregate_metrics_table) + .expect("Could not create create_torrent_aggregate_metrics_table table."); + conn.query_drop(&create_keys_table).expect("Could not create keys table."); + conn.query_drop(&create_whitelist_table) + .expect("Could not create whitelist table."); + + Ok(()) + } + + /// Refer to [`databases::Database::drop_database_tables`](crate::core::databases::Database::drop_database_tables). + fn drop_database_tables(&self) -> Result<(), Error> { + let drop_whitelist_table = " + DROP TABLE `whitelist`;" + .to_string(); + + let drop_torrents_table = " + DROP TABLE `torrents`;" + .to_string(); + + let drop_keys_table = " + DROP TABLE `keys`;" + .to_string(); + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.query_drop(&drop_whitelist_table) + .expect("Could not drop `whitelist` table."); + conn.query_drop(&drop_torrents_table) + .expect("Could not drop `torrents` table."); + conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); + + Ok(()) + } + + /// Refer to [`databases::Database::load_persistent_torrents`](crate::core::databases::Database::load_persistent_torrents). + fn load_all_torrents_downloads(&self) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let torrents = conn.query_map( + "SELECT info_hash, completed FROM torrents", + |(info_hash_string, completed): (String, u32)| { + let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); + (info_hash, completed) + }, + )?; + + Ok(torrents.iter().copied().collect()) + } + + /// Refer to [`databases::Database::load_persistent_torrent`](crate::core::databases::Database::load_persistent_torrent). + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::( + "SELECT completed FROM torrents WHERE info_hash = :info_hash", + params! { "info_hash" => info_hash.to_hex_string() }, + ); + + let persistent_torrent = query?; + + Ok(persistent_torrent) + } + + /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). + fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + const COMMAND : &str = "INSERT INTO torrents (info_hash, completed) VALUES (:info_hash_str, :completed) ON DUPLICATE KEY UPDATE completed = VALUES(completed)"; + + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?) + } + + /// Refer to [`databases::Database::increase_number_of_downloads`](crate::core::databases::Database::increase_number_of_downloads). + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + conn.exec_drop( + "UPDATE torrents SET completed = completed + 1 WHERE info_hash = :info_hash_str", + params! { info_hash_str }, + )?; + + Ok(()) + } + + /// Refer to [`databases::Database::load_global_number_of_downloads`](crate::core::databases::Database::load_global_number_of_downloads). + fn load_global_downloads(&self) -> Result, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) + } + + /// Refer to [`databases::Database::save_global_number_of_downloads`](crate::core::databases::Database::save_global_number_of_downloads). + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) + } + + /// Refer to [`databases::Database::increase_global_number_of_downloads`](crate::core::databases::Database::increase_global_number_of_downloads). + fn increase_global_downloads(&self) -> Result<(), Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + conn.exec_drop( + "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = :metric_name", + params! { metric_name }, + )?; + + Ok(()) + } + + /// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys). + fn load_keys(&self) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let keys = conn.query_map( + "SELECT `key`, valid_until FROM `keys`", + |(key, valid_until): (String, Option)| match valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + }, + )?; + + Ok(keys) + } + + /// Refer to [`databases::Database::load_whitelist`](crate::core::databases::Database::load_whitelist). + fn load_whitelist(&self) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { + InfoHash::from_str(&info_hash).unwrap() + })?; + + Ok(info_hashes) + } + + /// Refer to [`databases::Database::get_info_hash_from_whitelist`](crate::core::databases::Database::get_info_hash_from_whitelist). + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let select = conn.exec_first::( + "SELECT info_hash FROM whitelist WHERE info_hash = :info_hash", + params! { "info_hash" => info_hash.to_hex_string() }, + )?; + + let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); + + Ok(info_hash) + } + + /// Refer to [`databases::Database::add_info_hash_to_whitelist`](crate::core::databases::Database::add_info_hash_to_whitelist). + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash_str = info_hash.to_string(); + + conn.exec_drop( + "INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)", + params! { info_hash_str }, + )?; + + Ok(1) + } + + /// Refer to [`databases::Database::remove_info_hash_from_whitelist`](crate::core::databases::Database::remove_info_hash_from_whitelist). + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let info_hash = info_hash.to_string(); + + conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?; + + Ok(1) + } + + /// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys). + fn get_key_from_keys(&self, key: &Key) -> Result, Error> { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let query = conn.exec_first::<(String, Option), _, _>( + "SELECT `key`, valid_until FROM `keys` WHERE `key` = :key", + params! { "key" => key.to_string() }, + ); + + let key = query?; + + Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { + Some(valid_until) => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), + }, + None => authentication::PeerKey { + key: key.parse::().unwrap(), + valid_until: None, + }, + })) + } + + /// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys). + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + match auth_key.valid_until { + Some(valid_until) => conn.exec_drop( + "INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)", + params! { "key" => auth_key.key.to_string(), "valid_until" => valid_until.as_secs().to_string() }, + )?, + None => conn.exec_drop( + "INSERT INTO `keys` (`key`) VALUES (:key)", + params! { "key" => auth_key.key.to_string() }, + )?, + } + + Ok(1) + } + + /// Refer to [`databases::Database::remove_key_from_keys`](crate::core::databases::Database::remove_key_from_keys). + fn remove_key_from_keys(&self, key: &Key) -> Result { + let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + conn.exec_drop("DELETE FROM `keys` WHERE `key` = :key", params! { "key" => key.to_string() })?; + + Ok(1) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use testcontainers::core::IntoContainerPort; + /* + We run a MySQL container and run all the tests against the same container and database. + + Test for this driver are executed with: + + `TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST=true cargo test` + + The `Database` trait is very simple and we only have one driver that needs + a container. In the future we might want to use different approaches like: + + - https://github.com/testcontainers/testcontainers-rs/issues/707 + - https://www.infinyon.com/blog/2021/04/rust-custom-test-harness/ + - https://github.com/torrust/torrust-tracker/blob/develop/src/bin/e2e_tests_runner.rs + + If we increase the number of methods or the number or drivers. + */ + use testcontainers::runners::AsyncRunner; + use testcontainers::{ContainerAsync, GenericImage, ImageExt}; + use torrust_tracker_configuration::Core; + + use super::Mysql; + use crate::databases::driver::tests::run_tests; + use crate::databases::Database; + + #[derive(Debug, Default)] + struct StoppedMysqlContainer {} + + impl StoppedMysqlContainer { + async fn run(self, config: &MysqlConfiguration) -> Result> { + let container = GenericImage::new("mysql", "8.0") + .with_exposed_port(config.internal_port.tcp()) + // todo: this does not work + //.with_wait_for(WaitFor::message_on_stdout("ready for connections")) + .with_env_var("MYSQL_ROOT_PASSWORD", config.db_root_password.clone()) + .with_env_var("MYSQL_DATABASE", config.database.clone()) + .with_env_var("MYSQL_ROOT_HOST", "%") + .start() + .await?; + + Ok(RunningMysqlContainer::new(container, config.internal_port)) + } + } + + struct RunningMysqlContainer { + container: ContainerAsync, + internal_port: u16, + } + + impl RunningMysqlContainer { + fn new(container: ContainerAsync, internal_port: u16) -> Self { + Self { + container, + internal_port, + } + } + + async fn stop(self) { + self.container.stop().await.unwrap(); + } + + async fn get_host(&self) -> url::Host { + self.container.get_host().await.unwrap() + } + + async fn get_host_port_ipv4(&self) -> u16 { + self.container.get_host_port_ipv4(self.internal_port).await.unwrap() + } + } + + impl Default for MysqlConfiguration { + fn default() -> Self { + Self { + internal_port: 3306, + database: "torrust_tracker_test".to_string(), + db_user: "root".to_string(), + db_root_password: "test".to_string(), + } + } + } + + struct MysqlConfiguration { + pub internal_port: u16, + pub database: String, + pub db_user: String, + pub db_root_password: String, + } + + fn core_configuration(host: &url::Host, port: u16, mysql_configuration: &MysqlConfiguration) -> Core { + let mut config = Core::default(); + + let database = mysql_configuration.database.clone(); + let db_user = mysql_configuration.db_user.clone(); + let db_password = mysql_configuration.db_root_password.clone(); + + config.database.path = format!("mysql://{db_user}:{db_password}@{host}:{port}/{database}"); + + config + } + + fn initialize_driver(config: &Core) -> Arc> { + let driver: Arc> = Arc::new(Box::new(Mysql::new(&config.database.path).unwrap())); + driver + } + + #[tokio::test] + async fn run_mysql_driver_tests() -> Result<(), Box> { + if std::env::var("TORRUST_TRACKER_CORE_RUN_MYSQL_DRIVER_TEST").is_err() { + println!("Skipping the MySQL driver tests."); + return Ok(()); + } + + let mysql_configuration = MysqlConfiguration::default(); + + let stopped_mysql_container = StoppedMysqlContainer::default(); + + let mysql_container = stopped_mysql_container.run(&mysql_configuration).await.unwrap(); + + let host = mysql_container.get_host().await; + let port = mysql_container.get_host_port_ipv4().await; + + let config = core_configuration(&host, port, &mysql_configuration); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + mysql_container.stop().await; + + Ok(()) + } +} diff --git a/src/core/databases/sqlite.rs b/packages/tracker-core/src/databases/driver/sqlite.rs similarity index 56% rename from src/core/databases/sqlite.rs rename to packages/tracker-core/src/databases/driver/sqlite.rs index 69470ee04..d08351aa8 100644 --- a/src/core/databases/sqlite.rs +++ b/packages/tracker-core/src/databases/driver/sqlite.rs @@ -1,37 +1,90 @@ //! The `SQLite3` database driver. +//! +//! This module provides an implementation of the [`Database`] trait for +//! `SQLite3` using the `r2d2_sqlite` connection pool. It defines the schema for +//! whitelist, torrent metrics, and authentication keys, and provides methods +//! to create and drop tables as well as perform CRUD operations on these +//! persistent objects. use std::panic::Location; use std::str::FromStr; +use bittorrent_primitives::info_hash::InfoHash; use r2d2::Pool; use r2d2_sqlite::rusqlite::params; use r2d2_sqlite::rusqlite::types::Null; use r2d2_sqlite::SqliteConnectionManager; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::{DurationSinceUnixEpoch, PersistentTorrents}; +use torrust_tracker_primitives::{DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; -use super::driver::Driver; -use super::{Database, Error}; -use crate::core::auth::{self, Key}; +use super::{Database, Driver, Error, TORRENTS_DOWNLOADS_TOTAL}; +use crate::authentication::{self, Key}; const DRIVER: Driver = Driver::Sqlite3; -pub struct Sqlite { +/// `SQLite` driver implementation. +/// +/// This struct encapsulates a connection pool for `SQLite` using the `r2d2_sqlite` +/// connection manager. +pub(crate) struct Sqlite { pool: Pool, } -impl Database for Sqlite { - /// It instantiates a new `SQLite3` database driver. +impl Sqlite { + /// Instantiates a new `SQLite3` database driver. + /// + /// This function creates a connection manager for the `SQLite` database + /// located at `db_path` and then builds a connection pool using `r2d2`. If + /// the pool cannot be created, an error is returned (wrapped with the + /// appropriate driver information). /// - /// Refer to [`databases::Database::new`](crate::core::databases::Database::new). + /// # Arguments + /// + /// * `db_path` - A string slice representing the file path to the `SQLite` database. /// /// # Errors /// - /// Will return `r2d2::Error` if `db_path` is not able to create `SqLite` database. - fn new(db_path: &str) -> Result { - let cm = SqliteConnectionManager::file(db_path); - Pool::new(cm).map_or_else(|err| Err((err, Driver::Sqlite3).into()), |pool| Ok(Sqlite { pool })) + /// Returns an [`Error`] if the connection pool cannot be built. + pub fn new(db_path: &str) -> Result { + let manager = SqliteConnectionManager::file(db_path); + let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; + + Ok(Self { pool }) + } + + fn load_torrent_aggregate_metric(&self, metric_name: &str) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT value FROM torrent_aggregate_metrics WHERE metric_name = ?")?; + + let mut rows = stmt.query([metric_name])?; + + let persistent_torrent = rows.next()?; + + Ok(persistent_torrent.map(|f| { + let value: i64 = f.get(0).unwrap(); + u32::try_from(value).unwrap() + })) + } + + fn save_torrent_aggregate_metric(&self, metric_name: &str, completed: NumberOfDownloads) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = conn.execute( + "INSERT INTO torrent_aggregate_metrics (metric_name, value) VALUES (?1, ?2) ON CONFLICT(metric_name) DO UPDATE SET value = ?2", + [metric_name.to_string(), completed.to_string()], + )?; + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + Ok(()) + } } +} +impl Database for Sqlite { /// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables). fn create_database_tables(&self) -> Result<(), Error> { let create_whitelist_table = " @@ -49,6 +102,14 @@ impl Database for Sqlite { );" .to_string(); + let create_torrent_aggregate_metrics_table = " + CREATE TABLE IF NOT EXISTS torrent_aggregate_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + metric_name TEXT NOT NULL UNIQUE, + value INTEGER DEFAULT 0 NOT NULL + );" + .to_string(); + let create_keys_table = " CREATE TABLE IF NOT EXISTS keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -62,6 +123,7 @@ impl Database for Sqlite { conn.execute(&create_whitelist_table, [])?; conn.execute(&create_keys_table, [])?; conn.execute(&create_torrents_table, [])?; + conn.execute(&create_torrent_aggregate_metrics_table, [])?; Ok(()) } @@ -90,7 +152,7 @@ impl Database for Sqlite { } /// Refer to [`databases::Database::load_persistent_torrents`](crate::core::databases::Database::load_persistent_torrents). - fn load_persistent_torrents(&self) -> Result { + fn load_all_torrents_downloads(&self) -> Result { let conn = self.pool.get().map_err(|e| (e, DRIVER))?; let mut stmt = conn.prepare("SELECT info_hash, completed FROM torrents")?; @@ -105,8 +167,79 @@ impl Database for Sqlite { Ok(torrent_iter.filter_map(std::result::Result::ok).collect()) } + /// Refer to [`databases::Database::load_persistent_torrent`](crate::core::databases::Database::load_persistent_torrent). + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let mut stmt = conn.prepare("SELECT completed FROM torrents WHERE info_hash = ?")?; + + let mut rows = stmt.query([info_hash.to_hex_string()])?; + + let persistent_torrent = rows.next()?; + + Ok(persistent_torrent.map(|f| { + let completed: i64 = f.get(0).unwrap(); + u32::try_from(completed).unwrap() + })) + } + + /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). + fn save_torrent_downloads(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let insert = conn.execute( + "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", + [info_hash.to_string(), completed.to_string()], + )?; + + if insert == 0 { + Err(Error::InsertFailed { + location: Location::caller(), + driver: DRIVER, + }) + } else { + Ok(()) + } + } + + /// Refer to [`databases::Database::increase_number_of_downloads`](crate::core::databases::Database::increase_number_of_downloads). + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let _ = conn.execute( + "UPDATE torrents SET completed = completed + 1 WHERE info_hash = ?", + [info_hash.to_string()], + )?; + + Ok(()) + } + + /// Refer to [`databases::Database::load_global_number_of_downloads`](crate::core::databases::Database::load_global_number_of_downloads). + fn load_global_downloads(&self) -> Result, Error> { + self.load_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL) + } + + /// Refer to [`databases::Database::save_global_number_of_downloads`](crate::core::databases::Database::save_global_number_of_downloads). + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error> { + self.save_torrent_aggregate_metric(TORRENTS_DOWNLOADS_TOTAL, downloaded) + } + + /// Refer to [`databases::Database::increase_global_number_of_downloads`](crate::core::databases::Database::increase_global_number_of_downloads). + fn increase_global_downloads(&self) -> Result<(), Error> { + let conn = self.pool.get().map_err(|e| (e, DRIVER))?; + + let metric_name = TORRENTS_DOWNLOADS_TOTAL; + + let _ = conn.execute( + "UPDATE torrent_aggregate_metrics SET value = value + 1 WHERE metric_name = ?", + [metric_name], + )?; + + Ok(()) + } + /// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys). - fn load_keys(&self) -> Result, Error> { + fn load_keys(&self) -> Result, Error> { let conn = self.pool.get().map_err(|e| (e, DRIVER))?; let mut stmt = conn.prepare("SELECT key, valid_until FROM keys")?; @@ -116,18 +249,18 @@ impl Database for Sqlite { let opt_valid_until: Option = row.get(1)?; match opt_valid_until { - Some(valid_until) => Ok(auth::PeerKey { + Some(valid_until) => Ok(authentication::PeerKey { key: key.parse::().unwrap(), valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), }), - None => Ok(auth::PeerKey { + None => Ok(authentication::PeerKey { key: key.parse::().unwrap(), valid_until: None, }), } })?; - let keys: Vec = keys_iter.filter_map(std::result::Result::ok).collect(); + let keys: Vec = keys_iter.filter_map(std::result::Result::ok).collect(); Ok(keys) } @@ -149,25 +282,6 @@ impl Database for Sqlite { Ok(info_hashes) } - /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). - fn save_persistent_torrent(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - let conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let insert = conn.execute( - "INSERT INTO torrents (info_hash, completed) VALUES (?1, ?2) ON CONFLICT(info_hash) DO UPDATE SET completed = ?2", - [info_hash.to_string(), completed.to_string()], - )?; - - if insert == 0 { - Err(Error::InsertFailed { - location: Location::caller(), - driver: DRIVER, - }) - } else { - Ok(()) - } - } - /// Refer to [`databases::Database::get_info_hash_from_whitelist`](crate::core::databases::Database::get_info_hash_from_whitelist). fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { let conn = self.pool.get().map_err(|e| (e, DRIVER))?; @@ -216,7 +330,7 @@ impl Database for Sqlite { } /// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys). - fn get_key_from_keys(&self, key: &Key) -> Result, Error> { + fn get_key_from_keys(&self, key: &Key) -> Result, Error> { let conn = self.pool.get().map_err(|e| (e, DRIVER))?; let mut stmt = conn.prepare("SELECT key, valid_until FROM keys WHERE key = ?")?; @@ -230,11 +344,11 @@ impl Database for Sqlite { let key: String = f.get(0).unwrap(); match valid_until { - Some(valid_until) => auth::PeerKey { + Some(valid_until) => authentication::PeerKey { key: key.parse::().unwrap(), valid_until: Some(DurationSinceUnixEpoch::from_secs(valid_until.unsigned_abs())), }, - None => auth::PeerKey { + None => authentication::PeerKey { key: key.parse::().unwrap(), valid_until: None, }, @@ -243,7 +357,7 @@ impl Database for Sqlite { } /// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys). - fn add_key_to_keys(&self, auth_key: &auth::PeerKey) -> Result { + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result { let conn = self.pool.get().map_err(|e| (e, DRIVER))?; let insert = match auth_key.valid_until { @@ -285,3 +399,39 @@ impl Database for Sqlite { } } } + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + + use crate::databases::driver::sqlite::Sqlite; + use crate::databases::driver::tests::run_tests; + use crate::databases::Database; + + fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + config + } + + fn initialize_driver(config: &Core) -> Arc> { + let driver: Arc> = Arc::new(Box::new(Sqlite::new(&config.database.path).unwrap())); + driver + } + + #[tokio::test] + async fn run_sqlite_driver_tests() -> Result<(), Box> { + let config = ephemeral_configuration(); + + let driver = initialize_driver(&config); + + run_tests(&driver).await; + + Ok(()) + } +} diff --git a/packages/tracker-core/src/databases/error.rs b/packages/tracker-core/src/databases/error.rs new file mode 100644 index 000000000..2df2cb277 --- /dev/null +++ b/packages/tracker-core/src/databases/error.rs @@ -0,0 +1,173 @@ +//! Database errors. +//! +//! This module defines the [`Error`] enum used to represent errors that occur +//! during database operations. These errors encapsulate issues such as missing +//! query results, malformed queries, connection failures, and connection pool +//! creation errors. Each error variant includes contextual information such as +//! the associated database driver and, when applicable, the source error. +//! +//! External errors from database libraries (e.g., `rusqlite`, `mysql`) are +//! converted into this error type using the provided `From` implementations. +use std::panic::Location; +use std::sync::Arc; + +use r2d2_mysql::mysql::UrlError; +use torrust_tracker_located_error::{DynError, Located, LocatedError}; + +use super::driver::Driver; + +/// Database error type that encapsulates various failures encountered during +/// database operations. +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + /// Indicates that a query unexpectedly returned no rows. + /// + /// This error variant is used when a query that is expected to return a + /// result does not. + #[error("The {driver} query unexpectedly returned nothing: {source}")] + QueryReturnedNoRows { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + driver: Driver, + }, + + /// Indicates that the query was malformed. + /// + /// This error variant is used when the SQL query itself is invalid or + /// improperly formatted. + #[error("The {driver} query was malformed: {source}")] + InvalidQuery { + source: LocatedError<'static, dyn std::error::Error + Send + Sync>, + driver: Driver, + }, + + /// Indicates a failure to insert a record into the database. + /// + /// This error is raised when an insertion operation fails. + #[error("Unable to insert record into {driver} database, {location}")] + InsertFailed { + location: &'static Location<'static>, + driver: Driver, + }, + + /// Indicates a failure to update a record into the database. + /// + /// This error is raised when an insertion operation fails. + #[error("Unable to update record into {driver} database, {location}")] + UpdateFailed { + location: &'static Location<'static>, + driver: Driver, + }, + + /// Indicates a failure to delete a record from the database. + /// + /// This error includes an error code that may be returned by the database + /// driver. + #[error("Failed to remove record from {driver} database, error-code: {error_code}, {location}")] + DeleteFailed { + location: &'static Location<'static>, + error_code: usize, + driver: Driver, + }, + + /// Indicates a failure to connect to the database. + /// + /// This error variant wraps connection-related errors, such as those caused by an invalid URL. + #[error("Failed to connect to {driver} database: {source}")] + ConnectionError { + source: LocatedError<'static, UrlError>, + driver: Driver, + }, + + /// Indicates a failure to create a connection pool. + /// + /// This error variant is used when the connection pool creation (using r2d2) fails. + #[error("Failed to create r2d2 {driver} connection pool: {source}")] + ConnectionPool { + source: LocatedError<'static, r2d2::Error>, + driver: Driver, + }, +} + +impl From for Error { + #[track_caller] + fn from(err: r2d2_sqlite::rusqlite::Error) -> Self { + match err { + r2d2_sqlite::rusqlite::Error::QueryReturnedNoRows => Error::QueryReturnedNoRows { + source: (Arc::new(err) as DynError).into(), + driver: Driver::Sqlite3, + }, + _ => Error::InvalidQuery { + source: (Arc::new(err) as DynError).into(), + driver: Driver::Sqlite3, + }, + } + } +} + +impl From for Error { + #[track_caller] + fn from(err: r2d2_mysql::mysql::Error) -> Self { + let e: DynError = Arc::new(err); + Error::InvalidQuery { + source: e.into(), + driver: Driver::MySQL, + } + } +} + +impl From for Error { + #[track_caller] + fn from(err: UrlError) -> Self { + Self::ConnectionError { + source: Located(err).into(), + driver: Driver::MySQL, + } + } +} + +impl From<(r2d2::Error, Driver)> for Error { + #[track_caller] + fn from(e: (r2d2::Error, Driver)) -> Self { + let (err, driver) = e; + Self::ConnectionPool { + source: Located(err).into(), + driver, + } + } +} + +#[cfg(test)] +mod tests { + use r2d2_mysql::mysql; + + use crate::databases::error::Error; + + #[test] + fn it_should_build_a_database_error_from_a_rusqlite_error() { + let err: Error = r2d2_sqlite::rusqlite::Error::InvalidQuery.into(); + + assert!(matches!(err, Error::InvalidQuery { .. })); + } + + #[test] + fn it_should_build_an_specific_database_error_from_a_no_rows_returned_rusqlite_error() { + let err: Error = r2d2_sqlite::rusqlite::Error::QueryReturnedNoRows.into(); + + assert!(matches!(err, Error::QueryReturnedNoRows { .. })); + } + + #[test] + fn it_should_build_a_database_error_from_a_mysql_error() { + let url_err = mysql::error::UrlError::BadUrl; + let err: Error = r2d2_mysql::mysql::Error::UrlError(url_err).into(); + + assert!(matches!(err, Error::InvalidQuery { .. })); + } + + #[test] + fn it_should_build_a_database_error_from_a_mysql_url_error() { + let err: Error = mysql::error::UrlError::BadUrl.into(); + + assert!(matches!(err, Error::ConnectionError { .. })); + } +} diff --git a/packages/tracker-core/src/databases/mod.rs b/packages/tracker-core/src/databases/mod.rs new file mode 100644 index 000000000..c9d89769a --- /dev/null +++ b/packages/tracker-core/src/databases/mod.rs @@ -0,0 +1,272 @@ +//! The persistence module. +//! +//! Persistence is currently implemented using a single [`Database`] trait. +//! +//! There are two implementations of the trait (two drivers): +//! +//! - **`MySQL`** +//! - **`Sqlite`** +//! +//! > **NOTICE**: There are no database migrations at this time. If schema +//! > changes occur, either migration functionality will be implemented or a +//! > script will be provided to migrate to the new schema. +//! +//! The persistent objects handled by this module include: +//! +//! - **Torrent metrics**: Metrics such as the number of completed downloads for +//! each torrent. +//! - **Torrent whitelist**: A list of torrents (by infohash) that are allowed. +//! - **Authentication keys**: Expiring authentication keys used to secure +//! access to private trackers. +//! +//! # Torrent Metrics +//! +//! | Field | Sample data | Description | +//! |-------------|--------------------------------------------|-----------------------------------------------------------------------------| +//! | `id` | 1 | Auto-increment id | +//! | `info_hash` | `c1277613db1d28709b034a017ab2cae4be07ae10` | `BitTorrent` infohash V1 | +//! | `completed` | 20 | The number of peers that have completed downloading the associated torrent. | +//! +//! > **NOTICE**: The peer list for a torrent is not persisted. Because peers re-announce at +//! > intervals, the peer list is regenerated periodically. +//! +//! # Torrent Whitelist +//! +//! | Field | Sample data | Description | +//! |-------------|--------------------------------------------|--------------------------------| +//! | `id` | 1 | Auto-increment id | +//! | `info_hash` | `c1277613db1d28709b034a017ab2cae4be07ae10` | `BitTorrent` infohash V1 | +//! +//! # Authentication Keys +//! +//! | Field | Sample data | Description | +//! |---------------|------------------------------------|--------------------------------------| +//! | `id` | 1 | Auto-increment id | +//! | `key` | `IrweYtVuQPGbG9Jzx1DihcPmJGGpVy82` | Authentication token (32 chars) | +//! | `valid_until` | 1672419840 | Timestamp indicating expiration time | +//! +//! > **NOTICE**: All authentication keys must have an expiration date. +pub mod driver; +pub mod error; +pub mod setup; + +use bittorrent_primitives::info_hash::InfoHash; +use mockall::automock; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use self::error::Error; +use crate::authentication::{self, Key}; + +/// The persistence trait. +/// +/// This trait defines all the methods required to interact with the database, +/// including creating and dropping schema tables, and CRUD operations for +/// torrent metrics, whitelists, and authentication keys. Implementations of +/// this trait must ensure that operations are safe, consistent, and report +/// errors using the [`Error`] type. +#[automock] +pub trait Database: Sync + Send { + /// Creates the necessary database tables. + /// + /// The SQL queries for table creation are hardcoded in the trait implementation. + /// + /// # Context: Schema + /// + /// # Errors + /// + /// Returns an [`Error`] if the tables cannot be created. + fn create_database_tables(&self) -> Result<(), Error>; + + /// Drops the database tables. + /// + /// This operation removes the persistent schema. + /// + /// # Context: Schema + /// + /// # Errors + /// + /// Returns an [`Error`] if the tables cannot be dropped. + fn drop_database_tables(&self) -> Result<(), Error>; + + // Torrent Metrics + + /// Loads torrent metrics data from the database for all torrents. + /// + /// This function returns the persistent torrent metrics as a collection of + /// tuples, where each tuple contains an [`InfoHash`] and the `downloaded` + /// counter (i.e. the number of times the torrent has been downloaded). + /// + /// # Context: Torrent Metrics + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be loaded. + fn load_all_torrents_downloads(&self) -> Result; + + /// Loads torrent metrics data from the database for one torrent. + /// + /// # Context: Torrent Metrics + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be loaded. + fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error>; + + /// Saves torrent metrics data into the database. + /// + /// # Arguments + /// + /// * `info_hash` - A reference to the torrent's info hash. + /// * `downloaded` - The number of times the torrent has been downloaded. + /// + /// # Context: Torrent Metrics + /// + /// # Errors + /// + /// Returns an [`Error`] if the metrics cannot be saved. + fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error>; + + /// Increases the number of downloads for a given torrent. + /// + /// It does not create a new entry if the torrent is not found and it does + /// not return an error. + /// + /// # Context: Torrent Metrics + /// + /// # Arguments + /// + /// * `info_hash` - A reference to the torrent's info hash. + /// + /// # Errors + /// + /// Returns an [`Error`] if the query failed. + fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error>; + + /// Loads the total number of downloads for all torrents from the database. + /// + /// # Context: Torrent Metrics + /// + /// # Errors + /// + /// Returns an [`Error`] if the total downloads cannot be loaded. + fn load_global_downloads(&self) -> Result, Error>; + + /// Saves the total number of downloads for all torrents into the database. + /// + /// # Context: Torrent Metrics + /// + /// # Arguments + /// + /// * `info_hash` - A reference to the torrent's info hash. + /// * `downloaded` - The number of times the torrent has been downloaded. + /// + /// # Errors + /// + /// Returns an [`Error`] if the total downloads cannot be saved. + fn save_global_downloads(&self, downloaded: NumberOfDownloads) -> Result<(), Error>; + + /// Increases the total number of downloads for all torrents. + /// + /// # Context: Torrent Metrics + /// + /// # Errors + /// + /// Returns an [`Error`] if the query failed. + fn increase_global_downloads(&self) -> Result<(), Error>; + + // Whitelist + + /// Loads the whitelisted torrents from the database. + /// + /// # Context: Whitelist + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be loaded. + fn load_whitelist(&self) -> Result, Error>; + + /// Retrieves a whitelisted torrent from the database. + /// + /// Returns `Some(InfoHash)` if the torrent is in the whitelist, or `None` + /// otherwise. + /// + /// # Context: Whitelist + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be queried. + fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error>; + + /// Adds a torrent to the whitelist. + /// + /// # Context: Whitelist + /// + /// # Errors + /// + /// Returns an [`Error`] if the torrent cannot be added to the whitelist. + fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result; + + /// Checks whether a torrent is whitelisted. + /// + /// This default implementation returns `true` if the infohash is included + /// in the whitelist, or `false` otherwise. + /// + /// # Context: Whitelist + /// + /// # Errors + /// + /// Returns an [`Error`] if the whitelist cannot be queried. + fn is_info_hash_whitelisted(&self, info_hash: InfoHash) -> Result { + Ok(self.get_info_hash_from_whitelist(info_hash)?.is_some()) + } + + /// Removes a torrent from the whitelist. + /// + /// # Context: Whitelist + /// + /// # Errors + /// + /// Returns an [`Error`] if the torrent cannot be removed from the whitelist. + fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result; + + // Authentication keys + + /// Loads all authentication keys from the database. + /// + /// # Context: Authentication Keys + /// + /// # Errors + /// + /// Returns an [`Error`] if the keys cannot be loaded. + fn load_keys(&self) -> Result, Error>; + + /// Retrieves a specific authentication key from the database. + /// + /// Returns `Some(PeerKey)` if a key corresponding to the provided [`Key`] + /// exists, or `None` otherwise. + /// + /// # Context: Authentication Keys + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be queried. + fn get_key_from_keys(&self, key: &Key) -> Result, Error>; + + /// Adds an authentication key to the database. + /// + /// # Context: Authentication Keys + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be saved. + fn add_key_to_keys(&self, auth_key: &authentication::PeerKey) -> Result; + + /// Removes an authentication key from the database. + /// + /// # Context: Authentication Keys + /// + /// # Errors + /// + /// Returns an [`Error`] if the key cannot be removed. + fn remove_key_from_keys(&self, key: &Key) -> Result; +} diff --git a/packages/tracker-core/src/databases/setup.rs b/packages/tracker-core/src/databases/setup.rs new file mode 100644 index 000000000..6ba9f2a64 --- /dev/null +++ b/packages/tracker-core/src/databases/setup.rs @@ -0,0 +1,61 @@ +//! This module provides functionality for setting up databases. +use std::sync::Arc; + +use torrust_tracker_configuration::Core; + +use super::driver::{self, Driver}; +use super::Database; + +/// Initializes and returns a database instance based on the provided configuration. +/// +/// This function creates a new database instance according to the settings +/// defined in the [`Core`] configuration. It selects the appropriate driver +/// (either `Sqlite3` or `MySQL`) as specified in `config.database.driver` and +/// attempts to build the database connection using the path defined in +/// `config.database.path`. +/// +/// The resulting database instance is wrapped in a shared pointer (`Arc`) to a +/// boxed trait object, allowing safe sharing of the database connection across +/// multiple threads. +/// +/// # Panics +/// +/// This function will panic if the database cannot be initialized (i.e., if the +/// driver fails to build the connection). This is enforced by the use of +/// [`expect`](std::result::Result::expect) in the implementation. +/// +/// # Example +/// +/// ```rust,no_run +/// use torrust_tracker_configuration::Core; +/// use bittorrent_tracker_core::databases::setup::initialize_database; +/// +/// // Create a default configuration (ensure it is properly set up for your environment) +/// let config = Core::default(); +/// +/// // Initialize the database; this will panic if initialization fails. +/// let database = initialize_database(&config); +/// +/// // The returned database instance can now be used for persistence operations. +/// ``` +#[must_use] +pub fn initialize_database(config: &Core) -> Arc> { + let driver = match config.database.driver { + torrust_tracker_configuration::Driver::Sqlite3 => Driver::Sqlite3, + torrust_tracker_configuration::Driver::MySQL => Driver::MySQL, + }; + + Arc::new(driver::build(&driver, &config.database.path).expect("Database driver build failed.")) +} + +#[cfg(test)] +mod tests { + use super::initialize_database; + use crate::test_helpers::tests::ephemeral_configuration; + + #[test] + fn it_should_initialize_the_sqlite_database() { + let config = ephemeral_configuration(); + let _database = initialize_database(&config); + } +} diff --git a/packages/tracker-core/src/error.rs b/packages/tracker-core/src/error.rs new file mode 100644 index 000000000..866aa64c5 --- /dev/null +++ b/packages/tracker-core/src/error.rs @@ -0,0 +1,205 @@ +//! Core tracker errors. +//! +//! This module defines the error types used internally by the `BitTorrent` +//! tracker core. +//! +//! These errors encapsulate issues such as whitelisting violations, invalid +//! peer key data, and database persistence failures. Each error variant +//! includes contextual information (such as source code location) to facilitate +//! debugging. +use std::panic::Location; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_located_error::LocatedError; + +use super::authentication::key::ParseKeyError; +use super::databases; +use crate::authentication; + +/// Wrapper for all errors returned by the tracker core. +#[derive(thiserror::Error, Debug, Clone)] +pub enum TrackerCoreError { + /// Error returned when there was an error with the tracker core announce handler. + #[error("Tracker core announce error: {source}")] + AnnounceError { source: AnnounceError }, + + /// Error returned when there was an error with the tracker core scrape handler. + #[error("Tracker core scrape error: {source}")] + ScrapeError { source: ScrapeError }, + + /// Error returned when there was an error with the tracker core whitelist. + #[error("Tracker core whitelist error: {source}")] + WhitelistError { source: WhitelistError }, + + /// Error returned when there was an error with the authentication in the tracker core. + #[error("Tracker core authentication error: {source}")] + AuthenticationError { source: authentication::key::Error }, +} + +impl From for TrackerCoreError { + fn from(announce_error: AnnounceError) -> Self { + Self::AnnounceError { source: announce_error } + } +} + +impl From for TrackerCoreError { + fn from(scrape_error: ScrapeError) -> Self { + Self::ScrapeError { source: scrape_error } + } +} + +impl From for TrackerCoreError { + fn from(whitelist_error: WhitelistError) -> Self { + Self::WhitelistError { source: whitelist_error } + } +} + +impl From for TrackerCoreError { + fn from(whitelist_error: authentication::key::Error) -> Self { + Self::AuthenticationError { source: whitelist_error } + } +} + +/// Errors related to announce requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum AnnounceError { + /// Wraps errors related to torrent whitelisting. + #[error("Whitelist error: {0}")] + Whitelist(#[from] WhitelistError), + + /// Wraps errors related to database. + #[error("Database error: {0}")] + Database(#[from] databases::error::Error), +} + +/// Errors related to scrape requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum ScrapeError { + /// Wraps errors related to torrent whitelisting. + #[error("Whitelist error: {0}")] + Whitelist(#[from] WhitelistError), +} + +/// Errors related to torrent whitelisting. +/// +/// This error is returned when an operation involves a torrent that is not +/// present in the whitelist. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +pub enum WhitelistError { + /// Indicates that the torrent identified by `info_hash` is not whitelisted. + #[error("The torrent: {info_hash}, is not whitelisted, {location}")] + TorrentNotWhitelisted { + info_hash: InfoHash, + location: &'static Location<'static>, + }, +} + +/// Errors related to peer key operations. +/// +/// This error type covers issues encountered during the handling of peer keys, +/// including validation of key durations, parsing errors, and database +/// persistence problems. +#[allow(clippy::module_name_repetitions)] +#[derive(thiserror::Error, Debug, Clone)] +pub enum PeerKeyError { + /// Returned when the duration specified for the peer key exceeds the + /// maximum. + #[error("Invalid peer key duration: {seconds_valid:?}, is not valid")] + DurationOverflow { seconds_valid: u64 }, + + /// Returned when the provided peer key is invalid. + #[error("Invalid key: {key}")] + InvalidKey { + key: String, + source: LocatedError<'static, ParseKeyError>, + }, + + /// Returned when persisting the peer key to the database fails. + #[error("Can't persist key: {source}")] + DatabaseError { + source: LocatedError<'static, databases::error::Error>, + }, +} + +#[cfg(test)] +mod tests { + + mod whitelist_error { + + use crate::error::WhitelistError; + use crate::test_helpers::tests::sample_info_hash; + + #[test] + fn torrent_not_whitelisted() { + let err = WhitelistError::TorrentNotWhitelisted { + info_hash: sample_info_hash(), + location: std::panic::Location::caller(), + }; + + let err_msg = format!("{err}"); + + assert!( + err_msg.contains(&format!("The torrent: {}, is not whitelisted", sample_info_hash())), + "Error message did not contain expected text: {err_msg}" + ); + } + } + + mod peer_key_error { + use torrust_tracker_located_error::Located; + + use crate::databases::driver::Driver; + use crate::error::PeerKeyError; + use crate::{authentication, databases}; + + #[test] + fn duration_overflow() { + let seconds_valid = 100; + + let err = PeerKeyError::DurationOverflow { seconds_valid }; + + let err_msg = format!("{err}"); + + assert!( + err_msg.contains(&format!("Invalid peer key duration: {seconds_valid}")), + "Error message did not contain expected text: {err_msg}" + ); + } + + #[test] + fn parsing_from_string() { + let err = authentication::key::ParseKeyError::InvalidKeyLength; + + let err = PeerKeyError::InvalidKey { + key: "INVALID KEY".to_string(), + source: Located(err).into(), + }; + + let err_msg = format!("{err}"); + + assert!( + err_msg.contains(&"Invalid key: INVALID KEY".to_string()), + "Error message did not contain expected text: {err_msg}" + ); + } + + #[test] + fn persisting_into_database() { + let err = databases::error::Error::InsertFailed { + location: std::panic::Location::caller(), + driver: Driver::Sqlite3, + }; + + let err = PeerKeyError::DatabaseError { + source: Located(err).into(), + }; + + let err_msg = format!("{err}"); + + assert!( + err_msg.contains(&"Can't persist key".to_string()), + "Error message did not contain expected text: {err}" + ); + } + } +} diff --git a/packages/tracker-core/src/lib.rs b/packages/tracker-core/src/lib.rs new file mode 100644 index 000000000..5167abf51 --- /dev/null +++ b/packages/tracker-core/src/lib.rs @@ -0,0 +1,273 @@ +//! The core `bittorrent-tracker-core` crate contains the generic `BitTorrent` +//! tracker logic which is independent of the delivery layer. +//! +//! It contains the tracker services and their dependencies. It's a domain layer +//! which does not specify how the end user should connect to the `Tracker`. +//! +//! Typically this crate is intended to be used by higher components like: +//! +//! - A UDP tracker +//! - A HTTP tracker +//! - A tracker REST API +//! +//! ```text +//! Delivery layer | Domain layer +//! ----------------------------------- +//! HTTP tracker | +//! UDP tracker |-> Core tracker +//! Tracker REST API | +//! ``` +//! +//! # Table of contents +//! +//! - [Introduction](#introduction) +//! - [Configuration](#configuration) +//! - [Announce handler](#announce-handler) +//! - [Scrape handler](#scrape-handler) +//! - [Authentication](#authentication) +//! - [Databases](#databases) +//! - [Torrent](#torrent) +//! - [Whitelist](#whitelist) +//! +//! # Introduction +//! +//! The main purpose of this crate is to provide a generic `BitTorrent` tracker. +//! +//! It has two main responsibilities: +//! +//! - To handle **announce** requests. +//! - To handle **scrape** requests. +//! +//! The crate has also other features: +//! +//! - **Authentication**: It handles authentication keys which are used by HTTP trackers. +//! - **Persistence**: It handles persistence of data into a database. +//! - **Torrent**: It handles the torrent data. +//! - **Whitelist**: When the tracker runs in [`listed`](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/type.Core.html) mode +//! all operations are restricted to whitelisted torrents. +//! +//! Refer to [torrust-tracker-configuration](https://docs.rs/torrust-tracker-configuration) +//! crate docs to get more information about the tracker settings. +//! +//! # Configuration +//! +//! You can control the behavior of this crate with the `Core` settings: +//! +//! ```toml +//! [logging] +//! threshold = "debug" +//! +//! [core] +//! inactive_peer_cleanup_interval = 600 +//! listed = false +//! private = false +//! tracker_usage_statistics = true +//! +//! [core.announce_policy] +//! interval = 120 +//! interval_min = 120 +//! +//! [core.database] +//! driver = "sqlite3" +//! path = "./storage/tracker/lib/database/sqlite3.db" +//! +//! [core.net] +//! on_reverse_proxy = false +//! external_ip = "2.137.87.41" +//! +//! [core.tracker_policy] +//! max_peer_timeout = 900 +//! persistent_torrent_completed_stat = false +//! remove_peerless_torrents = true +//! ``` +//! +//! Refer to the [`configuration` module documentation](https://docs.rs/torrust-tracker-configuration) to get more information about all options. +//! +//! # Announce handler +//! +//! The `AnnounceHandler` is responsible for handling announce requests. +//! +//! Please refer to the [`announce_handler`] documentation. +//! +//! # Scrape handler +//! +//! The `ScrapeHandler` is responsible for handling scrape requests. +//! +//! Please refer to the [`scrape_handler`] documentation. +//! +//! # Authentication +//! +//! The `Authentication` module is responsible for handling authentication keys which are used by HTTP trackers. +//! +//! Please refer to the [`authentication`] documentation. +//! +//! # Databases +//! +//! The `Databases` module is responsible for handling persistence of data into a database. +//! +//! Please refer to the [`databases`] documentation. +//! +//! # Torrent +//! +//! The `Torrent` module is responsible for handling the torrent data. +//! +//! Please refer to the [`torrent`] documentation. +//! +//! # Whitelist +//! +//! The `Whitelist` module is responsible for handling the whitelist. +//! +//! Please refer to the [`whitelist`] documentation. +pub mod announce_handler; +pub mod authentication; +pub mod container; +pub mod databases; +pub mod error; +pub mod scrape_handler; +pub mod statistics; +pub mod torrent; +pub mod whitelist; + +pub mod peer_tests; +pub mod test_helpers; + +use torrust_tracker_clock::clock; + +/// The maximum number of torrents that can be returned in an `scrape` response. +/// +/// The [BEP 15. UDP Tracker Protocol for `BitTorrent`](https://www.bittorrent.org/beps/bep_0015.html) +/// defines this limit: +/// +/// "Up to about 74 torrents can be scraped at once. A full scrape can't be done +/// with this protocol." +/// +/// The [BEP 48. Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html) +/// does not specifically mention this limit, but the limit is being used for +/// both the UDP and HTTP trackers since it's applied at the domain level. +pub const MAX_SCRAPE_TORRENTS: u8 = 74; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + +pub const TRACKER_CORE_LOG_TARGET: &str = "TRACKER_CORE"; + +#[cfg(test)] +mod tests { + mod the_tracker { + use std::sync::Arc; + + use torrust_tracker_test_helpers::configuration; + + use crate::announce_handler::AnnounceHandler; + use crate::scrape_handler::ScrapeHandler; + use crate::test_helpers::tests::initialize_handlers; + + fn initialize_handlers_for_public_tracker() -> (Arc, Arc) { + let config = configuration::ephemeral_public(); + initialize_handlers(&config) + } + + fn initialize_handlers_for_listed_tracker() -> (Arc, Arc) { + let config = configuration::ephemeral_listed(); + initialize_handlers(&config) + } + + mod for_all_config_modes { + + mod handling_a_scrape_request { + + use std::net::{IpAddr, Ipv4Addr}; + + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::announce_handler::PeersWanted; + use crate::test_helpers::tests::{complete_peer, incomplete_peer}; + use crate::tests::the_tracker::initialize_handlers_for_public_tracker; + + #[tokio::test] + async fn it_should_return_the_swarm_metadata_for_the_requested_file_if_the_tracker_has_that_torrent() { + let (announce_handler, scrape_handler) = initialize_handlers_for_public_tracker(); + + let info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(); // DevSkim: ignore DS173237 + + // Announce a "complete" peer for the torrent + let mut complete_peer = complete_peer(); + announce_handler + .handle_announcement( + &info_hash, + &mut complete_peer, + &IpAddr::V4(Ipv4Addr::new(126, 0, 0, 10)), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + // Announce an "incomplete" peer for the torrent + let mut incomplete_peer = incomplete_peer(); + announce_handler + .handle_announcement( + &info_hash, + &mut incomplete_peer, + &IpAddr::V4(Ipv4Addr::new(126, 0, 0, 11)), + &PeersWanted::AsManyAsPossible, + ) + .await + .unwrap(); + + // Scrape + let scrape_data = scrape_handler.handle_scrape(&vec![info_hash]).await.unwrap(); + + // The expected swarm metadata for the torrent + let mut expected_scrape_data = ScrapeData::empty(); + expected_scrape_data.add_file( + &info_hash, + SwarmMetadata { + complete: 1, // the "incomplete" announced + downloaded: 0, // the "complete" peer download does not count because it was not previously known + incomplete: 1, // the "incomplete" peer announced + }, + ); + + assert_eq!(scrape_data, expected_scrape_data); + } + } + } + + mod configured_as_whitelisted { + + mod handling_a_scrape_request { + + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + + use crate::tests::the_tracker::initialize_handlers_for_listed_tracker; + + #[tokio::test] + async fn it_should_return_the_zeroed_swarm_metadata_for_the_requested_file_if_it_is_not_whitelisted() { + let (_announce_handler, scrape_handler) = initialize_handlers_for_listed_tracker(); + + let non_whitelisted_info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(); // DevSkim: ignore DS173237 + + let scrape_data = scrape_handler.handle_scrape(&vec![non_whitelisted_info_hash]).await.unwrap(); + + // The expected zeroed swarm metadata for the file + let mut expected_scrape_data = ScrapeData::empty(); + expected_scrape_data.add_file(&non_whitelisted_info_hash, SwarmMetadata::zeroed()); + + assert_eq!(scrape_data, expected_scrape_data); + } + } + } + } +} diff --git a/src/core/peer_tests.rs b/packages/tracker-core/src/peer_tests.rs similarity index 100% rename from src/core/peer_tests.rs rename to packages/tracker-core/src/peer_tests.rs diff --git a/packages/tracker-core/src/scrape_handler.rs b/packages/tracker-core/src/scrape_handler.rs new file mode 100644 index 000000000..9c94a4e50 --- /dev/null +++ b/packages/tracker-core/src/scrape_handler.rs @@ -0,0 +1,187 @@ +//! Scrape handler. +//! +//! The `scrape` request allows clients to query metadata about the swarm in bulk. +//! +//! An `scrape` request includes a list of infohashes whose swarm metadata you +//! want to collect. +//! +//! ## Scrape Response Format +//! +//! The returned struct is: +//! +//! ```rust,no_run +//! use bittorrent_primitives::info_hash::InfoHash; +//! use std::collections::HashMap; +//! +//! pub struct ScrapeData { +//! pub files: HashMap, +//! } +//! +//! pub struct SwarmMetadata { +//! pub complete: u32, // The number of active peers that have completed downloading (seeders) +//! pub downloaded: u32, // The number of peers that have ever completed downloading +//! pub incomplete: u32, // The number of active peers that have not completed downloading (leechers) +//! } +//! ``` +//! +//! ## Example JSON Response +//! +//! The JSON representation of a sample `scrape` response would be like the following: +//! +//! ```json +//! { +//! 'files': { +//! 'xxxxxxxxxxxxxxxxxxxx': {'complete': 11, 'downloaded': 13772, 'incomplete': 19}, +//! 'yyyyyyyyyyyyyyyyyyyy': {'complete': 21, 'downloaded': 206, 'incomplete': 20} +//! } +//! } +//! ``` +//! +//! `xxxxxxxxxxxxxxxxxxxx` and `yyyyyyyyyyyyyyyyyyyy` are 20-byte infohash arrays. +//! There are two data structures for infohashes: byte arrays and hex strings: +//! +//! ```rust,no_run +//! use bittorrent_primitives::info_hash::InfoHash; +//! use std::str::FromStr; +//! +//! let info_hash: InfoHash = [255u8; 20].into(); +//! +//! assert_eq!( +//! info_hash, +//! InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() +//! ); +//! ``` +//! +//! ## References: +//! +//! Refer to `BitTorrent` BEPs and other sites for more information about the `scrape` request: +//! +//! - [BEP 48. Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html) +//! - [BEP 15. UDP Tracker Protocol for `BitTorrent`. Scrape section](https://www.bittorrent.org/beps/bep_0015.html) +//! - [Vuze docs](https://wiki.vuze.com/w/Scrape) +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::core::ScrapeData; +use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + +use super::torrent::repository::in_memory::InMemoryTorrentRepository; +use super::whitelist; +use crate::error::ScrapeError; + +/// Handles scrape requests, providing torrent swarm metadata. +pub struct ScrapeHandler { + /// Service for authorizing access to whitelisted torrents. + whitelist_authorization: Arc, + + /// The in-memory torrents repository. + in_memory_torrent_repository: Arc, +} + +impl ScrapeHandler { + /// Creates a new `ScrapeHandler` instance. + #[must_use] + pub fn new( + whitelist_authorization: &Arc, + in_memory_torrent_repository: &Arc, + ) -> Self { + Self { + whitelist_authorization: whitelist_authorization.clone(), + in_memory_torrent_repository: in_memory_torrent_repository.clone(), + } + } + + /// Handles a scrape request for multiple torrents. + /// + /// - Returns metadata for each requested torrent. + /// - If a torrent isn't whitelisted or doesn't exist, returns zeroed stats. + /// + /// # Errors + /// + /// It does not return any errors for the time being. The error is returned + /// to avoid breaking changes in the future if we decide to return errors. + /// For example, a new tracker configuration option could be added to return + /// an error if a torrent is not whitelisted instead of returning zeroed + /// stats. + /// + /// # BEP Reference: + /// + /// [BEP 48: Scrape Protocol](https://www.bittorrent.org/beps/bep_0048.html) + pub async fn handle_scrape(&self, info_hashes: &Vec) -> Result { + let mut scrape_data = ScrapeData::empty(); + + for info_hash in info_hashes { + let swarm_metadata = match self.whitelist_authorization.authorize(info_hash).await { + Ok(()) => { + self.in_memory_torrent_repository + .get_swarm_metadata_or_default(info_hash) + .await + } + Err(_) => SwarmMetadata::zeroed(), + }; + scrape_data.add_file(info_hash, swarm_metadata); + } + + Ok(scrape_data) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_primitives::core::ScrapeData; + use torrust_tracker_test_helpers::configuration; + + use super::ScrapeHandler; + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + use crate::whitelist::{self}; + + fn scrape_handler() -> ScrapeHandler { + let config = configuration::ephemeral_public(); + + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(whitelist::authorization::WhitelistAuthorization::new( + &config.core, + &in_memory_whitelist.clone(), + )); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository) + } + + #[tokio::test] + async fn it_should_return_a_zeroed_swarm_metadata_for_the_requested_file_if_the_tracker_does_not_have_that_torrent() { + let scrape_handler = scrape_handler(); + + let info_hashes = vec!["3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap()]; // DevSkim: ignore DS173237 + + let scrape_data = scrape_handler.handle_scrape(&info_hashes).await.unwrap(); + + let mut expected_scrape_data = ScrapeData::empty(); + + expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[0]); + + assert_eq!(scrape_data, expected_scrape_data); + } + + #[tokio::test] + async fn it_should_allow_scraping_for_multiple_torrents() { + let scrape_handler = scrape_handler(); + + let info_hashes = vec![ + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), // DevSkim: ignore DS173237 + "99c82bb73505a3c0b453f9fa0e881d6e5a32a0c1".parse::().unwrap(), // DevSkim: ignore DS173237 + ]; + + let scrape_data = scrape_handler.handle_scrape(&info_hashes).await.unwrap(); + + let mut expected_scrape_data = ScrapeData::empty(); + expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[0]); + expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[1]); + + assert_eq!(scrape_data, expected_scrape_data); + } +} diff --git a/packages/tracker-core/src/statistics/event/handler.rs b/packages/tracker-core/src/statistics/event/handler.rs new file mode 100644 index 000000000..9a5182f25 --- /dev/null +++ b/packages/tracker-core/src/statistics/event/handler.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use torrust_tracker_swarm_coordination_registry::event::Event; + +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use crate::statistics::repository::Repository; +use crate::statistics::TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL; + +pub async fn handle_event( + event: Event, + stats_repository: &Arc, + db_downloads_metric_repository: &Arc, + persistent_torrent_completed_stat: bool, + now: DurationSinceUnixEpoch, +) { + match event { + // Torrent events + Event::TorrentAdded { info_hash, .. } => { + tracing::debug!(info_hash = ?info_hash, "Torrent added",); + } + Event::TorrentRemoved { info_hash } => { + tracing::debug!(info_hash = ?info_hash, "Torrent removed",); + } + + // Peer events + Event::PeerAdded { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer added", ); + } + Event::PeerRemoved { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer removed", ); + } + Event::PeerUpdated { + info_hash, + old_peer, + new_peer, + } => { + tracing::debug!(info_hash = ?info_hash, old_peer = ?old_peer, new_peer = ?new_peer, "Peer updated"); + } + Event::PeerDownloadCompleted { info_hash, peer } => { + tracing::debug!(info_hash = ?info_hash, peer = ?peer, "Peer download completed", ); + + // Increment the number of downloads for all the torrents in memory + let _unused = stats_repository + .increment_counter( + &metric_name!(TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL), + &LabelSet::default(), + now, + ) + .await; + + if persistent_torrent_completed_stat { + // Increment the number of downloads for the torrent in the database + match db_downloads_metric_repository.increase_downloads_for_torrent(&info_hash) { + Ok(()) => { + tracing::debug!(info_hash = ?info_hash, "Number of torrent downloads increased"); + } + Err(err) => { + tracing::error!(info_hash = ?info_hash, error = ?err, "Failed to increase number of downloads for the torrent"); + } + } + + // Increment the global number of downloads (for all torrents) in the database + match db_downloads_metric_repository.increase_global_downloads() { + Ok(()) => { + tracing::debug!("Global number of downloads increased"); + } + Err(err) => { + tracing::error!(error = ?err, "Failed to increase global number of downloads"); + } + } + } + } + } +} diff --git a/packages/tracker-core/src/statistics/event/listener.rs b/packages/tracker-core/src/statistics/event/listener.rs new file mode 100644 index 000000000..8d2d74c71 --- /dev/null +++ b/packages/tracker-core/src/statistics/event/listener.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; +use torrust_tracker_swarm_coordination_registry::event::receiver::Receiver; + +use super::handler::handle_event; +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use crate::statistics::repository::Repository; +use crate::{CurrentClock, TRACKER_CORE_LOG_TARGET}; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + repository: &Arc, + db_downloads_metric_repository: &Arc, + persistent_torrent_completed_stat: bool, +) -> JoinHandle<()> { + let stats_repository = repository.clone(); + let db_downloads_metric_repository: Arc = db_downloads_metric_repository.clone(); + + tracing::info!(target: TRACKER_CORE_LOG_TARGET, "Starting tracker core event listener"); + + tokio::spawn(async move { + dispatch_events( + receiver, + cancellation_token, + stats_repository, + db_downloads_metric_repository, + persistent_torrent_completed_stat, + ) + .await; + + tracing::info!(target: TRACKER_CORE_LOG_TARGET, "Tracker core listener finished"); + }) +} + +async fn dispatch_events( + mut receiver: Receiver, + cancellation_token: CancellationToken, + stats_repository: Arc, + db_downloads_metric_repository: Arc, + persistent_torrent_completed_stat: bool, +) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: TRACKER_CORE_LOG_TARGET, "Received cancellation request, shutting down tracker core event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event( + event, + &stats_repository, + &db_downloads_metric_repository, + persistent_torrent_completed_stat, + CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: TRACKER_CORE_LOG_TARGET, "Tracker core event receiver closed"); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: TRACKER_CORE_LOG_TARGET, "Tracker core event receiver lagged by {} events", n); + } + } + } + } + } + } + } +} diff --git a/packages/tracker-core/src/statistics/event/mod.rs b/packages/tracker-core/src/statistics/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/tracker-core/src/statistics/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/tracker-core/src/statistics/metrics.rs b/packages/tracker-core/src/statistics/metrics.rs new file mode 100644 index 000000000..a5caaf1cf --- /dev/null +++ b/packages/tracker-core/src/statistics/metrics.rs @@ -0,0 +1,76 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::{Error, MetricCollection}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +/// Metrics collected by the torrent repository. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct Metrics { + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increment_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_counter(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn set_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: u64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_counter(metric_name, labels, value, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn set_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_gauge(metric_name, labels, value, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increment_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_gauge(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn decrement_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.decrement_gauge(metric_name, labels, now) + } +} diff --git a/packages/tracker-core/src/statistics/mod.rs b/packages/tracker-core/src/statistics/mod.rs new file mode 100644 index 000000000..fdb8e8fd4 --- /dev/null +++ b/packages/tracker-core/src/statistics/mod.rs @@ -0,0 +1,28 @@ +pub mod event; +pub mod metrics; +pub mod persisted; +pub mod repository; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_metrics::unit::Unit; + +// Torrent metrics + +const TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL: &str = "tracker_core_persistent_torrents_downloads_total"; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + // Torrent metrics + + metrics.metric_collection.describe_counter( + &metric_name!(TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("The total number of torrent downloads (persisted).")), + ); + + metrics +} diff --git a/packages/tracker-core/src/statistics/persisted/downloads.rs b/packages/tracker-core/src/statistics/persisted/downloads.rs new file mode 100644 index 000000000..6248bdc73 --- /dev/null +++ b/packages/tracker-core/src/statistics/persisted/downloads.rs @@ -0,0 +1,200 @@ +//! The repository that stored persistent torrents' data into the database. +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::{NumberOfDownloads, NumberOfDownloadsBTreeMap}; + +use crate::databases::error::Error; +use crate::databases::Database; + +/// It persists torrent metrics in a database. +/// +/// This repository persists only a subset of the torrent data: the torrent +/// metrics, specifically the number of downloads (or completed counts) for each +/// torrent. It relies on a database driver (either `SQLite3` or `MySQL`) that +/// implements the [`Database`] trait to perform the actual persistence +/// operations. +/// +/// # Note +/// +/// Not all in-memory torrent data is persisted; only the aggregate metrics are +/// stored. +pub struct DatabaseDownloadsMetricRepository { + /// A shared reference to the database driver implementation. + /// + /// The driver must implement the [`Database`] trait. This allows for + /// different underlying implementations (e.g., `SQLite3` or `MySQL`) to be + /// used interchangeably. + database: Arc>, +} + +impl DatabaseDownloadsMetricRepository { + /// Creates a new instance of `DatabasePersistentTorrentRepository`. + /// + /// # Arguments + /// + /// * `database` - A shared reference to a boxed database driver + /// implementing the [`Database`] trait. + /// + /// # Returns + /// + /// A new `DatabasePersistentTorrentRepository` instance with a cloned + /// reference to the provided database. + #[must_use] + pub fn new(database: &Arc>) -> DatabaseDownloadsMetricRepository { + Self { + database: database.clone(), + } + } + + // Single Torrent Metrics + + /// Increases the number of downloads for a given torrent. + /// + /// If the torrent is not found, it creates a new entry. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// + /// # Errors + /// + /// Returns an [`Error`] if the database operation fails. + pub(crate) fn increase_downloads_for_torrent(&self, info_hash: &InfoHash) -> Result<(), Error> { + let torrent = self.load_torrent_downloads(info_hash)?; + + match torrent { + Some(_number_of_downloads) => self.database.increase_downloads_for_torrent(info_hash), + None => self.save_torrent_downloads(info_hash, 1), + } + } + + /// Loads all persistent torrent metrics from the database. + /// + /// This function retrieves the torrent metrics (e.g., download counts) from the persistent store + /// and returns them as a [`PersistentTorrents`] map. + /// + /// # Errors + /// + /// Returns an [`Error`] if the underlying database query fails. + pub(crate) fn load_all_torrents_downloads(&self) -> Result { + self.database.load_all_torrents_downloads() + } + + /// Loads one persistent torrent metrics from the database. + /// + /// This function retrieves the torrent metrics (e.g., download counts) from the persistent store + /// and returns them as a [`PersistentTorrents`] map. + /// + /// # Errors + /// + /// Returns an [`Error`] if the underlying database query fails. + pub(crate) fn load_torrent_downloads(&self, info_hash: &InfoHash) -> Result, Error> { + self.database.load_torrent_downloads(info_hash) + } + + /// Saves the persistent torrent metric into the database. + /// + /// This function stores or updates the download count for the torrent + /// identified by the provided infohash. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// * `downloaded` - The number of times the torrent has been downloaded. + /// + /// # Errors + /// + /// Returns an [`Error`] if the database operation fails. + pub(crate) fn save_torrent_downloads(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error> { + self.database.save_torrent_downloads(info_hash, downloaded) + } + + // Aggregate Metrics + + /// Increases the global number of downloads for all torrent. + /// + /// If the metric is not found, it creates it. + /// + /// # Errors + /// + /// Returns an [`Error`] if the database operation fails. + pub(crate) fn increase_global_downloads(&self) -> Result<(), Error> { + let torrent = self.database.load_global_downloads()?; + + match torrent { + Some(_number_of_downloads) => self.database.increase_global_downloads(), + None => self.database.save_global_downloads(1), + } + } + + /// Loads the global number of downloads for all torrents from the database. + /// + /// # Errors + /// + /// Returns an [`Error`] if the underlying database query fails. + pub(crate) fn load_global_downloads(&self) -> Result, Error> { + self.database.load_global_downloads() + } +} + +#[cfg(test)] +mod tests { + + use torrust_tracker_primitives::NumberOfDownloadsBTreeMap; + + use super::DatabaseDownloadsMetricRepository; + use crate::databases::setup::initialize_database; + use crate::test_helpers::tests::{ephemeral_configuration, sample_info_hash, sample_info_hash_one, sample_info_hash_two}; + + fn initialize_db_persistent_torrent_repository() -> DatabaseDownloadsMetricRepository { + let config = ephemeral_configuration(); + let database = initialize_database(&config); + DatabaseDownloadsMetricRepository::new(&database) + } + + #[test] + fn it_saves_the_numbers_of_downloads_for_a_torrent_into_the_database() { + let repository = initialize_db_persistent_torrent_repository(); + + let infohash = sample_info_hash(); + + repository.save_torrent_downloads(&infohash, 1).unwrap(); + + let torrents = repository.load_all_torrents_downloads().unwrap(); + + assert_eq!(torrents.get(&infohash), Some(1).as_ref()); + } + + #[test] + fn it_increases_the_numbers_of_downloads_for_a_torrent_into_the_database() { + let repository = initialize_db_persistent_torrent_repository(); + + let infohash = sample_info_hash(); + + repository.increase_downloads_for_torrent(&infohash).unwrap(); + + let torrents = repository.load_all_torrents_downloads().unwrap(); + + assert_eq!(torrents.get(&infohash), Some(1).as_ref()); + } + + #[test] + fn it_loads_the_numbers_of_downloads_for_all_torrents_from_the_database() { + let repository = initialize_db_persistent_torrent_repository(); + + let infohash_one = sample_info_hash_one(); + let infohash_two = sample_info_hash_two(); + + repository.save_torrent_downloads(&infohash_one, 1).unwrap(); + repository.save_torrent_downloads(&infohash_two, 2).unwrap(); + + let torrents = repository.load_all_torrents_downloads().unwrap(); + + let mut expected_torrents = NumberOfDownloadsBTreeMap::new(); + expected_torrents.insert(infohash_one, 1); + expected_torrents.insert(infohash_two, 2); + + assert_eq!(torrents, expected_torrents); + } +} diff --git a/packages/tracker-core/src/statistics/persisted/mod.rs b/packages/tracker-core/src/statistics/persisted/mod.rs new file mode 100644 index 000000000..86c28370d --- /dev/null +++ b/packages/tracker-core/src/statistics/persisted/mod.rs @@ -0,0 +1,59 @@ +pub mod downloads; + +use std::sync::Arc; + +use thiserror::Error; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::{metric_collection, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::repository::Repository; +use super::TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL; +use crate::databases; +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + +/// Loads persisted metrics from the database and sets them in the stats repository. +/// +/// # Errors +/// +/// This function will return an error if the database query fails or if the +/// metric collection fails to set the initial metric values. +pub async fn load_persisted_metrics( + stats_repository: &Arc, + db_downloads_metric_repository: &Arc, + now: DurationSinceUnixEpoch, +) -> Result<(), Error> { + if let Some(downloads) = db_downloads_metric_repository.load_global_downloads()? { + stats_repository + .set_counter( + &metric_name!(TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL), + &LabelSet::default(), + u64::from(downloads), + now, + ) + .await?; + } + + Ok(()) +} + +#[derive(Error, Debug, Clone)] +pub enum Error { + #[error("Database error: {err}")] + DatabaseError { err: databases::error::Error }, + + #[error("Metrics error: {err}")] + MetricsError { err: metric_collection::Error }, +} + +impl From for Error { + fn from(err: databases::error::Error) -> Self { + Self::DatabaseError { err } + } +} + +impl From for Error { + fn from(err: metric_collection::Error) -> Self { + Self::MetricsError { err } + } +} diff --git a/packages/tracker-core/src/statistics/repository.rs b/packages/tracker-core/src/statistics/repository.rs new file mode 100644 index 000000000..21b1da7f2 --- /dev/null +++ b/packages/tracker-core/src/statistics/repository.rs @@ -0,0 +1,176 @@ +use std::sync::Arc; + +use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::Error; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::metrics::Metrics; +use super::{describe_metrics, TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL}; + +/// A repository for the torrent repository metrics. +#[derive(Clone)] +pub struct Repository { + pub stats: Arc>, +} + +impl Default for Repository { + fn default() -> Self { + Self::new() + } +} + +impl Repository { + #[must_use] + pub fn new() -> Self { + let stats = Arc::new(RwLock::new(describe_metrics())); + + Self { stats } + } + + pub async fn get_metrics(&self) -> RwLockReadGuard<'_, Metrics> { + self.stats.read().await + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increment the counter. + pub async fn increment_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increment_counter(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to increment the counter: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increment the counter. + pub async fn set_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + value: u64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.set_counter(metric_name, labels, value, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to set the counter: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// set the gauge. + pub async fn set_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.set_gauge(metric_name, labels, value, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to set the gauge: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increment the gauge. + pub async fn increment_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increment_gauge(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to increment the gauge: {}", err), + } + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// decrement the gauge. + pub async fn decrement_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.decrement_gauge(metric_name, labels, now); + + drop(stats_lock); + + match result { + Ok(()) => {} + Err(ref err) => tracing::error!("Failed to decrement the gauge: {}", err), + } + + result + } + + /// Get the total number of torrent downloads. + /// + /// The value is persisted in database if persistence for downloads metrics is enabled. + pub async fn get_torrents_downloads_total(&self) -> u64 { + let metrics = self.get_metrics().await; + + let downloads = metrics.metric_collection.get_counter_value( + &metric_name!(TRACKER_CORE_PERSISTENT_TORRENTS_DOWNLOADS_TOTAL), + &LabelSet::default(), + ); + + if let Some(downloads) = downloads { + downloads.value() + } else { + 0 + } + } +} diff --git a/packages/tracker-core/src/test_helpers.rs b/packages/tracker-core/src/test_helpers.rs new file mode 100644 index 000000000..bf21e6f94 --- /dev/null +++ b/packages/tracker-core/src/test_helpers.rs @@ -0,0 +1,184 @@ +//! Some generic test helpers functions. + +#[cfg(test)] +pub(crate) mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use bittorrent_primitives::info_hash::InfoHash; + use rand::RngExt; + use torrust_tracker_configuration::Configuration; + #[cfg(test)] + use torrust_tracker_configuration::Core; + use torrust_tracker_primitives::peer::Peer; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + #[cfg(test)] + use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + + use crate::announce_handler::AnnounceHandler; + use crate::databases::setup::initialize_database; + use crate::scrape_handler::ScrapeHandler; + use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + use crate::whitelist::{self}; + + /// Generates a random `InfoHash`. + #[must_use] + pub fn random_info_hash() -> InfoHash { + let mut rng = rand::rng(); + let mut random_bytes = [0u8; 20]; + rng.fill(&mut random_bytes); + + InfoHash::from_bytes(&random_bytes) + } + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash_one() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash_two() -> InfoHash { + "99c82bb73505a3c0b453f9fa0e881d6e5a32a0c1" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } + + /// Sample peer whose state is not relevant for the tests. + #[must_use] + pub fn sample_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + #[must_use] + pub fn seeder() -> Peer { + complete_peer() + } + + #[must_use] + pub fn leecher() -> Peer { + incomplete_peer() + } + + #[must_use] + pub fn started_peer() -> Peer { + incomplete_peer() + } + + #[must_use] + pub fn completed_peer() -> Peer { + complete_peer() + } + + /// A peer that counts as `complete` is swarm metadata + /// IMPORTANT!: it only counts if the it has been announce at least once before + /// announcing the `AnnounceEvent::Completed` event. + #[must_use] + pub fn complete_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000001"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } + } + + /// A peer that counts as `incomplete` is swarm metadata + #[must_use] + pub fn incomplete_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000002"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(1000), // Still bytes to download + event: AnnounceEvent::Started, + } + } + + #[must_use] + pub fn initialize_handlers(config: &Configuration) -> (Arc, Arc) { + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(whitelist::authorization::WhitelistAuthorization::new( + &config.core, + &in_memory_whitelist.clone(), + )); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + let scrape_handler = Arc::new(ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository)); + + (announce_handler, scrape_handler) + } + + /// # Panics + /// + /// Will panic if the temporary database file path is not a valid UFT string. + #[cfg(test)] + #[must_use] + pub fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + + config + } + + /// # Panics + /// + /// Will panic if the temporary database file path is not a valid UFT string. + #[cfg(test)] + #[must_use] + pub fn ephemeral_configuration_for_listed_tracker() -> Core { + let mut config = Core { + listed: true, + ..Default::default() + }; + + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + + config + } +} diff --git a/packages/tracker-core/src/torrent/manager.rs b/packages/tracker-core/src/torrent/manager.rs new file mode 100644 index 000000000..5acc27980 --- /dev/null +++ b/packages/tracker-core/src/torrent/manager.rs @@ -0,0 +1,302 @@ +//! Torrents manager. +use std::sync::Arc; +use std::time::Duration; + +use torrust_tracker_clock::clock::Time; +use torrust_tracker_configuration::Core; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::repository::in_memory::InMemoryTorrentRepository; +use crate::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; +use crate::{databases, CurrentClock}; + +/// The `TorrentsManager` is responsible for managing torrent entries by +/// integrating persistent storage and in-memory state. It provides methods to +/// load torrent data from the database into memory, and to periodically clean +/// up stale torrent entries by removing inactive peers or entire torrent +/// entries that no longer have active peers. +/// +/// This manager relies on two repositories: +/// +/// - An **in-memory repository** to provide fast access to the current torrent +/// state. +/// - A **persistent repository** that stores aggregate torrent metrics (e.g., +/// seeders count) across tracker restarts. +pub struct TorrentsManager { + /// The tracker configuration. + config: Core, + + /// The in-memory torrents repository. + in_memory_torrent_repository: Arc, + + /// The download metrics repository. + db_downloads_metric_repository: Arc, +} + +impl TorrentsManager { + /// Creates a new instance of `TorrentsManager`. + /// + /// # Arguments + /// + /// * `config` - A reference to the tracker configuration. + /// * `in_memory_torrent_repository` - A shared reference to the in-memory + /// repository of torrents. + /// * `db_downloads_metric_repository` - A shared reference to the persistent + /// repository for torrent metrics. + /// + /// # Returns + /// + /// A new `TorrentsManager` instance with cloned references of the provided dependencies. + #[must_use] + pub fn new( + config: &Core, + in_memory_torrent_repository: &Arc, + db_downloads_metric_repository: &Arc, + ) -> Self { + Self { + config: config.clone(), + in_memory_torrent_repository: in_memory_torrent_repository.clone(), + db_downloads_metric_repository: db_downloads_metric_repository.clone(), + } + } + + /// Loads torrents from the database into the in-memory repository. + /// + /// This function retrieves the list of persistent torrent entries (which + /// include only the aggregate metrics, not the detailed peer lists) from + /// the database, and then imports that data into the in-memory repository. + /// + /// # Errors + /// + /// Returns a `databases::error::Error` if unable to load the persistent + /// torrent data. + pub fn load_torrents_from_database(&self) -> Result<(), databases::error::Error> { + let persistent_torrents = self.db_downloads_metric_repository.load_all_torrents_downloads()?; + + self.in_memory_torrent_repository.import_persistent(&persistent_torrents); + + Ok(()) + } + + /// Cleans up torrent entries by removing inactive peers and, optionally, + /// torrents with no active peers. + /// + /// This function performs two cleanup tasks: + /// + /// 1. It removes peers from torrent entries that have not been updated + /// within a cutoff time. The cutoff time is calculated as the current + /// time minus the maximum allowed peer timeout, as specified in the + /// tracker policy. + /// 2. If the tracker is configured to remove peerless torrents + /// (`remove_peerless_torrents` is set), it removes entire torrent + /// entries that have no active peers. + pub async fn cleanup_torrents(&self) { + self.log_aggregate_swarm_metadata().await; + + self.remove_inactive_peers().await; + + self.log_aggregate_swarm_metadata().await; + + self.remove_peerless_torrents().await; + + self.log_aggregate_swarm_metadata().await; + } + + async fn remove_inactive_peers(&self) { + self.in_memory_torrent_repository + .remove_inactive_peers(self.current_cutoff()) + .await; + } + + fn current_cutoff(&self) -> DurationSinceUnixEpoch { + CurrentClock::now_sub(&Duration::from_secs(u64::from(self.config.tracker_policy.max_peer_timeout))).unwrap_or_default() + } + + async fn remove_peerless_torrents(&self) { + if self.config.tracker_policy.remove_peerless_torrents { + self.in_memory_torrent_repository + .remove_peerless_torrents(&self.config.tracker_policy) + .await; + } + } + + async fn log_aggregate_swarm_metadata(&self) { + // Pre-calculated data + let aggregate_swarm_metadata = self.in_memory_torrent_repository.get_aggregate_swarm_metadata().await; + + tracing::info!(name: "pre_calculated_aggregate_swarm_metadata", + torrents = aggregate_swarm_metadata.total_torrents, + downloads = aggregate_swarm_metadata.total_downloaded, + seeders = aggregate_swarm_metadata.total_complete, + leechers = aggregate_swarm_metadata.total_incomplete, + ); + + // Hot data (iterating over data structures) + let peerless_torrents = self.in_memory_torrent_repository.count_peerless_torrents().await; + let peers = self.in_memory_torrent_repository.count_peers().await; + + tracing::info!(name: "hot_aggregate_swarm_metadata", + peerless_torrents = peerless_torrents, + peers = peers, + ); + } +} + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + use torrust_tracker_swarm_coordination_registry::Registry; + + use super::{DatabaseDownloadsMetricRepository, TorrentsManager}; + use crate::databases::setup::initialize_database; + use crate::test_helpers::tests::{ephemeral_configuration, sample_info_hash}; + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + + struct TorrentsManagerDeps { + config: Arc, + in_memory_torrent_repository: Arc, + database_persistent_torrent_repository: Arc, + } + + fn initialize_torrents_manager() -> (Arc, Arc) { + let config = ephemeral_configuration(); + initialize_torrents_manager_with(config.clone()) + } + + fn initialize_torrents_manager_with(config: Core) -> (Arc, Arc) { + let swarms = Arc::new(Registry::default()); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::new(swarms)); + let database = initialize_database(&config); + let database_persistent_torrent_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + + let torrents_manager = Arc::new(TorrentsManager::new( + &config, + &in_memory_torrent_repository, + &database_persistent_torrent_repository, + )); + + ( + torrents_manager, + Arc::new(TorrentsManagerDeps { + config: Arc::new(config), + in_memory_torrent_repository, + database_persistent_torrent_repository, + }), + ) + } + + #[tokio::test] + async fn it_should_load_the_numbers_of_downloads_for_all_torrents_from_the_database() { + let (torrents_manager, services) = initialize_torrents_manager(); + + let infohash = sample_info_hash(); + + services + .database_persistent_torrent_repository + .save_torrent_downloads(&infohash, 1) + .unwrap(); + + torrents_manager.load_torrents_from_database().unwrap(); + + assert_eq!( + services + .in_memory_torrent_repository + .get(&infohash) + .unwrap() + .lock() + .await + .metadata() + .downloaded, + 1 + ); + } + + mod cleaning_torrents { + use std::ops::Add; + use std::sync::Arc; + use std::time::Duration; + + use bittorrent_primitives::info_hash::InfoHash; + use torrust_tracker_clock::clock::stopped::Stopped; + use torrust_tracker_clock::clock::{self}; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::test_helpers::tests::{ephemeral_configuration, sample_info_hash, sample_peer}; + use crate::torrent::manager::tests::{initialize_torrents_manager, initialize_torrents_manager_with}; + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + + #[tokio::test] + async fn it_should_remove_peers_that_have_not_been_updated_after_a_cutoff_time() { + let (torrents_manager, services) = initialize_torrents_manager(); + + let infohash = sample_info_hash(); + + clock::Stopped::local_set(&Duration::from_secs(0)); + + // Add a peer to the torrent + let mut peer = sample_peer(); + peer.updated = DurationSinceUnixEpoch::new(0, 0); + services + .in_memory_torrent_repository + .handle_announcement(&infohash, &peer, None) + .await; + + // Simulate the time has passed 1 second more than the max peer timeout. + clock::Stopped::local_add(&Duration::from_secs( + (services.config.tracker_policy.max_peer_timeout + 1).into(), + )) + .unwrap(); + + torrents_manager.cleanup_torrents().await; + + assert!(services.in_memory_torrent_repository.get(&infohash).is_none()); + } + + async fn add_a_peerless_torrent(infohash: &InfoHash, in_memory_torrent_repository: &Arc) { + // Add a peer to the torrent + let mut peer = sample_peer(); + peer.updated = DurationSinceUnixEpoch::new(0, 0); + in_memory_torrent_repository.handle_announcement(infohash, &peer, None).await; + + // Remove the peer. The torrent is now peerless. + in_memory_torrent_repository + .remove_inactive_peers(peer.updated.add(Duration::from_secs(1))) + .await; + } + + #[tokio::test] + async fn it_should_remove_torrents_that_have_no_peers_when_it_is_configured_to_do_so() { + let mut config = ephemeral_configuration(); + config.tracker_policy.remove_peerless_torrents = true; + + let (torrents_manager, services) = initialize_torrents_manager_with(config); + + let infohash = sample_info_hash(); + + add_a_peerless_torrent(&infohash, &services.in_memory_torrent_repository).await; + + torrents_manager.cleanup_torrents().await; + + assert!(services.in_memory_torrent_repository.get(&infohash).is_none()); + } + + #[tokio::test] + async fn it_should_retain_peerless_torrents_when_it_is_configured_to_do_so() { + let mut config = ephemeral_configuration(); + config.tracker_policy.remove_peerless_torrents = false; + + let (torrents_manager, services) = initialize_torrents_manager_with(config); + + let infohash = sample_info_hash(); + + add_a_peerless_torrent(&infohash, &services.in_memory_torrent_repository).await; + + torrents_manager.cleanup_torrents().await; + + assert!(services.in_memory_torrent_repository.get(&infohash).is_some()); + } + } +} diff --git a/packages/tracker-core/src/torrent/mod.rs b/packages/tracker-core/src/torrent/mod.rs new file mode 100644 index 000000000..01d33b893 --- /dev/null +++ b/packages/tracker-core/src/torrent/mod.rs @@ -0,0 +1,168 @@ +//! Swarm Data Structures. +//! +//! This module defines the primary data structures used to store and manage +//! swarm data within the tracker. In `BitTorrent` terminology, a "swarm" is +//! the collection of peers that are sharing or downloading a given torrent. +//! +//! There are two main types of data stored: +//! +//! - **Torrent Entry** (`Entry`): Contains all the information the tracker +//! stores for a single torrent, including the list of peers currently in the +//! swarm. This data is crucial for peers to locate each other and initiate +//! downloads. +//! +//! - **Swarm Metadata** (`SwarmMetadata`): Contains aggregate data derived from +//! all torrent entries. This metadata is split into: +//! - **Active Peers Data:** Metrics related to the peers that are currently +//! active in the swarm. +//! - **Historical Data:** Metrics collected since the tracker started, such +//! as the total number of completed downloads. +//! +//! ## Metrics Collected +//! +//! The tracker collects and aggregates the following metrics: +//! +//! - The total number of peers that have completed downloading the torrent +//! since the tracker began collecting metrics. +//! - The number of completed downloads from peers that remain active (i.e., seeders). +//! - The number of active peers that have not completed downloading the torrent (i.e., leechers). +//! +//! This information is used both to inform peers about available connections +//! and to provide overall swarm statistics. +//! +//! This module re-exports core types from the torrent repository crate to +//! simplify integration. +//! +//! ## Internal Data Structures +//! +//! The [`torrent`](crate::torrent) module contains all the data structures +//! stored by the tracker except for peers. +//! +//! We can represent the data stored in memory internally by the tracker with +//! this JSON object: +//! +//! ```json +//! { +//! "c1277613db1d28709b034a017ab2cae4be07ae10": { +//! "completed": 0, +//! "peers": { +//! "-qB00000000000000001": { +//! "peer_id": "-qB00000000000000001", +//! "peer_addr": "2.137.87.41:1754", +//! "updated": 1672419840, +//! "uploaded": 120, +//! "downloaded": 60, +//! "left": 60, +//! "event": "started" +//! }, +//! "-qB00000000000000002": { +//! "peer_id": "-qB00000000000000002", +//! "peer_addr": "23.17.287.141:2345", +//! "updated": 1679415984, +//! "uploaded": 80, +//! "downloaded": 20, +//! "left": 40, +//! "event": "started" +//! } +//! } +//! } +//! } +//! ``` +//! +//! The tracker maintains an indexed-by-info-hash list of torrents. For each +//! torrent, it stores a torrent `Entry`. The torrent entry has two attributes: +//! +//! - `completed`: which is hte number of peers that have completed downloading +//! the torrent file/s. As they have completed downloading, they have a full +//! version of the torrent data, and they can provide the full data to other +//! peers. That's why they are also known as "seeders". +//! - `peers`: an indexed and orderer list of peer for the torrent. Each peer +//! contains the data received from the peer in the `announce` request. +//! +//! The [`crate::torrent`] module not only contains the original data obtained +//! from peer via `announce` requests, it also contains aggregate data that can +//! be derived from the original data. For example: +//! +//! ```rust,no_run +//! pub struct SwarmMetadata { +//! pub complete: u32, // The number of active peers that have completed downloading (seeders) +//! pub downloaded: u32, // The number of peers that have ever completed downloading +//! pub incomplete: u32, // The number of active peers that have not completed downloading (leechers) +//! } +//! ``` +//! +//! > **NOTICE**: that `complete` or `completed` peers are the peers that have +//! > completed downloading, but only the active ones are considered "seeders". +//! +//! `SwarmMetadata` struct follows name conventions for `scrape` responses. See +//! [BEP 48](https://www.bittorrent.org/beps/bep_0048.html), while `SwarmMetadata` +//! is used for the rest of cases. +//! +//! ## Peers +//! +//! A `Peer` is the struct used by the tracker to keep peers data: +//! +//! ```rust,no_run +//! use std::net::SocketAddr; +//! use aquatic_udp_protocol::PeerId; +//! use torrust_tracker_primitives::DurationSinceUnixEpoch; +//! use aquatic_udp_protocol::NumberOfBytes; +//! use aquatic_udp_protocol::AnnounceEvent; +//! +//! pub struct Peer { +//! pub peer_id: PeerId, // The peer ID +//! pub peer_addr: SocketAddr, // Peer socket address +//! pub updated: DurationSinceUnixEpoch, // Last time (timestamp) when the peer was updated +//! pub uploaded: NumberOfBytes, // Number of bytes the peer has uploaded so far +//! pub downloaded: NumberOfBytes, // Number of bytes the peer has downloaded so far +//! pub left: NumberOfBytes, // The number of bytes this peer still has to download +//! pub event: AnnounceEvent, // The event the peer has announced: `started`, `completed`, `stopped` +//! } +//! ``` +//! +//! Notice that most of the attributes are obtained from the `announce` request. +//! For example, an HTTP announce request would contain the following `GET` parameters: +//! +//! +//! +//! The `Tracker` keeps an in-memory ordered data structure with all the torrents and a list of peers for each torrent, together with some swarm metrics. +//! +//! We can represent the data stored in memory with this JSON object: +//! +//! ```json +//! { +//! "c1277613db1d28709b034a017ab2cae4be07ae10": { +//! "completed": 0, +//! "peers": { +//! "-qB00000000000000001": { +//! "peer_id": "-qB00000000000000001", +//! "peer_addr": "2.137.87.41:1754", +//! "updated": 1672419840, +//! "uploaded": 120, +//! "downloaded": 60, +//! "left": 60, +//! "event": "started" +//! }, +//! "-qB00000000000000002": { +//! "peer_id": "-qB00000000000000002", +//! "peer_addr": "23.17.287.141:2345", +//! "updated": 1679415984, +//! "uploaded": 80, +//! "downloaded": 20, +//! "left": 40, +//! "event": "started" +//! } +//! } +//! } +//! } +//! ``` +//! +//! That JSON object does not exist, it's only a representation of the `Tracker` torrents data. +//! +//! `c1277613db1d28709b034a017ab2cae4be07ae10` is the torrent infohash and `completed` contains the number of peers +//! that have a full version of the torrent data, also known as seeders. +//! +//! Refer to [`peer`](torrust_tracker_primitives::peer) for more information about peers. +pub mod manager; +pub mod repository; +pub mod services; diff --git a/packages/tracker-core/src/torrent/repository/in_memory.rs b/packages/tracker-core/src/torrent/repository/in_memory.rs new file mode 100644 index 000000000..e50a82933 --- /dev/null +++ b/packages/tracker-core/src/torrent/repository/in_memory.rs @@ -0,0 +1,276 @@ +//! In-memory torrents repository. +use std::cmp::max; +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_configuration::{TrackerPolicy, TORRENT_PEERS_LIMIT}; +use torrust_tracker_primitives::pagination::Pagination; +use torrust_tracker_primitives::swarm_metadata::{AggregateActiveSwarmMetadata, SwarmMetadata}; +use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch, NumberOfDownloads, NumberOfDownloadsBTreeMap}; +use torrust_tracker_swarm_coordination_registry::{CoordinatorHandle, Registry}; + +/// In-memory repository for torrent entries. +/// +/// This repository manages the torrent entries and their associated peer lists +/// in memory. It is built on top of a high-performance data structure (the +/// production implementation) and provides methods to update, query, and remove +/// torrent entries as well as to import persisted data. +/// +/// Multiple implementations were considered, and the chosen implementation is +/// used in production. Other implementations are kept for reference. +#[derive(Default)] +pub struct InMemoryTorrentRepository { + /// The underlying in-memory data structure that stores swarms data. + swarms: Arc, +} + +impl InMemoryTorrentRepository { + #[must_use] + pub fn new(swarms: Arc) -> Self { + Self { swarms } + } + + /// Inserts or updates a peer in the torrent entry corresponding to the + /// given infohash. + /// + /// If the torrent entry already exists, the peer is added to its peer list; + /// otherwise, a new torrent entry is created. + /// + /// # Arguments + /// + /// * `info_hash` - The unique identifier of the torrent. + /// * `peer` - The peer to insert or update in the torrent entry. + /// + /// # Returns + /// + /// `true` if the peer stats were updated. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + pub async fn handle_announcement( + &self, + info_hash: &InfoHash, + peer: &peer::Peer, + opt_persistent_torrent: Option, + ) { + self.swarms + .handle_announcement(info_hash, peer, opt_persistent_torrent) + .await + .expect("Failed to upsert the peer in swarms"); + } + + /// Removes inactive peers from all torrent entries. + /// + /// A peer is considered inactive if its last update timestamp is older than + /// the provided cutoff time. + /// + /// # Arguments + /// + /// * `current_cutoff` - The cutoff timestamp; peers not updated since this + /// time will be removed. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + pub(crate) async fn remove_inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) { + self.swarms + .remove_inactive_peers(current_cutoff) + .await + .expect("Failed to remove inactive peers from swarms"); + } + + /// Removes torrent entries that have no active peers. + /// + /// Depending on the tracker policy, torrents without any peers may be + /// removed to conserve memory. + /// + /// # Arguments + /// + /// * `policy` - The tracker policy containing the configuration for + /// removing peerless torrents. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + pub(crate) async fn remove_peerless_torrents(&self, policy: &TrackerPolicy) { + self.swarms + .remove_peerless_torrents(policy) + .await + .expect("Failed to remove peerless torrents from swarms"); + } + + /// Retrieves a torrent entry by its infohash. + /// + /// # Arguments + /// + /// * `key` - The info hash of the torrent. + /// + /// # Returns + /// + /// An `Option` containing the torrent entry if found. + #[must_use] + pub(crate) fn get(&self, key: &InfoHash) -> Option { + self.swarms.get(key) + } + + /// Retrieves a paginated list of torrent entries. + /// + /// This method returns a vector of tuples, each containing an infohash and + /// its associated torrent entry. The pagination parameters (offset and limit) + /// can be used to control the size of the result set. + /// + /// # Arguments + /// + /// * `pagination` - An optional reference to a `Pagination` object. + /// + /// # Returns + /// + /// A vector of `(InfoHash, TorrentEntry)` tuples. + #[must_use] + pub(crate) fn get_paginated(&self, pagination: Option<&Pagination>) -> Vec<(InfoHash, CoordinatorHandle)> { + self.swarms.get_paginated(pagination) + } + + /// Retrieves swarm metadata for a given torrent. + /// + /// This method returns the swarm metadata (aggregate information such as + /// peer counts) for the torrent specified by the infohash. If the torrent + /// entry is not found, a zeroed metadata struct is returned. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// + /// # Returns + /// + /// A `SwarmMetadata` struct containing the aggregated torrent data. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error.s + #[must_use] + pub(crate) async fn get_swarm_metadata_or_default(&self, info_hash: &InfoHash) -> SwarmMetadata { + self.swarms + .get_swarm_metadata_or_default(info_hash) + .await + .expect("Failed to get swarm metadata") + } + + /// Retrieves torrent peers for a given torrent and client, excluding the + /// requesting client. + /// + /// This method filters out the client making the request (based on its + /// network address) and returns up to a maximum number of peers, defined by + /// the greater of the provided limit or the global `TORRENT_PEERS_LIMIT`. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// * `peer` - The client peer that should be excluded from the returned list. + /// * `limit` - The maximum number of peers to return. + /// + /// # Returns + /// + /// A vector of peers (wrapped in `Arc`) representing the active peers for + /// the torrent, excluding the requesting client. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + #[must_use] + pub(crate) async fn get_peers_for(&self, info_hash: &InfoHash, peer: &peer::Peer, limit: usize) -> Vec> { + self.swarms + .get_peers_peers_excluding(info_hash, peer, max(limit, TORRENT_PEERS_LIMIT)) + .await + .expect("Failed to get other peers in swarm") + } + + /// Retrieves the list of peers for a given torrent. + /// + /// This method returns up to `TORRENT_PEERS_LIMIT` peers for the torrent + /// specified by the info-hash. + /// + /// # Arguments + /// + /// * `info_hash` - The info hash of the torrent. + /// + /// # Returns + /// + /// A vector of peers (wrapped in `Arc`) representing the active peers for + /// the torrent. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + #[must_use] + pub async fn get_torrent_peers(&self, info_hash: &InfoHash) -> Vec> { + // todo: pass the limit as an argument like `get_peers_for` + self.swarms + .get_swarm_peers(info_hash, TORRENT_PEERS_LIMIT) + .await + .expect("Failed to get other peers in swarm") + } + + /// Calculates and returns overall torrent metrics. + /// + /// The returned [`AggregateSwarmMetadata`] contains aggregate data such as + /// the total number of torrents, total complete (seeders), incomplete + /// (leechers), and downloaded counts. + /// + /// # Returns + /// + /// A [`AggregateSwarmMetadata`] struct with the aggregated metrics. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + #[must_use] + pub async fn get_aggregate_swarm_metadata(&self) -> AggregateActiveSwarmMetadata { + self.swarms + .get_aggregate_swarm_metadata() + .await + .expect("Failed to get aggregate swarm metadata") + } + + /// Counts the number of peerless torrents in the repository. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + #[must_use] + pub async fn count_peerless_torrents(&self) -> usize { + self.swarms + .count_peerless_torrents() + .await + .expect("Failed to count peerless torrents") + } + + /// Counts the number of peers in the repository. + /// + /// # Panics + /// + /// This function panics if the underling swarms return an error. + #[must_use] + pub async fn count_peers(&self) -> usize { + self.swarms.count_peers().await.expect("Failed to count peers") + } + + /// Imports persistent torrent data into the in-memory repository. + /// + /// This method takes a set of persisted torrent entries (e.g., from a database) + /// and imports them into the in-memory repository for immediate access. + /// + /// # Arguments + /// + /// * `persistent_torrents` - A reference to the persisted torrent data. + pub fn import_persistent(&self, persistent_torrents: &NumberOfDownloadsBTreeMap) { + self.swarms.import_persistent(persistent_torrents); + } + + /// Checks if the repository contains a torrent entry for the given infohash. + #[must_use] + pub fn contains(&self, info_hash: &InfoHash) -> bool { + self.swarms.contains(info_hash) + } +} diff --git a/packages/tracker-core/src/torrent/repository/mod.rs b/packages/tracker-core/src/torrent/repository/mod.rs new file mode 100644 index 000000000..d8325dec5 --- /dev/null +++ b/packages/tracker-core/src/torrent/repository/mod.rs @@ -0,0 +1,2 @@ +//! Torrent repository implementations. +pub mod in_memory; diff --git a/packages/tracker-core/src/torrent/services.rs b/packages/tracker-core/src/torrent/services.rs new file mode 100644 index 000000000..874ad1349 --- /dev/null +++ b/packages/tracker-core/src/torrent/services.rs @@ -0,0 +1,455 @@ +//! Core tracker domain services. +//! +//! This module defines the primary services for retrieving torrent-related data +//! from the tracker. There are two main services: +//! +//! - [`get_torrent_info`]: Returns all available data (including the list of +//! peers) about a single torrent. +//! - [`get_torrents_page`] and [`get_torrents`]: Return summarized data about +//! multiple torrents, excluding the peer list. +//! +//! The full torrent info is represented by the [`Info`] struct, which includes +//! swarm data (peer list) and aggregate metrics. The [`BasicInfo`] struct +//! provides similar data but without the list of peers, making it suitable for +//! bulk queries. +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_primitives::pagination::Pagination; +use torrust_tracker_primitives::peer; + +use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + +/// Full torrent information, including swarm (peer) details. +/// +/// This struct contains all the information that the tracker holds about a +/// torrent, including the infohash, aggregate swarm metrics (seeders, leechers, +/// completed downloads), and the complete list of peers in the swarm. +#[derive(Debug, PartialEq)] +pub struct Info { + /// The infohash of the torrent this data is related to + pub info_hash: InfoHash, + + /// The total number of seeders for this torrent. Peer that actively serving + /// a full copy of the torrent data + pub seeders: u64, + + /// The total number of peers that have ever complete downloading this + /// torrent + pub completed: u64, + + /// The total number of leechers for this torrent. Peers that actively + /// downloading this torrent + pub leechers: u64, + + /// The swarm: the list of peers that are actively trying to download or + /// serving this torrent + pub peers: Option>, +} + +/// Basic torrent information, excluding the list of peers. +/// +/// This struct contains the same aggregate metrics as [`Info`] (infohash, +/// seeders, completed, leechers) but omits the peer list. It is used when only +/// summary information is needed. +#[derive(Debug, PartialEq, Clone)] +pub struct BasicInfo { + /// The infohash of the torrent this data is related to + pub info_hash: InfoHash, + + /// The total number of seeders for this torrent. Peer that actively serving + /// a full copy of the torrent data + pub seeders: u64, + + /// The total number of peers that have ever complete downloading this + /// torrent + pub completed: u64, + + /// The total number of leechers for this torrent. Peers that actively + /// downloading this torrent + pub leechers: u64, +} + +/// Retrieves complete torrent information for a given torrent. +/// +/// This function queries the in-memory torrent repository for a torrent entry +/// matching the provided infohash. If found, it extracts the swarm metadata +/// (aggregate metrics) and the current list of peers, and returns an [`Info`] +/// struct. +/// +/// # Arguments +/// +/// * `in_memory_torrent_repository` - A shared reference to the in-memory +/// torrent repository. +/// * `info_hash` - A reference to the torrent's infohash. +/// +/// # Returns +/// +/// An [`Option`] which is: +/// - `Some(Info)` if the torrent exists in the repository. +/// - `None` if the torrent is not found. +/// +/// # Panics +/// +/// This function panics if the lock for the torrent entry cannot be obtained. +#[must_use] +pub async fn get_torrent_info( + in_memory_torrent_repository: &Arc, + info_hash: &InfoHash, +) -> Option { + let torrent_entry_option = in_memory_torrent_repository.get(info_hash); + + let torrent_entry = torrent_entry_option?; + + let stats = torrent_entry.lock().await.metadata(); + + let peers = torrent_entry.lock().await.peers(None); + + let peers = Some(peers.iter().map(|peer| **peer).collect()); + + Some(Info { + info_hash: *info_hash, + seeders: u64::from(stats.complete), + completed: u64::from(stats.downloaded), + leechers: u64::from(stats.incomplete), + peers, + }) +} + +/// Retrieves summarized torrent information for a paginated set of torrents. +/// +/// This function returns a vector of [`BasicInfo`] structures for torrents in +/// the repository, according to the provided pagination parameters. The +/// returned data excludes the peer list, providing only aggregate metrics. +/// +/// # Arguments +/// +/// * `in_memory_torrent_repository` - A shared reference to the in-memory +/// torrent repository. +/// * `pagination` - An optional reference to a [`Pagination`] object specifying +/// offset and limit. +/// +/// # Returns +/// +/// A vector of [`BasicInfo`] structs representing the summarized data of the +/// torrents. +/// +/// # Panics +/// +/// This function panics if the lock for the torrent entry cannot be obtained. +#[must_use] +pub async fn get_torrents_page( + in_memory_torrent_repository: &Arc, + pagination: Option<&Pagination>, +) -> Vec { + let mut basic_infos: Vec = vec![]; + + for (info_hash, torrent_entry) in in_memory_torrent_repository.get_paginated(pagination) { + let stats = torrent_entry.lock().await.metadata(); + + basic_infos.push(BasicInfo { + info_hash, + seeders: u64::from(stats.complete), + completed: u64::from(stats.downloaded), + leechers: u64::from(stats.incomplete), + }); + } + + basic_infos +} + +/// Retrieves summarized torrent information for a specified list of torrents. +/// +/// This function iterates over a slice of infohashes, fetches the corresponding +/// swarm metadata from the in-memory repository (if available), and returns a +/// vector of [`BasicInfo`] structs. This function is useful for bulk queries +/// where detailed peer information is not required. +/// +/// # Arguments +/// +/// * `in_memory_torrent_repository` - A shared reference to the in-memory +/// torrent repository. +/// * `info_hashes` - A slice of infohashes for which to retrieve the torrent +/// information. +/// +/// # Returns +/// +/// A vector of [`BasicInfo`] structs for the requested torrents. +/// +/// # Panics +/// +/// This function panics if the lock for the torrent entry cannot be obtained. +#[must_use] +pub async fn get_torrents( + in_memory_torrent_repository: &Arc, + info_hashes: &[InfoHash], +) -> Vec { + let mut basic_infos: Vec = vec![]; + + for info_hash in info_hashes { + if let Some(torrent_entry) = in_memory_torrent_repository.get(info_hash) { + let metadata = torrent_entry.lock().await.metadata(); + + basic_infos.push(BasicInfo { + info_hash: *info_hash, + seeders: u64::from(metadata.complete), + completed: u64::from(metadata.downloaded), + leechers: u64::from(metadata.incomplete), + }); + } + } + + basic_infos +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; + + fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Started, + } + } + + mod getting_a_torrent_info { + + use std::str::FromStr; + use std::sync::Arc; + + use bittorrent_primitives::info_hash::InfoHash; + + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + use crate::torrent::services::tests::sample_peer; + use crate::torrent::services::{get_torrent_info, Info}; + + #[tokio::test] + async fn it_should_return_none_if_the_tracker_does_not_have_the_torrent() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let torrent_info = get_torrent_info( + &in_memory_torrent_repository, + &InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(), // DevSkim: ignore DS173237 + ) + .await; + + assert!(torrent_info.is_none()); + } + + #[tokio::test] + async fn it_should_return_the_torrent_info_if_the_tracker_has_the_torrent() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + in_memory_torrent_repository + .handle_announcement(&info_hash, &sample_peer(), None) + .await; + + let torrent_info = get_torrent_info(&in_memory_torrent_repository, &info_hash).await.unwrap(); + + assert_eq!( + torrent_info, + Info { + info_hash: InfoHash::from_str(&hash).unwrap(), + seeders: 1, + completed: 0, + leechers: 0, + peers: Some(vec![sample_peer()]), + } + ); + } + } + + mod searching_for_torrents { + + use std::str::FromStr; + use std::sync::Arc; + + use bittorrent_primitives::info_hash::InfoHash; + + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + use crate::torrent::services::tests::sample_peer; + use crate::torrent::services::{get_torrents_page, BasicInfo, Pagination}; + + #[tokio::test] + async fn it_should_return_an_empty_result_if_the_tracker_does_not_have_any_torrent() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let torrents = get_torrents_page(&in_memory_torrent_repository, Some(&Pagination::default())).await; + + assert_eq!(torrents, vec![]); + } + + #[tokio::test] + async fn it_should_return_a_summarized_info_for_all_torrents() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash = InfoHash::from_str(&hash).unwrap(); + + in_memory_torrent_repository + .handle_announcement(&info_hash, &sample_peer(), None) + .await; + + let torrents = get_torrents_page(&in_memory_torrent_repository, Some(&Pagination::default())).await; + + assert_eq!( + torrents, + vec![BasicInfo { + info_hash: InfoHash::from_str(&hash).unwrap(), + seeders: 1, + completed: 0, + leechers: 0, + }] + ); + } + + #[tokio::test] + async fn it_should_allow_limiting_the_number_of_torrents_in_the_result() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash1 = InfoHash::from_str(&hash1).unwrap(); + + let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); // DevSkim: ignore DS173237 + let info_hash2 = InfoHash::from_str(&hash2).unwrap(); + + in_memory_torrent_repository + .handle_announcement(&info_hash1, &sample_peer(), None) + .await; + in_memory_torrent_repository + .handle_announcement(&info_hash2, &sample_peer(), None) + .await; + + let offset = 0; + let limit = 1; + + let torrents = get_torrents_page(&in_memory_torrent_repository, Some(&Pagination::new(offset, limit))).await; + + assert_eq!(torrents.len(), 1); + } + + #[tokio::test] + async fn it_should_allow_using_pagination_in_the_result() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash1 = InfoHash::from_str(&hash1).unwrap(); + + let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); // DevSkim: ignore DS173237 + let info_hash2 = InfoHash::from_str(&hash2).unwrap(); + + in_memory_torrent_repository + .handle_announcement(&info_hash1, &sample_peer(), None) + .await; + in_memory_torrent_repository + .handle_announcement(&info_hash2, &sample_peer(), None) + .await; + + let offset = 1; + let limit = 4000; + + let torrents = get_torrents_page(&in_memory_torrent_repository, Some(&Pagination::new(offset, limit))).await; + + assert_eq!(torrents.len(), 1); + assert_eq!( + torrents, + vec![BasicInfo { + info_hash: InfoHash::from_str(&hash1).unwrap(), + seeders: 1, + completed: 0, + leechers: 0, + }] + ); + } + + #[tokio::test] + async fn it_should_return_torrents_ordered_by_info_hash() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); // DevSkim: ignore DS173237 + let info_hash1 = InfoHash::from_str(&hash1).unwrap(); + in_memory_torrent_repository + .handle_announcement(&info_hash1, &sample_peer(), None) + .await; + + let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); // DevSkim: ignore DS173237 + let info_hash2 = InfoHash::from_str(&hash2).unwrap(); + in_memory_torrent_repository + .handle_announcement(&info_hash2, &sample_peer(), None) + .await; + + let torrents = get_torrents_page(&in_memory_torrent_repository, Some(&Pagination::default())).await; + + assert_eq!( + torrents, + vec![ + BasicInfo { + info_hash: InfoHash::from_str(&hash2).unwrap(), + seeders: 1, + completed: 0, + leechers: 0, + }, + BasicInfo { + info_hash: InfoHash::from_str(&hash1).unwrap(), + seeders: 1, + completed: 0, + leechers: 0, + } + ] + ); + } + } + + mod getting_basic_torrent_info_for_multiple_torrents_at_once { + + use std::sync::Arc; + + use crate::test_helpers::tests::sample_info_hash; + use crate::torrent::repository::in_memory::InMemoryTorrentRepository; + use crate::torrent::services::tests::sample_peer; + use crate::torrent::services::{get_torrents, BasicInfo}; + + #[tokio::test] + async fn it_should_return_an_empty_list_if_none_of_the_requested_torrents_is_found() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let torrent_info = get_torrents(&in_memory_torrent_repository, &[sample_info_hash()]).await; + + assert!(torrent_info.is_empty()); + } + + #[tokio::test] + async fn it_should_return_a_list_with_basic_info_about_the_requested_torrents() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let info_hash = sample_info_hash(); + + in_memory_torrent_repository + .handle_announcement(&info_hash, &sample_peer(), None) + .await; + + let torrent_info = get_torrents(&in_memory_torrent_repository, &[info_hash]).await; + + assert_eq!( + torrent_info, + vec!(BasicInfo { + info_hash, + seeders: 1, + completed: 0, + leechers: 0, + }) + ); + } + } +} diff --git a/packages/tracker-core/src/whitelist/authorization.rs b/packages/tracker-core/src/whitelist/authorization.rs new file mode 100644 index 000000000..a8323457b --- /dev/null +++ b/packages/tracker-core/src/whitelist/authorization.rs @@ -0,0 +1,183 @@ +//! Whitelist authorization. +use std::panic::Location; +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_configuration::Core; +use tracing::instrument; + +use super::repository::in_memory::InMemoryWhitelist; +use crate::error::WhitelistError; + +/// Manages the authorization of torrents based on the whitelist. +/// +/// Used to determine whether a given torrent (`infohash`) is allowed +/// to be announced or scraped from the tracker. +pub struct WhitelistAuthorization { + /// Core tracker configuration. + config: Core, + + /// The in-memory list of allowed torrents. + in_memory_whitelist: Arc, +} + +impl WhitelistAuthorization { + /// Creates a new `WhitelistAuthorization` instance. + /// + /// # Arguments + /// - `config`: Tracker configuration. + /// - `in_memory_whitelist`: The in-memory whitelist instance. + /// + /// # Returns + /// A new `WhitelistAuthorization` instance. + pub fn new(config: &Core, in_memory_whitelist: &Arc) -> Self { + Self { + config: config.clone(), + in_memory_whitelist: in_memory_whitelist.clone(), + } + } + + /// Checks whether a torrent is authorized. + /// + /// - If the tracker is **public**, all torrents are authorized. + /// - If the tracker is **private** (listed mode), only whitelisted torrents + /// are authorized. + /// + /// # Errors + /// Returns `WhitelistError::TorrentNotWhitelisted` if the tracker is in `listed` mode + /// and the `info_hash` is not in the whitelist. + #[instrument(skip(self, info_hash), err)] + pub async fn authorize(&self, info_hash: &InfoHash) -> Result<(), WhitelistError> { + if !self.is_listed() { + return Ok(()); + } + + if self.is_info_hash_whitelisted(info_hash).await { + return Ok(()); + } + + Err(WhitelistError::TorrentNotWhitelisted { + info_hash: *info_hash, + location: Location::caller(), + }) + } + + /// Checks if the tracker is running in "listed" mode. + fn is_listed(&self) -> bool { + self.config.listed + } + + /// Checks if a torrent is present in the whitelist. + async fn is_info_hash_whitelisted(&self, info_hash: &InfoHash) -> bool { + self.in_memory_whitelist.contains(info_hash).await + } +} + +#[cfg(test)] +mod tests { + + mod the_whitelist_authorization_for_announce_and_scrape_actions { + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + + use crate::whitelist::authorization::WhitelistAuthorization; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + + fn initialize_whitelist_authorization_with(config: &Core) -> Arc { + let (whitelist_authorization, _in_memory_whitelist) = + initialize_whitelist_authorization_and_dependencies_with(config); + whitelist_authorization + } + + fn initialize_whitelist_authorization_and_dependencies_with( + config: &Core, + ) -> (Arc, Arc) { + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(config, &in_memory_whitelist.clone())); + + (whitelist_authorization, in_memory_whitelist) + } + + mod when_the_tacker_is_configured_as_listed { + + use torrust_tracker_configuration::Core; + + use crate::error::WhitelistError; + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::authorization::tests::the_whitelist_authorization_for_announce_and_scrape_actions::{ + initialize_whitelist_authorization_and_dependencies_with, initialize_whitelist_authorization_with, + }; + + fn configuration_for_listed_tracker() -> Core { + Core { + listed: true, + ..Default::default() + } + } + + #[tokio::test] + async fn should_authorize_a_whitelisted_infohash() { + let (whitelist_authorization, in_memory_whitelist) = + initialize_whitelist_authorization_and_dependencies_with(&configuration_for_listed_tracker()); + + let info_hash = sample_info_hash(); + + let _unused = in_memory_whitelist.add(&info_hash).await; + + let result = whitelist_authorization.authorize(&info_hash).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn should_not_authorize_a_non_whitelisted_infohash() { + let whitelist_authorization = initialize_whitelist_authorization_with(&configuration_for_listed_tracker()); + + let result = whitelist_authorization.authorize(&sample_info_hash()).await; + + assert!(matches!(result.unwrap_err(), WhitelistError::TorrentNotWhitelisted { .. })); + } + } + + mod when_the_tacker_is_not_configured_as_listed { + + use torrust_tracker_configuration::Core; + + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::authorization::tests::the_whitelist_authorization_for_announce_and_scrape_actions::{ + initialize_whitelist_authorization_and_dependencies_with, initialize_whitelist_authorization_with, + }; + + fn configuration_for_non_listed_tracker() -> Core { + Core { + listed: false, + ..Default::default() + } + } + + #[tokio::test] + async fn should_authorize_a_whitelisted_infohash() { + let (whitelist_authorization, in_memory_whitelist) = + initialize_whitelist_authorization_and_dependencies_with(&configuration_for_non_listed_tracker()); + + let info_hash = sample_info_hash(); + + let _unused = in_memory_whitelist.add(&info_hash).await; + + let result = whitelist_authorization.authorize(&info_hash).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn should_also_authorize_a_non_whitelisted_infohash() { + let whitelist_authorization = initialize_whitelist_authorization_with(&configuration_for_non_listed_tracker()); + + let result = whitelist_authorization.authorize(&sample_info_hash()).await; + + assert!(result.is_ok()); + } + } + } +} diff --git a/packages/tracker-core/src/whitelist/manager.rs b/packages/tracker-core/src/whitelist/manager.rs new file mode 100644 index 000000000..452fcb6c5 --- /dev/null +++ b/packages/tracker-core/src/whitelist/manager.rs @@ -0,0 +1,184 @@ +//! Whitelist manager. +//! +//! This module provides the `WhitelistManager` struct, which is responsible for +//! managing the whitelist of torrents. +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; + +use super::repository::in_memory::InMemoryWhitelist; +use super::repository::persisted::DatabaseWhitelist; +use crate::databases; +/// Manages the whitelist of allowed torrents. +/// +/// This structure handles both the in-memory and persistent representations of +/// the whitelist. It is primarily relevant for private trackers that restrict +/// access to specific torrents. +pub struct WhitelistManager { + /// The in-memory list of allowed torrents. + in_memory_whitelist: Arc, + + /// The persisted list of allowed torrents. + database_whitelist: Arc, +} + +impl WhitelistManager { + /// Creates a new `WhitelistManager` instance. + /// + /// # Arguments + /// + /// - `database_whitelist`: Persistent database-backed whitelist repository. + /// - `in_memory_whitelist`: In-memory whitelist repository for fast runtime + /// access. + /// + /// # Returns + /// + /// A new `WhitelistManager` instance. + #[must_use] + pub fn new(database_whitelist: Arc, in_memory_whitelist: Arc) -> Self { + Self { + in_memory_whitelist, + database_whitelist, + } + } + + /// Adds a torrent to the whitelist. + /// + /// This operation is relevant for private trackers to control which + /// torrents are allowed. + /// + /// # Errors + /// Returns a `database::Error` if the operation fails in the database. + pub async fn add_torrent_to_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + self.database_whitelist.add(info_hash)?; + self.in_memory_whitelist.add(info_hash).await; + Ok(()) + } + + /// Removes a torrent from the whitelist. + /// + /// This operation is relevant for private trackers to revoke access to + /// specific torrents. + /// + /// # Errors + /// Returns a `database::Error` if the operation fails in the database. + pub async fn remove_torrent_from_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + self.database_whitelist.remove(info_hash)?; + self.in_memory_whitelist.remove(info_hash).await; + Ok(()) + } + + /// Loads the whitelist from the database into memory. + /// + /// This is useful when restarting the tracker to ensure the in-memory + /// whitelist is synchronized with the database. + /// + /// # Errors + /// Returns a `database::Error` if the operation fails to load from the database. + pub async fn load_whitelist_from_database(&self) -> Result<(), databases::error::Error> { + let whitelisted_torrents_from_database = self.database_whitelist.load_from_database()?; + + self.in_memory_whitelist.clear().await; + + for info_hash in whitelisted_torrents_from_database { + let _: bool = self.in_memory_whitelist.add(&info_hash).await; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Core; + + use crate::databases::setup::initialize_database; + use crate::databases::Database; + use crate::test_helpers::tests::ephemeral_configuration_for_listed_tracker; + use crate::whitelist::manager::WhitelistManager; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + use crate::whitelist::repository::persisted::DatabaseWhitelist; + + struct WhitelistManagerDeps { + pub _database: Arc>, + pub database_whitelist: Arc, + pub in_memory_whitelist: Arc, + } + + fn initialize_whitelist_manager_for_whitelisted_tracker() -> (Arc, Arc) { + let config = ephemeral_configuration_for_listed_tracker(); + initialize_whitelist_manager_and_deps(&config) + } + + fn initialize_whitelist_manager_and_deps(config: &Core) -> (Arc, Arc) { + let database = initialize_database(config); + let database_whitelist = Arc::new(DatabaseWhitelist::new(database.clone())); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + + let whitelist_manager = Arc::new(WhitelistManager::new(database_whitelist.clone(), in_memory_whitelist.clone())); + + ( + whitelist_manager, + Arc::new(WhitelistManagerDeps { + _database: database, + database_whitelist, + in_memory_whitelist, + }), + ) + } + + mod configured_as_whitelisted { + + mod handling_the_torrent_whitelist { + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::manager::tests::initialize_whitelist_manager_for_whitelisted_tracker; + + #[tokio::test] + async fn it_should_add_a_torrent_to_the_whitelist() { + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + + let info_hash = sample_info_hash(); + + whitelist_manager.add_torrent_to_whitelist(&info_hash).await.unwrap(); + + assert!(services.in_memory_whitelist.contains(&info_hash).await); + assert!(services.database_whitelist.load_from_database().unwrap().contains(&info_hash)); + } + + #[tokio::test] + async fn it_should_remove_a_torrent_from_the_whitelist() { + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + + let info_hash = sample_info_hash(); + + whitelist_manager.add_torrent_to_whitelist(&info_hash).await.unwrap(); + + whitelist_manager.remove_torrent_from_whitelist(&info_hash).await.unwrap(); + + assert!(!services.in_memory_whitelist.contains(&info_hash).await); + assert!(!services.database_whitelist.load_from_database().unwrap().contains(&info_hash)); + } + + mod persistence { + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::manager::tests::initialize_whitelist_manager_for_whitelisted_tracker; + + #[tokio::test] + async fn it_should_load_the_whitelist_from_the_database() { + let (whitelist_manager, services) = initialize_whitelist_manager_for_whitelisted_tracker(); + + let info_hash = sample_info_hash(); + + services.database_whitelist.add(&info_hash).unwrap(); + + whitelist_manager.load_whitelist_from_database().await.unwrap(); + + assert!(services.in_memory_whitelist.contains(&info_hash).await); + } + } + } + } +} diff --git a/packages/tracker-core/src/whitelist/mod.rs b/packages/tracker-core/src/whitelist/mod.rs new file mode 100644 index 000000000..d9ad18311 --- /dev/null +++ b/packages/tracker-core/src/whitelist/mod.rs @@ -0,0 +1,58 @@ +//! This module contains the logic to manage the torrent whitelist. +//! +//! In tracker configurations where the tracker operates in "listed" mode, only +//! torrents that have been explicitly added to the whitelist are allowed to +//! perform announce and scrape actions. This module provides all the +//! functionality required to manage such a whitelist. +//! +//! The module is organized into the following submodules: +//! +//! - **`authorization`**: Contains the logic to authorize torrents based on their +//! whitelist status. +//! - **`manager`**: Provides high-level management functions for the whitelist, +//! such as adding or removing torrents. +//! - **`repository`**: Implements persistence for whitelist data. +//! - **`setup`**: Provides initialization routines for setting up the whitelist +//! system. +//! - **`test_helpers`**: Contains helper functions and fixtures for testing +//! whitelist functionality. +pub mod authorization; +pub mod manager; +pub mod repository; +pub mod setup; +pub mod test_helpers; + +#[cfg(test)] +mod tests { + + mod configured_as_whitelisted { + + mod handling_authorization { + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::test_helpers::tests::initialize_whitelist_services_for_listed_tracker; + + #[tokio::test] + async fn it_should_authorize_the_announce_and_scrape_actions_on_whitelisted_torrents() { + let (whitelist_authorization, whitelist_manager) = initialize_whitelist_services_for_listed_tracker(); + + let info_hash = sample_info_hash(); + + let result = whitelist_manager.add_torrent_to_whitelist(&info_hash).await; + assert!(result.is_ok()); + + let result = whitelist_authorization.authorize(&info_hash).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn it_should_not_authorize_the_announce_and_scrape_actions_on_not_whitelisted_torrents() { + let (whitelist_authorization, _whitelist_manager) = initialize_whitelist_services_for_listed_tracker(); + + let info_hash = sample_info_hash(); + + let result = whitelist_authorization.authorize(&info_hash).await; + assert!(result.is_err()); + } + } + } +} diff --git a/packages/tracker-core/src/whitelist/repository/in_memory.rs b/packages/tracker-core/src/whitelist/repository/in_memory.rs new file mode 100644 index 000000000..0cee3a94b --- /dev/null +++ b/packages/tracker-core/src/whitelist/repository/in_memory.rs @@ -0,0 +1,97 @@ +//! The in-memory list of allowed torrents. +use bittorrent_primitives::info_hash::InfoHash; + +/// In-memory whitelist to manage allowed torrents. +/// +/// Stores `InfoHash` values for quick lookup and modification. +#[derive(Debug, Default)] +pub struct InMemoryWhitelist { + /// A thread-safe set of whitelisted `InfoHash` values. + whitelist: tokio::sync::RwLock>, +} + +impl InMemoryWhitelist { + /// Adds a torrent to the in-memory whitelist. + /// + /// # Returns + /// + /// - `true` if the torrent was newly added. + /// - `false` if the torrent was already in the whitelist. + pub async fn add(&self, info_hash: &InfoHash) -> bool { + self.whitelist.write().await.insert(*info_hash) + } + + /// Removes a torrent from the in-memory whitelist. + /// + /// # Returns + /// + /// - `true` if the torrent was present and removed. + /// - `false` if the torrent was not found. + pub(crate) async fn remove(&self, info_hash: &InfoHash) -> bool { + self.whitelist.write().await.remove(info_hash) + } + + /// Checks if a torrent is in the whitelist. + pub async fn contains(&self, info_hash: &InfoHash) -> bool { + self.whitelist.read().await.contains(info_hash) + } + + /// Clears all torrents from the whitelist. + pub(crate) async fn clear(&self) { + let mut whitelist = self.whitelist.write().await; + whitelist.clear(); + } +} + +#[cfg(test)] +mod tests { + + use crate::test_helpers::tests::sample_info_hash; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + + #[tokio::test] + async fn should_allow_adding_a_new_torrent_to_the_whitelist() { + let info_hash = sample_info_hash(); + + let whitelist = InMemoryWhitelist::default(); + + whitelist.add(&info_hash).await; + + assert!(whitelist.contains(&info_hash).await); + } + + #[tokio::test] + async fn should_allow_removing_a_new_torrent_to_the_whitelist() { + let info_hash = sample_info_hash(); + + let whitelist = InMemoryWhitelist::default(); + + whitelist.add(&info_hash).await; + whitelist.remove(&sample_info_hash()).await; + + assert!(!whitelist.contains(&info_hash).await); + } + + #[tokio::test] + async fn should_allow_clearing_the_whitelist() { + let info_hash = sample_info_hash(); + + let whitelist = InMemoryWhitelist::default(); + + whitelist.add(&info_hash).await; + whitelist.clear().await; + + assert!(!whitelist.contains(&info_hash).await); + } + + #[tokio::test] + async fn should_allow_checking_if_an_infohash_is_whitelisted() { + let info_hash = sample_info_hash(); + + let whitelist = InMemoryWhitelist::default(); + + whitelist.add(&info_hash).await; + + assert!(whitelist.contains(&info_hash).await); + } +} diff --git a/packages/tracker-core/src/whitelist/repository/mod.rs b/packages/tracker-core/src/whitelist/repository/mod.rs new file mode 100644 index 000000000..d900a8c29 --- /dev/null +++ b/packages/tracker-core/src/whitelist/repository/mod.rs @@ -0,0 +1,3 @@ +//! Repository implementations for the whitelist. +pub mod in_memory; +pub mod persisted; diff --git a/packages/tracker-core/src/whitelist/repository/persisted.rs b/packages/tracker-core/src/whitelist/repository/persisted.rs new file mode 100644 index 000000000..eec6704d6 --- /dev/null +++ b/packages/tracker-core/src/whitelist/repository/persisted.rs @@ -0,0 +1,142 @@ +//! The repository that persists the whitelist. +use std::sync::Arc; + +use bittorrent_primitives::info_hash::InfoHash; + +use crate::databases::{self, Database}; + +/// The persisted list of allowed torrents. +/// +/// This repository handles adding, removing, and loading torrents +/// from a persistent database like `SQLite` or `MySQL`ç. +pub struct DatabaseWhitelist { + /// A database driver implementation: [`Sqlite3`](crate::core::databases::sqlite) + /// or [`MySQL`](crate::core::databases::mysql) + database: Arc>, +} + +impl DatabaseWhitelist { + /// Creates a new `DatabaseWhitelist`. + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// Adds a torrent to the whitelist if not already present. + /// + /// # Errors + /// Returns a `database::Error` if unable to add the `info_hash` to the + /// whitelist. + pub(crate) fn add(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; + + if is_whitelisted { + return Ok(()); + } + + self.database.add_info_hash_to_whitelist(*info_hash)?; + + Ok(()) + } + + /// Removes a torrent from the whitelist if it exists. + /// + /// # Errors + /// Returns a `database::Error` if unable to remove the `info_hash`. + pub(crate) fn remove(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { + let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; + + if !is_whitelisted { + return Ok(()); + } + + self.database.remove_info_hash_from_whitelist(*info_hash)?; + + Ok(()) + } + + /// Loads the entire whitelist from the database. + /// + /// # Errors + /// Returns a `database::Error` if unable to load whitelisted `info_hash` + /// values. + pub(crate) fn load_from_database(&self) -> Result, databases::error::Error> { + self.database.load_whitelist() + } +} + +#[cfg(test)] +mod tests { + mod the_persisted_whitelist_repository { + + use crate::databases::setup::initialize_database; + use crate::test_helpers::tests::{ephemeral_configuration_for_listed_tracker, sample_info_hash}; + use crate::whitelist::repository::persisted::DatabaseWhitelist; + + fn initialize_database_whitelist() -> DatabaseWhitelist { + let configuration = ephemeral_configuration_for_listed_tracker(); + let database = initialize_database(&configuration); + DatabaseWhitelist::new(database) + } + + #[test] + fn should_add_a_new_infohash_to_the_list() { + let whitelist = initialize_database_whitelist(); + + let infohash = sample_info_hash(); + + let _result = whitelist.add(&infohash); + + assert_eq!(whitelist.load_from_database().unwrap(), vec!(infohash)); + } + + #[test] + fn should_remove_a_infohash_from_the_list() { + let whitelist = initialize_database_whitelist(); + + let infohash = sample_info_hash(); + + let _result = whitelist.add(&infohash); + + let _result = whitelist.remove(&infohash); + + assert_eq!(whitelist.load_from_database().unwrap(), vec!()); + } + + #[test] + fn should_load_all_infohashes_from_the_database() { + let whitelist = initialize_database_whitelist(); + + let infohash = sample_info_hash(); + + let _result = whitelist.add(&infohash); + + let result = whitelist.load_from_database().unwrap(); + + assert_eq!(result, vec!(infohash)); + } + + #[test] + fn should_not_add_the_same_infohash_to_the_list_twice() { + let whitelist = initialize_database_whitelist(); + + let infohash = sample_info_hash(); + + let _result = whitelist.add(&infohash); + let _result = whitelist.add(&infohash); + + assert_eq!(whitelist.load_from_database().unwrap(), vec!(infohash)); + } + + #[test] + fn should_not_fail_removing_an_infohash_that_is_not_in_the_list() { + let whitelist = initialize_database_whitelist(); + + let infohash = sample_info_hash(); + + let result = whitelist.remove(&infohash); + + assert!(result.is_ok()); + } + } +} diff --git a/packages/tracker-core/src/whitelist/setup.rs b/packages/tracker-core/src/whitelist/setup.rs new file mode 100644 index 000000000..cb18c1478 --- /dev/null +++ b/packages/tracker-core/src/whitelist/setup.rs @@ -0,0 +1,41 @@ +//! Initializes the whitelist manager. +//! +//! This module provides functions to set up the `WhitelistManager`, which is responsible +//! for managing whitelisted torrents in both the in-memory and persistent database repositories. +use std::sync::Arc; + +use super::manager::WhitelistManager; +use super::repository::in_memory::InMemoryWhitelist; +use super::repository::persisted::DatabaseWhitelist; +use crate::databases::Database; + +/// Initializes the `WhitelistManager` by combining in-memory and database +/// repositories. +/// +/// The `WhitelistManager` handles the operations related to whitelisted +/// torrents, such as adding, removing, and verifying torrents in the whitelist. +/// It operates with: +/// +/// 1. **In-Memory Whitelist:** Provides fast, runtime-based access to +/// whitelisted torrents. +/// 2. **Database Whitelist:** Ensures persistent storage of the whitelist data. +/// +/// # Arguments +/// +/// * `database` - An `Arc>` representing the database connection, +/// sed for persistent whitelist storage. +/// * `in_memory_whitelist` - An `Arc` representing the in-memory +/// whitelist repository for fast access. +/// +/// # Returns +/// +/// An `Arc` instance that manages both the in-memory and database +/// whitelist repositories. +#[must_use] +pub fn initialize_whitelist_manager( + database: Arc>, + in_memory_whitelist: Arc, +) -> Arc { + let database_whitelist = Arc::new(DatabaseWhitelist::new(database)); + Arc::new(WhitelistManager::new(database_whitelist, in_memory_whitelist)) +} diff --git a/packages/tracker-core/src/whitelist/test_helpers.rs b/packages/tracker-core/src/whitelist/test_helpers.rs new file mode 100644 index 000000000..cf1699be4 --- /dev/null +++ b/packages/tracker-core/src/whitelist/test_helpers.rs @@ -0,0 +1,35 @@ +//! Generic test helper functions for the whitelist module. +//! +//! This module provides utility functions to initialize the whitelist services required for testing. +//! In particular, it sets up the `WhitelistAuthorization` and `WhitelistManager` services using a +//! configured database and an in-memory whitelist repository. +#[cfg(test)] +pub(crate) mod tests { + + use std::sync::Arc; + + use torrust_tracker_configuration::Configuration; + + use crate::databases::setup::initialize_database; + use crate::whitelist::authorization::WhitelistAuthorization; + use crate::whitelist::manager::WhitelistManager; + use crate::whitelist::repository::in_memory::InMemoryWhitelist; + use crate::whitelist::setup::initialize_whitelist_manager; + + #[must_use] + pub fn initialize_whitelist_services(config: &Configuration) -> (Arc, Arc) { + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let whitelist_manager = initialize_whitelist_manager(database.clone(), in_memory_whitelist.clone()); + + (whitelist_authorization, whitelist_manager) + } + + #[must_use] + pub fn initialize_whitelist_services_for_listed_tracker() -> (Arc, Arc) { + use torrust_tracker_test_helpers::configuration; + + initialize_whitelist_services(&configuration::ephemeral_listed()) + } +} diff --git a/packages/tracker-core/tests/common/fixtures.rs b/packages/tracker-core/tests/common/fixtures.rs new file mode 100644 index 000000000..ea9c93a65 --- /dev/null +++ b/packages/tracker-core/tests/common/fixtures.rs @@ -0,0 +1,52 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::str::FromStr; + +use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_configuration::Core; +use torrust_tracker_primitives::peer::Peer; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use torrust_tracker_test_helpers::configuration::ephemeral_sqlite_database; + +/// # Panics +/// +/// Will panic if the temporary file path is not a valid UTF-8 string. +#[must_use] +pub fn ephemeral_configuration() -> Core { + let mut config = Core::default(); + + let temp_file = ephemeral_sqlite_database(); + temp_file.to_str().unwrap().clone_into(&mut config.database.path); + + config +} + +/// # Panics +/// +/// Will panic if the string representation of the info hash is not a valid infohash. +#[must_use] +pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") +} + +/// Sample peer whose state is not relevant for the tests. +#[must_use] +pub fn sample_peer() -> Peer { + Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(remote_client_ip(), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), // No bytes left to download + event: AnnounceEvent::Completed, + } +} + +// The client peer IP. +#[must_use] +pub fn remote_client_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()) +} diff --git a/packages/tracker-core/tests/common/mod.rs b/packages/tracker-core/tests/common/mod.rs new file mode 100644 index 000000000..414e9d7b5 --- /dev/null +++ b/packages/tracker-core/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod fixtures; +pub mod test_env; diff --git a/packages/tracker-core/tests/common/test_env.rs b/packages/tracker-core/tests/common/test_env.rs new file mode 100644 index 000000000..3fe0464fe --- /dev/null +++ b/packages/tracker-core/tests/common/test_env.rs @@ -0,0 +1,180 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use aquatic_udp_protocol::AnnounceEvent; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::announce_handler::PeersWanted; +use bittorrent_tracker_core::container::TrackerCoreContainer; +use bittorrent_tracker_core::statistics::persisted::load_persisted_metrics; +use tokio::task::yield_now; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Core; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::core::{AnnounceData, ScrapeData}; +use torrust_tracker_primitives::peer::Peer; +use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +pub struct TestEnv { + pub swarm_coordination_registry_container: Arc, + pub tracker_core_container: Arc, +} + +impl TestEnv { + #[must_use] + pub async fn started(core_config: Core) -> Self { + let test_env = TestEnv::new(core_config); + test_env.start().await; + test_env + } + + #[must_use] + pub fn new(core_config: Core) -> Self { + let core_config = Arc::new(core_config); + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + Self { + swarm_coordination_registry_container, + tracker_core_container, + } + } + + pub async fn start(&self) { + let now = DurationSinceUnixEpoch::from_secs(0); + self.load_persisted_metrics(now).await; + self.run_jobs().await; + } + + async fn load_persisted_metrics(&self, now: DurationSinceUnixEpoch) { + load_persisted_metrics( + &self.tracker_core_container.stats_repository, + &self.tracker_core_container.db_downloads_metric_repository, + now, + ) + .await + .unwrap(); + } + + async fn run_jobs(&self) { + let mut jobs = vec![]; + let cancellation_token = CancellationToken::new(); + + let job = torrust_tracker_swarm_coordination_registry::statistics::event::listener::run_event_listener( + self.swarm_coordination_registry_container.event_bus.receiver(), + cancellation_token.clone(), + &self.swarm_coordination_registry_container.stats_repository, + ); + + jobs.push(job); + + let job = bittorrent_tracker_core::statistics::event::listener::run_event_listener( + self.swarm_coordination_registry_container.event_bus.receiver(), + cancellation_token.clone(), + &self.tracker_core_container.stats_repository, + &self.tracker_core_container.db_downloads_metric_repository, + self.tracker_core_container + .core_config + .tracker_policy + .persistent_torrent_completed_stat, + ); + jobs.push(job); + + // Give the event listeners some time to start + // todo: they should notify when they are ready + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + pub async fn announce_peer_started( + &mut self, + mut peer: Peer, + remote_client_ip: &IpAddr, + info_hash: &InfoHash, + ) -> AnnounceData { + peer.event = AnnounceEvent::Started; + + let announce_data = self + .tracker_core_container + .announce_handler + .handle_announcement(info_hash, &mut peer, remote_client_ip, &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + // Give time to the event listeners to process the event + yield_now().await; + + announce_data + } + + pub async fn announce_peer_completed( + &mut self, + mut peer: Peer, + remote_client_ip: &IpAddr, + info_hash: &InfoHash, + ) -> AnnounceData { + peer.event = AnnounceEvent::Completed; + + let announce_data = self + .tracker_core_container + .announce_handler + .handle_announcement(info_hash, &mut peer, remote_client_ip, &PeersWanted::AsManyAsPossible) + .await + .unwrap(); + + // Give time to the event listeners to process the event + yield_now().await; + + announce_data + } + + pub async fn scrape(&self, info_hash: &InfoHash) -> ScrapeData { + self.tracker_core_container + .scrape_handler + .handle_scrape(&vec![*info_hash]) + .await + .unwrap() + } + + pub async fn increase_number_of_downloads(&mut self, peer: Peer, remote_client_ip: &IpAddr, info_hash: &InfoHash) { + let _announce_data = self.announce_peer_started(peer, remote_client_ip, info_hash).await; + let announce_data = self.announce_peer_completed(peer, remote_client_ip, info_hash).await; + + assert_eq!(announce_data.stats.downloads(), 1); + } + + pub async fn get_swarm_metadata(&self, info_hash: &InfoHash) -> Option { + self.swarm_coordination_registry_container + .swarms + .get_swarm_metadata(info_hash) + .await + .unwrap() + } + + pub async fn remove_swarm(&self, info_hash: &InfoHash) { + self.swarm_coordination_registry_container + .swarms + .remove(info_hash) + .await + .unwrap(); + } + + pub async fn get_counter_value(&self, metric_name: &str) -> u64 { + self.tracker_core_container + .stats_repository + .get_metrics() + .await + .metric_collection + .get_counter_value(&MetricName::new(metric_name), &LabelSet::default()) + .unwrap() + .value() + } +} diff --git a/packages/tracker-core/tests/integration.rs b/packages/tracker-core/tests/integration.rs new file mode 100644 index 000000000..b170aaebd --- /dev/null +++ b/packages/tracker-core/tests/integration.rs @@ -0,0 +1,113 @@ +mod common; + +use common::fixtures::{ephemeral_configuration, remote_client_ip, sample_info_hash, sample_peer}; +use common::test_env::TestEnv; +use torrust_tracker_configuration::AnnouncePolicy; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + +#[tokio::test] +async fn it_should_handle_the_announce_request() { + let mut test_env = TestEnv::started(ephemeral_configuration()).await; + + let announce_data = test_env + .announce_peer_started(sample_peer(), &remote_client_ip(), &sample_info_hash()) + .await; + + assert_eq!( + announce_data, + AnnounceData { + peers: vec![], + stats: SwarmMetadata { + downloaded: 0, + complete: 1, + incomplete: 0 + }, + policy: AnnouncePolicy { + interval: 120, + interval_min: 120 + } + } + ); +} + +#[tokio::test] +async fn it_should_not_return_the_peer_making_the_announce_request() { + let mut test_env = TestEnv::started(ephemeral_configuration()).await; + + let announce_data = test_env + .announce_peer_started(sample_peer(), &remote_client_ip(), &sample_info_hash()) + .await; + + assert_eq!(announce_data.peers.len(), 0); +} + +#[tokio::test] +async fn it_should_handle_the_scrape_request() { + let mut test_env = TestEnv::started(ephemeral_configuration()).await; + + let info_hash = sample_info_hash(); + + let _announce_data = test_env + .announce_peer_started(sample_peer(), &remote_client_ip(), &info_hash) + .await; + + let scrape_data = test_env.scrape(&info_hash).await; + + assert!(scrape_data.files.contains_key(&info_hash)); +} + +#[tokio::test] +async fn it_should_persist_the_number_of_completed_peers_for_each_torrent_into_the_database() { + let mut core_config = ephemeral_configuration(); + core_config.tracker_policy.persistent_torrent_completed_stat = true; + + let mut test_env = TestEnv::started(core_config).await; + + let info_hash = sample_info_hash(); + + test_env + .increase_number_of_downloads(sample_peer(), &remote_client_ip(), &info_hash) + .await; + + assert!(test_env.get_swarm_metadata(&info_hash).await.unwrap().downloads() == 1); + + test_env.remove_swarm(&info_hash).await; + + // Ensure the swarm metadata is removed + assert!(test_env.get_swarm_metadata(&info_hash).await.is_none()); + + // Load torrents from the database to ensure the completed stats are persisted + test_env + .tracker_core_container + .torrents_manager + .load_torrents_from_database() + .unwrap(); + + assert!(test_env.get_swarm_metadata(&info_hash).await.unwrap().downloads() == 1); +} + +#[tokio::test] +async fn it_should_persist_the_global_number_of_completed_peers_into_the_database() { + let mut core_config = ephemeral_configuration(); + + core_config.tracker_policy.persistent_torrent_completed_stat = true; + + let mut test_env = TestEnv::started(core_config.clone()).await; + + test_env + .increase_number_of_downloads(sample_peer(), &remote_client_ip(), &sample_info_hash()) + .await; + + // We run a new instance of the test environment to simulate a restart. + // The new instance uses the same underlying database. + + let new_test_env = TestEnv::started(core_config).await; + + assert_eq!( + new_test_env + .get_counter_value("tracker_core_persistent_torrents_downloads_total") + .await, + 1 + ); +} diff --git a/packages/udp-protocol/Cargo.toml b/packages/udp-protocol/Cargo.toml new file mode 100644 index 000000000..31fd52af8 --- /dev/null +++ b/packages/udp-protocol/Cargo.toml @@ -0,0 +1,20 @@ +[package] +description = "A library with the primitive types and functions for the BitTorrent UDP tracker protocol." +keywords = ["bittorrent", "library", "primitives", "udp"] +name = "bittorrent-udp-tracker-protocol" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } diff --git a/packages/udp-protocol/LICENSE b/packages/udp-protocol/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/udp-protocol/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/udp-protocol/README.md b/packages/udp-protocol/README.md new file mode 100644 index 000000000..4f63fb675 --- /dev/null +++ b/packages/udp-protocol/README.md @@ -0,0 +1,11 @@ +# BitTorrent UDP Tracker Protocol + +A library with the primitive types and functions used by BitTorrent UDP trackers. + +## Documentation + +[Crate documentation](https://docs.rs/bittorrent-udp-protocol). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/udp-protocol/src/lib.rs b/packages/udp-protocol/src/lib.rs new file mode 100644 index 000000000..f0983a7ba --- /dev/null +++ b/packages/udp-protocol/src/lib.rs @@ -0,0 +1,15 @@ +//! Primitive types and functions for `BitTorrent` UDP trackers. +pub mod peer_builder; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/src/servers/udp/peer_builder.rs b/packages/udp-protocol/src/peer_builder.rs similarity index 100% rename from src/servers/udp/peer_builder.rs rename to packages/udp-protocol/src/peer_builder.rs diff --git a/packages/udp-tracker-core/Cargo.toml b/packages/udp-tracker-core/Cargo.toml new file mode 100644 index 000000000..aa12f898f --- /dev/null +++ b/packages/udp-tracker-core/Cargo.toml @@ -0,0 +1,48 @@ +[package] +authors.workspace = true +description = "A library with the core functionality needed to implement a BitTorrent UDP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["api", "bittorrent", "core", "library", "tracker"] +license.workspace = true +name = "bittorrent-udp-tracker-core" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +bittorrent-udp-tracker-protocol = { version = "3.0.0-develop", path = "../udp-protocol" } +bloom = "0.3.2" +blowfish = "0" +cipher = "0.4" +criterion = { version = "0.5.1", features = ["async_tokio"] } +futures = "0" +generic-array = "0" +lazy_static = "1" +rand = "0" +serde = "1.0.219" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-util = "0.7.15" +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +tracing = "0" +zerocopy = "0.7" + +[dev-dependencies] +mockall = "0" +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } + +[[bench]] +harness = false +name = "udp_tracker_core_benchmark" diff --git a/packages/udp-tracker-core/LICENSE b/packages/udp-tracker-core/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/udp-tracker-core/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/udp-tracker-core/README.md b/packages/udp-tracker-core/README.md new file mode 100644 index 000000000..625e5d011 --- /dev/null +++ b/packages/udp-tracker-core/README.md @@ -0,0 +1,15 @@ +# BitTorrent UDP Tracker Core library + +A library with the core functionality needed to implement a BitTorrent UDP tracker. + +You usually don’t need to use this library directly. Instead, you should use the [Torrust Tracker](https://github.com/torrust/torrust-tracker). If you want to build your own tracker, you can use this library as the core functionality. + +> **Disclaimer**: This library is actively under development. We’re currently extracting and refining common types from the[Torrust Tracker](https://github.com/torrust/torrust-tracker) to make them available to the BitTorrent community in Rust. While these types are functional, they are not yet ready for use in production or third-party projects. + +## Documentation + +[Crate documentation](https://docs.rs/bittorrent-udp-tracker-core). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/udp-tracker-core/benches/helpers/mod.rs b/packages/udp-tracker-core/benches/helpers/mod.rs new file mode 100644 index 000000000..ea1959bb4 --- /dev/null +++ b/packages/udp-tracker-core/benches/helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod sync; +mod utils; diff --git a/packages/udp-tracker-core/benches/helpers/sync.rs b/packages/udp-tracker-core/benches/helpers/sync.rs new file mode 100644 index 000000000..e8ec1ce03 --- /dev/null +++ b/packages/udp-tracker-core/benches/helpers/sync.rs @@ -0,0 +1,31 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bittorrent_udp_tracker_core::event::bus::EventBus; +use bittorrent_udp_tracker_core::event::sender::Broadcaster; +use bittorrent_udp_tracker_core::services::connect::ConnectService; +use torrust_tracker_events::bus::SenderStatus; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + +use crate::helpers::utils::{sample_ipv4_remote_addr, sample_issue_time}; + +#[allow(clippy::unused_async)] +pub async fn connect_once(samples: u64) -> Duration { + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + + let udp_core_stats_event_sender = event_bus.sender(); + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + let start = Instant::now(); + + for _ in 0..samples { + let _response = connect_service.handle_connect(client_socket_addr, server_service_binding.clone(), sample_issue_time()); + } + + start.elapsed() +} diff --git a/packages/udp-tracker-core/benches/helpers/utils.rs b/packages/udp-tracker-core/benches/helpers/utils.rs new file mode 100644 index 000000000..1423d4bcd --- /dev/null +++ b/packages/udp-tracker-core/benches/helpers/utils.rs @@ -0,0 +1,27 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use bittorrent_udp_tracker_core::event::Event; +use futures::future::BoxFuture; +use mockall::mock; +use torrust_tracker_events::sender::SendError; + +pub(crate) fn sample_ipv4_remote_addr() -> SocketAddr { + sample_ipv4_socket_address() +} + +pub(crate) fn sample_ipv4_socket_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) +} + +pub(crate) fn sample_issue_time() -> f64 { + 1_000_000_000_f64 +} + +mock! { + pub(crate) UdpCoreStatsEventSender {} + impl torrust_tracker_events::sender::Sender for UdpCoreStatsEventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } +} diff --git a/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs b/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs new file mode 100644 index 000000000..5bd0e27c8 --- /dev/null +++ b/packages/udp-tracker-core/benches/udp_tracker_core_benchmark.rs @@ -0,0 +1,20 @@ +mod helpers; + +use std::time::Duration; + +use criterion::{criterion_group, criterion_main, Criterion}; + +use crate::helpers::sync; + +fn bench_connect_once(c: &mut Criterion) { + let mut group = c.benchmark_group("udp_tracker/connect_once"); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_millis(1000)); + + group.bench_function("connect_once", |b| { + b.iter(|| sync::connect_once(100)); + }); +} + +criterion_group!(benches, bench_connect_once); +criterion_main!(benches); diff --git a/packages/udp-tracker-core/src/connection_cookie.rs b/packages/udp-tracker-core/src/connection_cookie.rs new file mode 100644 index 000000000..ce255705f --- /dev/null +++ b/packages/udp-tracker-core/src/connection_cookie.rs @@ -0,0 +1,334 @@ +//! Module for Generating and Verifying Connection IDs (Cookies) in the UDP Tracker Protocol +//! +//! **Overview:** +//! +//! In the `BitTorrent` UDP tracker protocol, clients initiate communication by obtaining a connection ID from the server. This connection ID serves as a safeguard against IP spoofing and replay attacks, ensuring that only legitimate clients can interact with the tracker. +//! +//! To maintain a stateless server architecture, this module implements a method for generating and verifying connection IDs based on the client's fingerprint (typically derived from the client's IP address) and the time of issuance, without storing state on the server. +//! +//! The connection ID is an encrypted, opaque cookie held by the client. Since the same server that generates the cookie also validates it, endianness is not a concern. +//! +//! **Connection ID Generation Algorithm:** +//! +//! 1. **Issue Time (`issue_at`):** +//! - Obtain a 64-bit floating-point number (`f64`), this number should be a normal number. +//! +//! 2. **Fingerprint:** +//! - Use an 8-byte fingerprint unique to the client (e.g., derived from the client's IP address). +//! +//! 3. **Assemble Cookie Value:** +//! - Interpret the bytes of `issue_at` as a 64-bit integer (`i64`) without altering the bit pattern. +//! - Similarly, interpret the fingerprint bytes as an `i64`. +//! - Compute the cookie value: +//! ```rust,ignore +//! let cookie_value = issue_at_i64.wrapping_add(fingerprint_i64); +//! ``` +//! - *Note:* Wrapping addition handles potential integer overflows gracefully. +//! +//! 4. **Encrypt Cookie Value:** +//! - Encrypt `cookie_value` using a symmetric block cipher obtained from `Current::get_cipher()`. +//! - The encrypted `cookie_value` becomes the connection ID sent to the client. +//! +//! **Connection ID Verification Algorithm:** +//! +//! When a client sends a request with a connection ID, the server verifies it using the following steps: +//! +//! 1. **Decrypt Connection ID:** +//! - Decrypt the received connection ID using the same cipher to retrieve `cookie_value`. +//! - *Important:* The decryption is non-authenticated, meaning it does not verify the integrity or authenticity of the ciphertext. The decrypted `cookie_value` can be any byte sequence, including manipulated data. +//! +//! 2. **Recover Issue Time:** +//! - Interpret the fingerprint bytes as `i64`. +//! - Compute the issue time: +//! ```rust,ignore +//! let issue_at_i64 = cookie_value.wrapping_sub(fingerprint_i64); +//! ``` +//! - *Note:* Wrapping subtraction handles potential integer underflows gracefully. +//! - Reinterpret `issue_at_i64` bytes as an `f64` to get `issue_time`. +//! +//! 3. **Validate Issue Time:** +//! - **Handling Arbitrary `issue_time` Values:** +//! - Since the decrypted `cookie_value` may be arbitrary, `issue_time` can be any `f64` value, including special values like `NaN`, positive or negative infinity, and subnormal numbers. +//! - **Validation Steps:** +//! - **Step 1:** Check if `issue_time` is finite using `issue_time.is_finite()`. +//! - If `issue_time` is `NaN` or infinite, it is considered invalid. +//! - **Step 2:** If `issue_time` is finite, perform range checks: +//! - Verify that `min <= issue_time <= max`. +//! - If `issue_time` passes these checks, accept the connection ID; otherwise, reject it with an appropriate error. +//! +//! **Security Considerations:** +//! +//! - **Non-Authenticated Encryption:** +//! - Due to protocol constraints (an 8-byte connection ID), using an authenticated encryption algorithm is not feasible. +//! - As a result, attackers might attempt to forge or manipulate connection IDs. +//! - However, the probability of an arbitrary 64-bit value decrypting to a valid `issue_time` within the acceptable range is extremely low, effectively serving as a form of authentication. +//! +//! - **Handling Special `f64` Values:** +//! - By checking `issue_time.is_finite()`, the implementation excludes `NaN` and infinite values, ensuring that only valid, finite timestamps are considered. +//! +//! - **Probability of Successful Attack:** +//! - Given the narrow valid time window (usually around 2 minutes) compared to the vast range of `f64` values, the chance of successfully guessing a valid `issue_time` is negligible. +//! +//! **Key Points:** +//! +//! - The server maintains a stateless design, reducing resource consumption and complexity. +//! - Wrapping arithmetic ensures that the addition and subtraction of `i64` values are safe from overflow or underflow issues. +//! - The validation process is robust against malformed or malicious connection IDs due to stringent checks on the deserialized `issue_time`. +//! - The module leverages existing cryptographic primitives while acknowledging and addressing the limitations imposed by the protocol's specifications. +//! + +use aquatic_udp_protocol::ConnectionId as Cookie; +use cookie_builder::{assemble, decode, disassemble, encode}; +use thiserror::Error; +use tracing::instrument; +use zerocopy::AsBytes; + +use crate::crypto::keys::CipherArrayBlowfish; + +/// Error returned when there was an error with the connection cookie. +#[derive(Error, Debug, Clone, PartialEq)] +pub enum ConnectionCookieError { + #[error("cookie value is not normal: {not_normal_value}")] + ValueNotNormal { not_normal_value: f64 }, + + #[error("cookie value is expired: {expired_value}, expected > {min_value}")] + ValueExpired { expired_value: f64, min_value: f64 }, + + #[error("cookie value is from future: {future_value}, expected < {max_value}")] + ValueFromFuture { future_value: f64, max_value: f64 }, +} + +/// Generates a new connection cookie. +/// +/// # Errors +/// +/// It would error if the supplied `issue_at` value is a zero, infinite, subnormal, or NaN. +/// +/// # Panics +/// +/// It would panic if the cookie is not exactly 8 bytes is size. +/// +#[instrument(err)] +pub fn make(fingerprint: u64, issue_at: f64) -> Result { + if !issue_at.is_normal() { + return Err(ConnectionCookieError::ValueNotNormal { + not_normal_value: issue_at, + }); + } + + let cookie = assemble(fingerprint, issue_at); + let cookie = encode(cookie); + + // using `read_from` as the array may be not correctly aligned + Ok(zerocopy::FromBytes::read_from(cookie.as_slice()).expect("it should be the same size")) +} + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::net::SocketAddr; +use std::ops::Range; + +/// Checks if the supplied `connection_cookie` is valid. +/// +/// # Errors +/// +/// It would error if the connection cookie is somehow invalid or expired. +/// +/// # Panics +/// +/// It would panic if the range start is not smaller than it's end. +#[instrument] +pub fn check(cookie: &Cookie, fingerprint: u64, valid_range: Range) -> Result { + assert!(valid_range.start <= valid_range.end, "range start is larger than range end"); + + let cookie_bytes = CipherArrayBlowfish::from_slice(cookie.0.as_bytes()); + let cookie_bytes = decode(*cookie_bytes); + + let issue_time = disassemble(fingerprint, cookie_bytes); + + if !issue_time.is_normal() { + return Err(ConnectionCookieError::ValueNotNormal { + not_normal_value: issue_time, + }); + } + + if issue_time < valid_range.start { + return Err(ConnectionCookieError::ValueExpired { + expired_value: issue_time, + min_value: valid_range.start, + }); + } + + if issue_time > valid_range.end { + return Err(ConnectionCookieError::ValueFromFuture { + future_value: issue_time, + max_value: valid_range.end, + }); + } + + Ok(issue_time) +} + +#[must_use] +pub fn gen_remote_fingerprint(remote_addr: &SocketAddr) -> u64 { + let mut state = DefaultHasher::new(); + remote_addr.hash(&mut state); + state.finish() +} + +mod cookie_builder { + use cipher::{BlockDecrypt, BlockEncrypt}; + use tracing::instrument; + use zerocopy::{byteorder, AsBytes as _, NativeEndian}; + + pub type CookiePlainText = CipherArrayBlowfish; + pub type CookieCipherText = CipherArrayBlowfish; + + use crate::crypto::keys::{CipherArrayBlowfish, Current, Keeper}; + + #[instrument()] + pub(super) fn assemble(fingerprint: u64, issue_at: f64) -> CookiePlainText { + let issue_at: byteorder::I64 = + *zerocopy::FromBytes::ref_from(&issue_at.to_ne_bytes()).expect("it should be aligned"); + let fingerprint: byteorder::I64 = + *zerocopy::FromBytes::ref_from(&fingerprint.to_ne_bytes()).expect("it should be aligned"); + + let cookie = issue_at.get().wrapping_add(fingerprint.get()); + let cookie: byteorder::I64 = + *zerocopy::FromBytes::ref_from(&cookie.to_ne_bytes()).expect("it should be aligned"); + + *CipherArrayBlowfish::from_slice(cookie.as_bytes()) + } + + #[instrument()] + pub(super) fn disassemble(fingerprint: u64, cookie: CookiePlainText) -> f64 { + let fingerprint: byteorder::I64 = + *zerocopy::FromBytes::ref_from(&fingerprint.to_ne_bytes()).expect("it should be aligned"); + + // the array may be not aligned, so we read instead of reference. + let cookie: byteorder::I64 = + zerocopy::FromBytes::read_from(cookie.as_bytes()).expect("it should be the same size"); + + let issue_time_bytes = cookie.get().wrapping_sub(fingerprint.get()).to_ne_bytes(); + + let issue_time: byteorder::F64 = + *zerocopy::FromBytes::ref_from(&issue_time_bytes).expect("it should be aligned"); + + issue_time.get() + } + + #[instrument()] + pub(super) fn encode(mut cookie: CookiePlainText) -> CookieCipherText { + let cipher = Current::get_cipher_blowfish(); + + cipher.encrypt_block(&mut cookie); + + cookie + } + + #[instrument()] + pub(super) fn decode(mut cookie: CookieCipherText) -> CookiePlainText { + let cipher = Current::get_cipher_blowfish(); + + cipher.decrypt_block(&mut cookie); + + cookie + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn it_should_make_a_connection_cookie() { + let fingerprint = 1_000_000; + let issue_at = 1000.0; + let cookie = make(fingerprint, issue_at).unwrap().0.get(); + + // Expected connection ID derived through experimentation + assert_eq!(cookie.to_le_bytes(), [10, 130, 175, 211, 244, 253, 230, 210]); + } + + #[test] + fn it_should_create_same_cookie_for_same_input() { + let fingerprint = 1_000_000; + let issue_at = 1000.0; + let cookie1 = make(fingerprint, issue_at).unwrap(); + let cookie2 = make(fingerprint, issue_at).unwrap(); + + assert_eq!(cookie1, cookie2); + } + + #[test] + fn it_should_create_different_cookies_for_different_fingerprints() { + let fingerprint1 = 1_000_000; + let fingerprint2 = 2_000_000; + let issue_at = 1000.0; + let cookie1 = make(fingerprint1, issue_at).unwrap(); + let cookie2 = make(fingerprint2, issue_at).unwrap(); + + assert_ne!(cookie1, cookie2); + } + + #[test] + fn it_should_create_different_cookies_for_different_issue_times() { + let fingerprint = 1_000_000; + let issue_at1 = 1000.0; + let issue_at2 = 2000.0; + let cookie1 = make(fingerprint, issue_at1).unwrap(); + let cookie2 = make(fingerprint, issue_at2).unwrap(); + + assert_ne!(cookie1, cookie2); + } + + #[test] + fn it_should_validate_a_valid_cookie() { + let fingerprint = 1_000_000; + let issue_at = 1_000_000_000_f64; + let cookie = make(fingerprint, issue_at).unwrap(); + + let min = issue_at - 10.0; + let max = issue_at + 10.0; + + let result = check(&cookie, fingerprint, min..max).unwrap(); + + // we should have exactly the same bytes returned + assert_eq!(result.to_ne_bytes(), issue_at.to_ne_bytes()); + } + + #[test] + fn it_should_reject_an_expired_cookie() { + let fingerprint = 1_000_000; + let issue_at = 1_000_000_000_f64; + let cookie = make(fingerprint, issue_at).unwrap(); + + let min = issue_at + 10.0; + let max = issue_at + 20.0; + + let result = check(&cookie, fingerprint, min..max).unwrap_err(); + + match result { + ConnectionCookieError::ValueExpired { .. } => {} // Expected error + _ => panic!("Expected ConnectionIdExpired error"), + } + } + + #[test] + fn it_should_reject_a_cookie_from_the_future() { + let fingerprint = 1_000_000; + let issue_at = 1_000_000_000_f64; + + let cookie = make(fingerprint, issue_at).unwrap(); + + let min = issue_at - 20.0; + let max = issue_at - 10.0; + + let result = check(&cookie, fingerprint, min..max).unwrap_err(); + + match result { + ConnectionCookieError::ValueFromFuture { .. } => {} // Expected error + _ => panic!("Expected ConnectionIdFromFuture error"), + } + } +} diff --git a/packages/udp-tracker-core/src/container.rs b/packages/udp-tracker-core/src/container.rs new file mode 100644 index 000000000..1d8b1d71c --- /dev/null +++ b/packages/udp-tracker-core/src/container.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use bittorrent_tracker_core::container::TrackerCoreContainer; +use tokio::sync::RwLock; +use torrust_tracker_configuration::{Core, UdpTracker}; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +use crate::event::bus::EventBus; +use crate::event::sender::Broadcaster; +use crate::services::announce::AnnounceService; +use crate::services::banning::BanService; +use crate::services::connect::ConnectService; +use crate::services::scrape::ScrapeService; +use crate::statistics::repository::Repository; +use crate::{event, services, statistics, MAX_CONNECTION_ID_ERRORS_PER_IP}; + +pub struct UdpTrackerCoreContainer { + pub udp_tracker_config: Arc, + + pub tracker_core_container: Arc, + + // `UdpTrackerCoreServices` + pub event_bus: Arc, + pub stats_event_sender: crate::event::sender::Sender, + pub stats_repository: Arc, + pub ban_service: Arc>, + pub connect_service: Arc, + pub announce_service: Arc, + pub scrape_service: Arc, +} + +impl UdpTrackerCoreContainer { + #[must_use] + pub fn initialize(core_config: &Arc, udp_tracker_config: &Arc) -> Arc { + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + core_config, + &swarm_coordination_registry_container, + )); + + Self::initialize_from_tracker_core(&tracker_core_container, udp_tracker_config) + } + + #[must_use] + pub fn initialize_from_tracker_core( + tracker_core_container: &Arc, + udp_tracker_config: &Arc, + ) -> Arc { + let udp_tracker_core_services = UdpTrackerCoreServices::initialize_from(tracker_core_container); + + Self::initialize_from_services(tracker_core_container, &udp_tracker_core_services, udp_tracker_config) + } + + #[must_use] + pub fn initialize_from_services( + tracker_core_container: &Arc, + udp_tracker_core_services: &Arc, + udp_tracker_config: &Arc, + ) -> Arc { + Arc::new(Self { + udp_tracker_config: udp_tracker_config.clone(), + + tracker_core_container: tracker_core_container.clone(), + + // `UdpTrackerCoreServices` + event_bus: udp_tracker_core_services.event_bus.clone(), + stats_event_sender: udp_tracker_core_services.stats_event_sender.clone(), + stats_repository: udp_tracker_core_services.stats_repository.clone(), + ban_service: udp_tracker_core_services.ban_service.clone(), + connect_service: udp_tracker_core_services.connect_service.clone(), + announce_service: udp_tracker_core_services.announce_service.clone(), + scrape_service: udp_tracker_core_services.scrape_service.clone(), + }) + } +} + +pub struct UdpTrackerCoreServices { + pub event_bus: Arc, + pub stats_event_sender: crate::event::sender::Sender, + pub stats_repository: Arc, + pub ban_service: Arc>, + pub connect_service: Arc, + pub announce_service: Arc, + pub scrape_service: Arc, +} + +impl UdpTrackerCoreServices { + #[must_use] + pub fn initialize_from(tracker_core_container: &Arc) -> Arc { + let udp_core_broadcaster = Broadcaster::default(); + let udp_core_stats_repository = Arc::new(Repository::new()); + let event_bus = Arc::new(EventBus::new( + tracker_core_container.core_config.tracker_usage_statistics.into(), + udp_core_broadcaster.clone(), + )); + + let udp_core_stats_event_sender = event_bus.sender(); + let ban_service = Arc::new(RwLock::new(BanService::new(MAX_CONNECTION_ID_ERRORS_PER_IP))); + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender.clone())); + let announce_service = Arc::new(AnnounceService::new( + tracker_core_container.announce_handler.clone(), + tracker_core_container.whitelist_authorization.clone(), + udp_core_stats_event_sender.clone(), + )); + let scrape_service = Arc::new(ScrapeService::new( + tracker_core_container.scrape_handler.clone(), + udp_core_stats_event_sender.clone(), + )); + + Arc::new(Self { + event_bus, + stats_event_sender: udp_core_stats_event_sender, + stats_repository: udp_core_stats_repository, + ban_service, + connect_service, + announce_service, + scrape_service, + }) + } +} diff --git a/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs b/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs new file mode 100644 index 000000000..de40e4b1d --- /dev/null +++ b/packages/udp-tracker-core/src/crypto/ephemeral_instance_keys.rs @@ -0,0 +1,32 @@ +//! This module contains the ephemeral instance keys used by the application. +//! +//! They are ephemeral because they are generated at runtime when the +//! application starts and are not persisted anywhere. + +use blowfish::BlowfishLE; +use cipher::{BlockSizeUser, KeyInit}; +use generic_array::GenericArray; +use rand::rngs::ThreadRng; +use rand::RngExt; + +pub type Seed = [u8; 32]; +pub type CipherBlowfish = BlowfishLE; +pub type CipherArrayBlowfish = GenericArray::BlockSize>; + +lazy_static! { + /// The random static seed. + pub static ref RANDOM_SEED: Seed = { + let mut rng = ThreadRng::default(); + rng.random::() + }; + + /// The random cipher from the seed. + pub static ref RANDOM_CIPHER_BLOWFISH: CipherBlowfish = { + let mut rng = ThreadRng::default(); + let seed: Seed = rng.random(); + CipherBlowfish::new_from_slice(&seed).expect("it could not generate key") + }; + + /// The constant cipher for testing. + pub static ref ZEROED_TEST_CIPHER_BLOWFISH: CipherBlowfish = CipherBlowfish::new_from_slice(&[0u8; 32]).expect("it could not generate key"); +} diff --git a/packages/udp-tracker-core/src/crypto/keys.rs b/packages/udp-tracker-core/src/crypto/keys.rs new file mode 100644 index 000000000..bb813b9dc --- /dev/null +++ b/packages/udp-tracker-core/src/crypto/keys.rs @@ -0,0 +1,156 @@ +//! This module contains logic related to cryptographic keys. +//! +//! Specifically, it contains the logic for storing the seed and providing +//! it to other modules. +//! +//! It also provides the logic for the cipher for encryption and decryption. + +use cipher::{BlockDecrypt, BlockEncrypt}; + +use self::detail_cipher::CURRENT_CIPHER; +use self::detail_seed::CURRENT_SEED; +pub use crate::crypto::ephemeral_instance_keys::CipherArrayBlowfish; +use crate::crypto::ephemeral_instance_keys::{CipherBlowfish, Seed, RANDOM_CIPHER_BLOWFISH, RANDOM_SEED}; + +/// This trait is for structures that can keep and provide a seed. +pub trait Keeper { + type Seed: Sized + Default + AsMut<[u8]>; + type Cipher: BlockEncrypt + BlockDecrypt; + + /// It returns a reference to the seed that is keeping. + fn get_seed() -> &'static Self::Seed; + fn get_cipher_blowfish() -> &'static Self::Cipher; +} + +/// The keeper for the instance. When the application is running +/// in production, this will be the seed keeper that is used. +pub struct Instance; + +/// The keeper for the current execution. It's a facade at compilation +/// time that will either be the instance seed keeper (with a randomly +/// generated key for production) or the zeroed seed keeper. +pub struct Current; + +impl Keeper for Instance { + type Seed = Seed; + type Cipher = CipherBlowfish; + + fn get_seed() -> &'static Self::Seed { + &RANDOM_SEED + } + + fn get_cipher_blowfish() -> &'static Self::Cipher { + &RANDOM_CIPHER_BLOWFISH + } +} + +impl Keeper for Current { + type Seed = Seed; + type Cipher = CipherBlowfish; + + #[allow(clippy::needless_borrow)] + fn get_seed() -> &'static Self::Seed { + &CURRENT_SEED + } + + fn get_cipher_blowfish() -> &'static Self::Cipher { + &CURRENT_CIPHER + } +} + +#[cfg(test)] +mod tests { + + use super::detail_seed::ZEROED_TEST_SEED; + use super::{Current, Instance, Keeper}; + use crate::crypto::ephemeral_instance_keys::{CipherBlowfish, Seed, ZEROED_TEST_CIPHER_BLOWFISH}; + + pub struct ZeroedTest; + + impl Keeper for ZeroedTest { + type Seed = Seed; + type Cipher = CipherBlowfish; + + #[allow(clippy::needless_borrow)] + fn get_seed() -> &'static Self::Seed { + &ZEROED_TEST_SEED + } + + fn get_cipher_blowfish() -> &'static Self::Cipher { + &ZEROED_TEST_CIPHER_BLOWFISH + } + } + + #[test] + fn the_default_seed_and_the_zeroed_seed_should_be_the_same_when_testing() { + assert_eq!(Current::get_seed(), ZeroedTest::get_seed()); + } + + #[test] + fn the_default_seed_and_the_instance_seed_should_be_different_when_testing() { + assert_ne!(Current::get_seed(), Instance::get_seed()); + } +} + +mod detail_seed { + use crate::crypto::ephemeral_instance_keys::Seed; + + #[allow(dead_code)] + pub const ZEROED_TEST_SEED: Seed = [0u8; 32]; + + #[cfg(test)] + pub use ZEROED_TEST_SEED as CURRENT_SEED; + + #[cfg(not(test))] + pub use crate::crypto::ephemeral_instance_keys::RANDOM_SEED as CURRENT_SEED; + + #[cfg(test)] + mod tests { + use crate::crypto::ephemeral_instance_keys::RANDOM_SEED; + use crate::crypto::keys::detail_seed::ZEROED_TEST_SEED; + use crate::crypto::keys::CURRENT_SEED; + + #[test] + fn it_should_have_a_zero_test_seed() { + assert_eq!(ZEROED_TEST_SEED, [0u8; 32]); + } + + #[test] + fn it_should_default_to_zeroed_seed_when_testing() { + assert_eq!(CURRENT_SEED, ZEROED_TEST_SEED); + } + + #[test] + fn it_should_have_a_large_random_seed() { + assert!(u128::from_ne_bytes((*RANDOM_SEED)[..16].try_into().unwrap()) > u128::from(u64::MAX)); + assert!(u128::from_ne_bytes((*RANDOM_SEED)[16..].try_into().unwrap()) > u128::from(u64::MAX)); + } + } +} + +mod detail_cipher { + #[allow(unused_imports)] + #[cfg(not(test))] + pub use crate::crypto::ephemeral_instance_keys::RANDOM_CIPHER_BLOWFISH as CURRENT_CIPHER; + #[cfg(test)] + pub use crate::crypto::ephemeral_instance_keys::ZEROED_TEST_CIPHER_BLOWFISH as CURRENT_CIPHER; + + #[cfg(test)] + mod tests { + use cipher::BlockEncrypt; + + use crate::crypto::ephemeral_instance_keys::{CipherArrayBlowfish, ZEROED_TEST_CIPHER_BLOWFISH}; + use crate::crypto::keys::detail_cipher::CURRENT_CIPHER; + + #[test] + fn it_should_default_to_zeroed_seed_when_testing() { + let mut data: cipher::generic_array::GenericArray = CipherArrayBlowfish::from([0u8; 8]); + let mut data_2 = CipherArrayBlowfish::from([0u8; 8]); + + CURRENT_CIPHER.encrypt_block(&mut data); + ZEROED_TEST_CIPHER_BLOWFISH.encrypt_block(&mut data_2); + + assert_eq!(data, data_2); + } + } +} diff --git a/src/shared/crypto/mod.rs b/packages/udp-tracker-core/src/crypto/mod.rs similarity index 100% rename from src/shared/crypto/mod.rs rename to packages/udp-tracker-core/src/crypto/mod.rs diff --git a/packages/udp-tracker-core/src/event.rs b/packages/udp-tracker-core/src/event.rs new file mode 100644 index 000000000..761b809d8 --- /dev/null +++ b/packages/udp-tracker-core/src/event.rs @@ -0,0 +1,97 @@ +use std::net::SocketAddr; + +use bittorrent_primitives::info_hash::InfoHash; +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::label_name; +use torrust_tracker_primitives::peer::PeerAnnouncement; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +/// A UDP core event. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum Event { + UdpConnect { + connection: ConnectionContext, + }, + UdpAnnounce { + connection: ConnectionContext, + info_hash: InfoHash, + announcement: PeerAnnouncement, + }, + UdpScrape { + connection: ConnectionContext, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ConnectionContext { + pub client_socket_addr: SocketAddr, + pub server_service_binding: ServiceBinding, +} + +impl ConnectionContext { + #[must_use] + pub fn new(client_socket_addr: SocketAddr, server_service_binding: ServiceBinding) -> Self { + Self { + client_socket_addr, + server_service_binding, + } + } + + #[must_use] + pub fn client_socket_addr(&self) -> SocketAddr { + self.client_socket_addr + } + + #[must_use] + pub fn server_socket_addr(&self) -> SocketAddr { + self.server_service_binding.bind_address() + } +} + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + label_name!("server_binding_protocol"), + LabelValue::new(&connection_context.server_service_binding.protocol().to_string()), + ), + ( + label_name!("server_binding_ip"), + LabelValue::new(&connection_context.server_service_binding.bind_address().ip().to_string()), + ), + ( + label_name!("server_binding_address_ip_type"), + LabelValue::new(&connection_context.server_service_binding.bind_address_ip_type().to_string()), + ), + ( + label_name!("server_binding_address_ip_family"), + LabelValue::new(&connection_context.server_service_binding.bind_address_ip_family().to_string()), + ), + ( + label_name!("server_binding_port"), + LabelValue::new(&connection_context.server_service_binding.bind_address().port().to_string()), + ), + ]) + } +} + +pub mod sender { + use std::sync::Arc; + + use super::Event; + + pub type Sender = Option>>; + pub type Broadcaster = torrust_tracker_events::broadcaster::Broadcaster; +} + +pub mod receiver { + use super::Event; + + pub type Receiver = Box>; +} + +pub mod bus { + use crate::event::Event; + + pub type EventBus = torrust_tracker_events::bus::EventBus; +} diff --git a/packages/udp-tracker-core/src/lib.rs b/packages/udp-tracker-core/src/lib.rs new file mode 100644 index 000000000..2c1943853 --- /dev/null +++ b/packages/udp-tracker-core/src/lib.rs @@ -0,0 +1,59 @@ +pub mod connection_cookie; +pub mod container; +pub mod crypto; +pub mod event; +pub mod services; +pub mod statistics; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + +use crypto::ephemeral_instance_keys; +use tracing::instrument; + +#[macro_use] +extern crate lazy_static; + +/// The maximum number of connection id errors per ip. Clients will be banned if +/// they exceed this limit. +pub const MAX_CONNECTION_ID_ERRORS_PER_IP: u32 = 10; + +pub const UDP_TRACKER_LOG_TARGET: &str = "UDP TRACKER"; + +/// It initializes the static values. +#[instrument(skip())] +pub fn initialize_static() { + // Initialize the Ephemeral Instance Random Seed + lazy_static::initialize(&ephemeral_instance_keys::RANDOM_SEED); + + // Initialize the Ephemeral Instance Random Cipher + lazy_static::initialize(&ephemeral_instance_keys::RANDOM_CIPHER_BLOWFISH); + + // Initialize the Zeroed Cipher + lazy_static::initialize(&ephemeral_instance_keys::ZEROED_TEST_CIPHER_BLOWFISH); +} + +#[cfg(test)] +pub(crate) mod tests { + use bittorrent_primitives::info_hash::InfoHash; + + /// # Panics + /// + /// Will panic if the string representation of the info hash is not a valid info hash. + #[must_use] + pub fn sample_info_hash() -> InfoHash { + "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" // DevSkim: ignore DS173237 + .parse::() + .expect("String should be a valid info hash") + } +} diff --git a/packages/udp-tracker-core/src/services/announce.rs b/packages/udp-tracker-core/src/services/announce.rs new file mode 100644 index 000000000..a69e91d8a --- /dev/null +++ b/packages/udp-tracker-core/src/services/announce.rs @@ -0,0 +1,161 @@ +//! The `announce` service. +//! +//! The service is responsible for handling the `announce` requests. +//! +//! It delegates the `announce` logic to the [`AnnounceHandler`] and it returns +//! the [`AnnounceData`]. +//! +//! It also sends an [`udp_tracker_core::statistics::event::Event`] +//! because events are specific for the HTTP tracker. +use std::net::SocketAddr; +use std::ops::Range; +use std::sync::Arc; + +use aquatic_udp_protocol::AnnounceRequest; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::announce_handler::{AnnounceHandler, PeersWanted}; +use bittorrent_tracker_core::error::{AnnounceError, WhitelistError}; +use bittorrent_tracker_core::whitelist; +use bittorrent_udp_tracker_protocol::peer_builder; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::peer::PeerAnnouncement; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::connection_cookie::{check, gen_remote_fingerprint, ConnectionCookieError}; +use crate::event::{ConnectionContext, Event}; + +/// The `AnnounceService` is responsible for handling the `announce` requests. +/// +/// The service sends an statistics event that increments: +/// +/// - The number of UDP `announce` requests handled by the UDP tracker. +pub struct AnnounceService { + announce_handler: Arc, + whitelist_authorization: Arc, + opt_udp_core_stats_event_sender: crate::event::sender::Sender, +} + +impl AnnounceService { + #[must_use] + pub fn new( + announce_handler: Arc, + whitelist_authorization: Arc, + opt_udp_core_stats_event_sender: crate::event::sender::Sender, + ) -> Self { + Self { + announce_handler, + whitelist_authorization, + opt_udp_core_stats_event_sender, + } + } + + /// It handles the `Announce` request. + /// + /// # Errors + /// + /// It will return an error if: + /// + /// - The tracker is running in listed mode and the torrent is not in the + /// whitelist. + pub async fn handle_announce( + &self, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request: &AnnounceRequest, + cookie_valid_range: Range, + ) -> Result { + Self::authenticate(client_socket_addr, request, cookie_valid_range)?; + + let info_hash = request.info_hash.into(); + + self.authorize(&info_hash).await?; + + let remote_client_ip = client_socket_addr.ip(); + + let mut peer = peer_builder::from_request(request, &remote_client_ip); + + let peers_wanted: PeersWanted = i32::from(request.peers_wanted.0).into(); + + let announce_data = self + .announce_handler + .handle_announcement(&info_hash, &mut peer, &remote_client_ip, &peers_wanted) + .await?; + + self.send_event(info_hash, peer, client_socket_addr, server_service_binding) + .await; + + Ok(announce_data) + } + + fn authenticate( + remote_addr: SocketAddr, + request: &AnnounceRequest, + cookie_valid_range: Range, + ) -> Result { + check( + &request.connection_id, + gen_remote_fingerprint(&remote_addr), + cookie_valid_range, + ) + } + + async fn authorize(&self, info_hash: &InfoHash) -> Result<(), WhitelistError> { + self.whitelist_authorization.authorize(info_hash).await + } + + async fn send_event( + &self, + info_hash: InfoHash, + announcement: PeerAnnouncement, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + ) { + if let Some(udp_stats_event_sender) = self.opt_udp_core_stats_event_sender.as_deref() { + let event = Event::UdpAnnounce { + connection: ConnectionContext::new(client_socket_addr, server_service_binding), + info_hash, + announcement, + }; + + tracing::debug!(target = crate::UDP_TRACKER_LOG_TARGET, "Sending UdpAnnounce event: {event:?}"); + + udp_stats_event_sender.send(event).await; + } + } +} + +/// Errors related to announce requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum UdpAnnounceError { + /// Error returned when there was an error with the connection cookie. + #[error("Connection cookie error: {source}")] + ConnectionCookieError { source: ConnectionCookieError }, + + /// Error returned when there was an error with the tracker core announce handler. + #[error("Tracker core announce error: {source}")] + TrackerCoreAnnounceError { source: AnnounceError }, + + /// Error returned when there was an error with the tracker core whitelist. + #[error("Tracker core whitelist error: {source}")] + TrackerCoreWhitelistError { source: WhitelistError }, +} + +impl From for UdpAnnounceError { + fn from(connection_cookie_error: ConnectionCookieError) -> Self { + Self::ConnectionCookieError { + source: connection_cookie_error, + } + } +} + +impl From for UdpAnnounceError { + fn from(announce_error: AnnounceError) -> Self { + Self::TrackerCoreAnnounceError { source: announce_error } + } +} + +impl From for UdpAnnounceError { + fn from(whitelist_error: WhitelistError) -> Self { + Self::TrackerCoreWhitelistError { source: whitelist_error } + } +} diff --git a/packages/udp-tracker-core/src/services/banning.rs b/packages/udp-tracker-core/src/services/banning.rs new file mode 100644 index 000000000..8f63dd804 --- /dev/null +++ b/packages/udp-tracker-core/src/services/banning.rs @@ -0,0 +1,150 @@ +//! Banning service for UDP tracker. +//! +//! It bans clients that send invalid connection id's. +//! +//! It uses two levels of filtering: +//! +//! 1. First, tt uses a Counting Bloom Filter to keep track of the number of +//! connection ID errors per ip. That means there can be false positives, but +//! not false negatives. 1 out of 100000 requests will be a false positive +//! and the client will be banned and not receive a response. +//! 2. Since we want to avoid false positives (banning a client that is not +//! sending invalid connection id's), we use a `HashMap` to keep track of the +//! exact number of connection ID errors per ip. +//! +//! This two level filtering is to avoid false positives. It has the advantage +//! of being fast by using a Counting Bloom Filter and not having false +//! negatives at the cost of increasing the memory usage. +use std::collections::HashMap; +use std::net::IpAddr; + +use bloom::{CountingBloomFilter, ASMS}; +use tokio::time::Instant; + +use crate::UDP_TRACKER_LOG_TARGET; + +pub struct BanService { + max_connection_id_errors_per_ip: u32, + fuzzy_error_counter: CountingBloomFilter, + accurate_error_counter: HashMap, + last_connection_id_errors_reset: Instant, +} + +impl BanService { + #[must_use] + pub fn new(max_connection_id_errors_per_ip: u32) -> Self { + Self { + max_connection_id_errors_per_ip, + fuzzy_error_counter: CountingBloomFilter::with_rate(4, 0.01, 100), + accurate_error_counter: HashMap::new(), + last_connection_id_errors_reset: tokio::time::Instant::now(), + } + } + + pub fn increase_counter(&mut self, ip: &IpAddr) { + self.fuzzy_error_counter.insert(&ip.to_string()); + *self.accurate_error_counter.entry(*ip).or_insert(0) += 1; + } + + #[must_use] + pub fn get_count(&self, ip: &IpAddr) -> Option { + self.accurate_error_counter.get(ip).copied() + } + + #[must_use] + pub fn get_banned_ips_total(&self) -> usize { + self.accurate_error_counter.len() + } + + #[must_use] + pub fn get_estimate_count(&self, ip: &IpAddr) -> u32 { + self.fuzzy_error_counter.estimate_count(&ip.to_string()) + } + + /// Returns true if the given ip address is banned. + #[must_use] + pub fn is_banned(&self, ip: &IpAddr) -> bool { + // First check if the ip is in the bloom filter (fast check) + if self.fuzzy_error_counter.estimate_count(&ip.to_string()) <= self.max_connection_id_errors_per_ip { + return false; + } + + // Check with the exact counter (to avoid false positives) + match self.get_count(ip) { + Some(count) => count > self.max_connection_id_errors_per_ip, + None => false, + } + } + + /// Resets the filters and updates the reset timestamp. + pub fn reset_bans(&mut self) { + self.fuzzy_error_counter.clear(); + + self.accurate_error_counter.clear(); + + self.last_connection_id_errors_reset = Instant::now(); + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Udp::run_udp_server::loop (connection id errors filter cleared)"); + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::BanService; + + /// Sample service with one day ban duration. + fn ban_service(counter_limit: u32) -> BanService { + BanService::new(counter_limit) + } + + #[test] + fn it_should_increase_the_errors_counter_for_a_given_ip() { + let mut ban_service = ban_service(1); + + let ip: IpAddr = "127.0.0.2".parse().unwrap(); + + ban_service.increase_counter(&ip); + + assert_eq!(ban_service.get_count(&ip), Some(1)); + } + + #[test] + fn it_should_ban_ips_with_counters_exceeding_a_predefined_limit() { + let mut ban_service = ban_service(1); + + let ip: IpAddr = "127.0.0.2".parse().unwrap(); + + ban_service.increase_counter(&ip); // Counter = 1 + ban_service.increase_counter(&ip); // Counter = 2 + + println!("Counter: {}", ban_service.get_count(&ip).unwrap()); + + assert!(ban_service.is_banned(&ip)); + } + + #[test] + fn it_should_not_ban_ips_whose_counters_do_not_exceed_the_predefined_limit() { + let mut ban_service = ban_service(1); + + let ip: IpAddr = "127.0.0.2".parse().unwrap(); + + ban_service.increase_counter(&ip); + + assert!(!ban_service.is_banned(&ip)); + } + + #[test] + fn it_should_allow_resetting_all_the_counters() { + let mut ban_service = ban_service(1); + + let ip: IpAddr = "127.0.0.2".parse().unwrap(); + + ban_service.increase_counter(&ip); // Counter = 1 + + ban_service.reset_bans(); + + assert_eq!(ban_service.get_estimate_count(&ip), 0); + } +} diff --git a/packages/udp-tracker-core/src/services/connect.rs b/packages/udp-tracker-core/src/services/connect.rs new file mode 100644 index 000000000..6ba36f274 --- /dev/null +++ b/packages/udp-tracker-core/src/services/connect.rs @@ -0,0 +1,187 @@ +//! The `connect` service. +//! +//! The service is responsible for handling the `connect` requests. +use std::net::SocketAddr; + +use aquatic_udp_protocol::ConnectionId; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::connection_cookie::{gen_remote_fingerprint, make}; +use crate::event::{ConnectionContext, Event}; + +/// The `ConnectService` is responsible for handling the `connect` requests. +/// +/// It is responsible for generating the connection cookie and sending the +/// appropriate statistics events. +pub struct ConnectService { + pub opt_udp_core_stats_event_sender: crate::event::sender::Sender, +} + +impl ConnectService { + #[must_use] + pub fn new(opt_udp_core_stats_event_sender: crate::event::sender::Sender) -> Self { + Self { + opt_udp_core_stats_event_sender, + } + } + + /// Handles a `connect` request. + /// + /// # Panics + /// + /// It will panic if there was an error making the connection cookie. + pub async fn handle_connect( + &self, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + cookie_issue_time: f64, + ) -> ConnectionId { + let connection_id = + make(gen_remote_fingerprint(&client_socket_addr), cookie_issue_time).expect("it should be a normal value"); + + if let Some(udp_stats_event_sender) = self.opt_udp_core_stats_event_sender.as_deref() { + udp_stats_event_sender + .send(Event::UdpConnect { + connection: ConnectionContext::new(client_socket_addr, server_service_binding), + }) + .await; + } + + connection_id + } +} + +#[cfg(test)] +mod tests { + + mod connect_request { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use mockall::predicate::eq; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::connection_cookie::make; + use crate::event::bus::EventBus; + use crate::event::sender::Broadcaster; + use crate::event::{ConnectionContext, Event}; + use crate::services::connect::ConnectService; + use crate::services::tests::{ + sample_ipv4_remote_addr, sample_ipv4_remote_addr_fingerprint, sample_ipv4_socket_address, sample_ipv6_remote_addr, + sample_ipv6_remote_addr_fingerprint, sample_issue_time, MockUdpCoreStatsEventSender, + }; + + #[tokio::test] + async fn a_connect_response_should_contain_the_same_transaction_id_as_the_connect_request() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = event_bus.sender(); + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = connect_service + .handle_connect(sample_ipv4_remote_addr(), server_service_binding, sample_issue_time()) + .await; + + assert_eq!( + response, + make(sample_ipv4_remote_addr_fingerprint(), sample_issue_time()).unwrap() + ); + } + + #[tokio::test] + async fn a_connect_response_should_contain_a_new_connection_id() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = event_bus.sender(); + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = connect_service + .handle_connect(sample_ipv4_remote_addr(), server_service_binding, sample_issue_time()) + .await; + + assert_eq!( + response, + make(sample_ipv4_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + ); + } + + #[tokio::test] + async fn a_connect_response_should_contain_a_new_connection_id_ipv6() { + let client_socket_addr = sample_ipv6_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = event_bus.sender(); + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = connect_service + .handle_connect(client_socket_addr, server_service_binding, sample_issue_time()) + .await; + + assert_eq!( + response, + make(sample_ipv6_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + ); + } + + #[tokio::test] + async fn it_should_send_the_upd4_connect_event_when_a_client_tries_to_connect_using_a_ip4_socket_address() { + let client_socket_addr = sample_ipv4_socket_address(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_stats_event_sender_mock = MockUdpCoreStatsEventSender::new(); + udp_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpConnect { + connection: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let opt_udp_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(udp_stats_event_sender_mock)); + + let connect_service = Arc::new(ConnectService::new(opt_udp_stats_event_sender)); + + connect_service + .handle_connect(client_socket_addr, server_service_binding, sample_issue_time()) + .await; + } + + #[tokio::test] + async fn it_should_send_the_upd6_connect_event_when_a_client_tries_to_connect_using_a_ip6_socket_address() { + let client_socket_addr = sample_ipv6_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_stats_event_sender_mock = MockUdpCoreStatsEventSender::new(); + udp_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpConnect { + connection: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let opt_udp_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(udp_stats_event_sender_mock)); + + let connect_service = Arc::new(ConnectService::new(opt_udp_stats_event_sender)); + + connect_service + .handle_connect(client_socket_addr, server_service_binding, sample_issue_time()) + .await; + } + } +} diff --git a/packages/udp-tracker-core/src/services/mod.rs b/packages/udp-tracker-core/src/services/mod.rs new file mode 100644 index 000000000..56882e68f --- /dev/null +++ b/packages/udp-tracker-core/src/services/mod.rs @@ -0,0 +1,54 @@ +pub mod announce; +pub mod banning; +pub mod connect; +pub mod scrape; + +#[cfg(test)] +pub(crate) mod tests { + + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use futures::future::BoxFuture; + use mockall::mock; + use torrust_tracker_events::sender::SendError; + + use crate::connection_cookie::gen_remote_fingerprint; + use crate::event::Event; + + pub(crate) fn sample_ipv4_remote_addr() -> SocketAddr { + sample_ipv4_socket_address() + } + + pub(crate) fn sample_ipv4_remote_addr_fingerprint() -> u64 { + gen_remote_fingerprint(&sample_ipv4_socket_address()) + } + + pub(crate) fn sample_ipv6_remote_addr() -> SocketAddr { + sample_ipv6_socket_address() + } + + pub(crate) fn sample_ipv6_remote_addr_fingerprint() -> u64 { + gen_remote_fingerprint(&sample_ipv6_socket_address()) + } + + pub(crate) fn sample_ipv4_socket_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) + } + + fn sample_ipv6_socket_address() -> SocketAddr { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080) + } + + pub(crate) fn sample_issue_time() -> f64 { + 1_000_000_000_f64 + } + + mock! { + pub(crate) UdpCoreStatsEventSender {} + impl torrust_tracker_events::sender::Sender for UdpCoreStatsEventSender { + type Event = Event; + + fn send(&self, event: Event) -> BoxFuture<'static,Option > > > ; + } + } +} diff --git a/packages/udp-tracker-core/src/services/scrape.rs b/packages/udp-tracker-core/src/services/scrape.rs new file mode 100644 index 000000000..8551351fb --- /dev/null +++ b/packages/udp-tracker-core/src/services/scrape.rs @@ -0,0 +1,130 @@ +//! The `scrape` service. +//! +//! The service is responsible for handling the `scrape` requests. +//! +//! It delegates the `scrape` logic to the [`ScrapeHandler`] and it returns the +//! [`ScrapeData`]. +//! +//! It also sends an [`udp_tracker_core::statistics::event::Event`] +//! because events are specific for the UDP tracker. +use std::net::SocketAddr; +use std::ops::Range; +use std::sync::Arc; + +use aquatic_udp_protocol::ScrapeRequest; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_core::error::{ScrapeError, WhitelistError}; +use bittorrent_tracker_core::scrape_handler::ScrapeHandler; +use torrust_tracker_primitives::core::ScrapeData; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::connection_cookie::{check, gen_remote_fingerprint, ConnectionCookieError}; +use crate::event::{ConnectionContext, Event}; + +/// The `ScrapeService` is responsible for handling the `scrape` requests. +/// +/// The service sends an statistics event that increments: +/// +/// - The number of UDP `scrape` requests handled by the UDP tracker. +pub struct ScrapeService { + scrape_handler: Arc, + opt_udp_stats_event_sender: crate::event::sender::Sender, +} + +impl ScrapeService { + #[must_use] + pub fn new(scrape_handler: Arc, opt_udp_stats_event_sender: crate::event::sender::Sender) -> Self { + Self { + scrape_handler, + opt_udp_stats_event_sender, + } + } + + /// It handles the `Scrape` request. + /// + /// # Errors + /// + /// It will return an error if the tracker core scrape handler returns an error. + pub async fn handle_scrape( + &self, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request: &ScrapeRequest, + cookie_valid_range: Range, + ) -> Result { + Self::authenticate(client_socket_addr, request, cookie_valid_range)?; + + let scrape_data = self + .scrape_handler + .handle_scrape(&Self::convert_from_aquatic(&request.info_hashes)) + .await?; + + self.send_event(client_socket_addr, server_service_binding).await; + + Ok(scrape_data) + } + + fn authenticate( + remote_addr: SocketAddr, + request: &ScrapeRequest, + cookie_valid_range: Range, + ) -> Result { + check( + &request.connection_id, + gen_remote_fingerprint(&remote_addr), + cookie_valid_range, + ) + } + + fn convert_from_aquatic(aquatic_infohashes: &[aquatic_udp_protocol::common::InfoHash]) -> Vec { + aquatic_infohashes.iter().map(|&x| x.into()).collect() + } + + async fn send_event(&self, client_socket_addr: SocketAddr, server_service_binding: ServiceBinding) { + if let Some(udp_stats_event_sender) = self.opt_udp_stats_event_sender.as_deref() { + let event = Event::UdpScrape { + connection: ConnectionContext::new(client_socket_addr, server_service_binding), + }; + + tracing::debug!(target = crate::UDP_TRACKER_LOG_TARGET, "Sending UdpScrape event: {event:?}"); + + udp_stats_event_sender.send(event).await; + } + } +} + +/// Errors related to scrape requests. +#[derive(thiserror::Error, Debug, Clone)] +pub enum UdpScrapeError { + /// Error returned when there was an error with the connection cookie. + #[error("Connection cookie error: {source}")] + ConnectionCookieError { source: ConnectionCookieError }, + + /// Error returned when there was an error with the tracker core scrape handler. + #[error("Tracker core scrape error: {source}")] + TrackerCoreScrapeError { source: ScrapeError }, + + /// Error returned when there was an error with the tracker core whitelist. + #[error("Tracker core whitelist error: {source}")] + TrackerCoreWhitelistError { source: WhitelistError }, +} + +impl From for UdpScrapeError { + fn from(connection_cookie_error: ConnectionCookieError) -> Self { + Self::ConnectionCookieError { + source: connection_cookie_error, + } + } +} + +impl From for UdpScrapeError { + fn from(scrape_error: ScrapeError) -> Self { + Self::TrackerCoreScrapeError { source: scrape_error } + } +} + +impl From for UdpScrapeError { + fn from(whitelist_error: WhitelistError) -> Self { + Self::TrackerCoreWhitelistError { source: whitelist_error } + } +} diff --git a/packages/udp-tracker-core/src/statistics/event/handler.rs b/packages/udp-tracker-core/src/statistics/event/handler.rs new file mode 100644 index 000000000..e5d2b87a7 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/event/handler.rs @@ -0,0 +1,222 @@ +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::Event; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL; + +/// # Panics +/// +/// This function panics if the IP version does not match the event type. +pub async fn handle_event(event: Event, stats_repository: &Repository, now: DurationSinceUnixEpoch) { + match event { + Event::UdpConnect { connection: context } => { + let mut label_set = LabelSet::from(context); + label_set.upsert(label_name!("request_kind"), LabelValue::new("connect")); + + match stats_repository + .increase_counter(&metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + Event::UdpAnnounce { connection: context, .. } => { + let mut label_set = LabelSet::from(context); + label_set.upsert(label_name!("request_kind"), LabelValue::new("announce")); + + match stats_repository + .increase_counter(&metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + Event::UdpScrape { connection: context } => { + let mut label_set = LabelSet::from(context); + label_set.upsert(label_name!("request_kind"), LabelValue::new("scrape")); + + match stats_repository + .increase_counter(&metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + } + + tracing::debug!("stats: {:?}", stats_repository.get_stats().await); +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::peer::PeerAnnouncement; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::tests::sample_info_hash; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_udp4_connections_counter_when_it_receives_a_udp4_connect_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpConnect { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_connections_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp4_announces_counter_when_it_receives_a_udp4_announce_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpAnnounce { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + info_hash: sample_info_hash(), + announcement: PeerAnnouncement::default(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_announces_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp4_scrapes_counter_when_it_receives_a_udp4_scrape_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpScrape { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_scrapes_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_connections_counter_when_it_receives_a_udp6_connect_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpConnect { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_connections_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_announces_counter_when_it_receives_a_udp6_announce_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpAnnounce { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + info_hash: sample_info_hash(), + announcement: PeerAnnouncement::default(), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_announces_handled(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_scrapes_counter_when_it_receives_a_udp6_scrape_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpScrape { + connection: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_scrapes_handled(), 1); + } +} diff --git a/packages/udp-tracker-core/src/statistics/event/listener.rs b/packages/udp-tracker-core/src/statistics/event/listener.rs new file mode 100644 index 000000000..b11bcce85 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/event/listener.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; + +use super::handler::handle_event; +use crate::event::receiver::Receiver; +use crate::statistics::repository::Repository; +use crate::{CurrentClock, UDP_TRACKER_LOG_TARGET}; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + repository: &Arc, +) -> JoinHandle<()> { + let stats_repository = repository.clone(); + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Starting UDP tracker core event listener"); + + tokio::spawn(async move { + dispatch_events(receiver, cancellation_token, stats_repository).await; + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "UDP tracker core event listener finished"); + }) +} + +async fn dispatch_events(mut receiver: Receiver, cancellation_token: CancellationToken, stats_repository: Arc) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Received cancellation request, shutting down UDP tracker core event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker core statistics receiver closed."); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker core statistics receiver lagged by {} events.", n); + } + } + } + } + } + } + } +} diff --git a/packages/udp-tracker-core/src/statistics/event/mod.rs b/packages/udp-tracker-core/src/statistics/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/udp-tracker-core/src/statistics/metrics.rs b/packages/udp-tracker-core/src/statistics/metrics.rs new file mode 100644 index 000000000..98906a596 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/metrics.rs @@ -0,0 +1,124 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::aggregate::sum::Sum; +use torrust_tracker_metrics::metric_collection::{Error, MetricCollection}; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::statistics::UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL; + +#[derive(Debug, PartialEq, Default, Serialize)] +pub struct Metrics { + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + /// # Errors + /// + /// This function returns an error if the metric does not exist and it + /// cannot be created. + pub fn increase_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_counter(metric_name, labels, now) + } + + /// # Errors + /// + /// This function returns an error if the metric does not exist and it + /// cannot be created. + pub fn set_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_gauge(metric_name, labels, value, now) + } +} + +impl Metrics { + /// Total number of UDP (UDP tracker) connections from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_connections_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "connect")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_announces_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_scrapes_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_connections_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "connect")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_announces_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_scrapes_handled(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } +} diff --git a/packages/udp-tracker-core/src/statistics/mod.rs b/packages/udp-tracker-core/src/statistics/mod.rs new file mode 100644 index 000000000..fec76069e --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/mod.rs @@ -0,0 +1,24 @@ +pub mod event; +pub mod metrics; +pub mod repository; +pub mod services; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_metrics::unit::Unit; + +const UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL: &str = "udp_tracker_core_requests_received_total"; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_CORE_REQUESTS_RECEIVED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests received")), + ); + + metrics +} diff --git a/packages/udp-tracker-core/src/statistics/repository.rs b/packages/udp-tracker-core/src/statistics/repository.rs new file mode 100644 index 000000000..ceee0e369 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/repository.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::Error; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::describe_metrics; +use super::metrics::Metrics; + +/// A repository for the tracker metrics. +#[derive(Clone)] +pub struct Repository { + pub stats: Arc>, +} + +impl Default for Repository { + fn default() -> Self { + Self::new() + } +} + +impl Repository { + #[must_use] + pub fn new() -> Self { + Self { + stats: Arc::new(RwLock::new(describe_metrics())), + } + } + + pub async fn get_stats(&self) -> RwLockReadGuard<'_, Metrics> { + self.stats.read().await + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increase the counter. + pub async fn increase_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increase_counter(metric_name, labels, now); + + drop(stats_lock); + + result + } +} diff --git a/packages/udp-tracker-core/src/statistics/services.rs b/packages/udp-tracker-core/src/statistics/services.rs new file mode 100644 index 000000000..18a80bad1 --- /dev/null +++ b/packages/udp-tracker-core/src/statistics/services.rs @@ -0,0 +1,105 @@ +//! Statistics services. +//! +//! It includes: +//! +//! - A [`factory`](crate::statistics::setup::factory) function to build the structs needed to collect the tracker metrics. +//! - A [`get_metrics`] service to get the tracker [`metrics`](crate::statistics::metrics::Metrics). +//! +//! Tracker metrics are collected using a Publisher-Subscribe pattern. +//! +//! The factory function builds two structs: +//! +//! - An event [`Sender`](crate::event::sender::Sender) +//! - An statistics [`Repository`] +//! +//! ```text +//! let (stats_event_sender, stats_repository) = factory(tracker_usage_statistics); +//! ``` +//! +//! The statistics repository is responsible for storing the metrics in memory. +//! The statistics event sender allows sending events related to metrics. +//! There is an event listener that is receiving all the events and processing them with an event handler. +//! Then, the event handler updates the metrics depending on the received event. +//! +//! For example, if you send the event [`Event::Udp4Connect`](crate::statistics::event::Event::Udp4Connect): +//! +//! ```text +//! let result = event_sender.send_event(Event::Udp4Connect).await; +//! ``` +//! +//! Eventually the counter for UDP connections from IPv4 peers will be increased. +//! +//! ```rust,no_run +//! pub struct Metrics { +//! // ... +//! pub udp4_connections_handled: u64, // This will be incremented +//! // ... +//! } +//! ``` +use std::sync::Arc; + +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + +use crate::statistics::metrics::Metrics; +use crate::statistics::repository::Repository; + +/// All the metrics collected by the tracker. +#[derive(Debug, PartialEq)] +pub struct TrackerMetrics { + /// Domain level metrics. + /// + /// General metrics for all torrents (number of seeders, leechers, etcetera) + pub torrents_metrics: AggregateActiveSwarmMetadata, + + /// Application level metrics. Usage statistics/metrics. + /// + /// Metrics about how the tracker is been used (number of udp announce requests, etcetera) + pub protocol_metrics: Metrics, +} + +/// It returns all the [`TrackerMetrics`] +pub async fn get_metrics( + in_memory_torrent_repository: Arc, + stats_repository: Arc, +) -> TrackerMetrics { + let torrents_metrics = in_memory_torrent_repository.get_aggregate_swarm_metadata().await; + let stats = stats_repository.get_stats().await; + + TrackerMetrics { + torrents_metrics, + protocol_metrics: Metrics { + metric_collection: stats.metric_collection.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::{self}; + use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + + use crate::statistics::describe_metrics; + use crate::statistics::repository::Repository; + use crate::statistics::services::{get_metrics, TrackerMetrics}; + + #[tokio::test] + async fn the_statistics_service_should_return_the_tracker_metrics() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let repository = Arc::new(Repository::new()); + + let tracker_metrics = get_metrics(in_memory_torrent_repository.clone(), repository.clone()).await; + + assert_eq!( + tracker_metrics, + TrackerMetrics { + torrents_metrics: AggregateActiveSwarmMetadata::default(), + protocol_metrics: describe_metrics(), + } + ); + } +} diff --git a/packages/udp-tracker-server/Cargo.toml b/packages/udp-tracker-server/Cargo.toml new file mode 100644 index 000000000..160fe58f9 --- /dev/null +++ b/packages/udp-tracker-server/Cargo.toml @@ -0,0 +1,46 @@ +[package] +authors.workspace = true +description = "The Torrust Bittorrent UDP tracker." +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["axum", "bittorrent", "server", "torrust", "tracker", "udp"] +license.workspace = true +name = "torrust-udp-tracker-server" +publish.workspace = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +bittorrent-tracker-client = { version = "3.0.0-develop", path = "../tracker-client" } +bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } +bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } +derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } +futures = "0" +futures-util = "0" +ringbuf = "0" +serde = "1.0.219" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio-util = "0.7.15" +torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } +torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-events = { version = "3.0.0-develop", path = "../events" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } +torrust-tracker-swarm-coordination-registry = { version = "3.0.0-develop", path = "../swarm-coordination-registry" } +tracing = "0" +url = { version = "2", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } +zerocopy = "0.7" + +[dev-dependencies] +local-ip-address = "0" +mockall = "0" +rand = "0" +torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } diff --git a/packages/udp-tracker-server/LICENSE b/packages/udp-tracker-server/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/udp-tracker-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/udp-tracker-server/README.md b/packages/udp-tracker-server/README.md new file mode 100644 index 000000000..bdf147104 --- /dev/null +++ b/packages/udp-tracker-server/README.md @@ -0,0 +1,11 @@ +# Torrust UDP Tracker + +The Torrust Bittorrent UDP tracker. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-udp-tracker-server). + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/udp-tracker-server/src/banning/event/handler.rs b/packages/udp-tracker-server/src/banning/event/handler.rs new file mode 100644 index 000000000..4876323a8 --- /dev/null +++ b/packages/udp-tracker-server/src/banning/event/handler.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use bittorrent_udp_tracker_core::services::banning::BanService; +use tokio::sync::RwLock; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::{ErrorKind, Event}; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_IPS_BANNED_TOTAL; + +pub async fn handle_event( + event: Event, + ban_service: &Arc>, + repository: &Repository, + now: DurationSinceUnixEpoch, +) { + if let Event::UdpError { + context, + kind: _, + error: ErrorKind::ConnectionCookie(_msg), + } = event + { + let mut ban_service = ban_service.write().await; + + ban_service.increase_counter(&context.client_socket_addr().ip()); + + update_metric_for_banned_ips_total(repository, ban_service.get_banned_ips_total(), now).await; + } +} + +#[allow(clippy::cast_precision_loss)] +async fn update_metric_for_banned_ips_total(repository: &Repository, ips_banned_total: usize, now: DurationSinceUnixEpoch) { + match repository + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), + &LabelSet::default(), + ips_banned_total as f64, + now, + ) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + } +} diff --git a/packages/udp-tracker-server/src/banning/event/listener.rs b/packages/udp-tracker-server/src/banning/event/listener.rs new file mode 100644 index 000000000..0d579f912 --- /dev/null +++ b/packages/udp-tracker-server/src/banning/event/listener.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use bittorrent_udp_tracker_core::services::banning::BanService; +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; + +use super::handler::handle_event; +use crate::event::receiver::Receiver; +use crate::statistics::repository::Repository; +use crate::CurrentClock; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + ban_service: &Arc>, + repository: &Arc, +) -> JoinHandle<()> { + let ban_service_clone = ban_service.clone(); + let repository_clone = repository.clone(); + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Starting UDP tracker server event listener (banning)"); + + tokio::spawn(async move { + dispatch_events(receiver, cancellation_token, ban_service_clone, repository_clone).await; + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "UDP tracker server event listener (banning) finished"); + }) +} + +async fn dispatch_events( + mut receiver: Receiver, + cancellation_token: CancellationToken, + ban_service: Arc>, + repository: Arc, +) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Received cancellation request, shutting down UDP tracker server event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event(event, &ban_service, &repository, CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker server receiver (banning) closed."); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker server receiver (banning) lagged by {} events.", n); + } + } + } + } + } + } + } +} diff --git a/packages/udp-tracker-server/src/banning/event/mod.rs b/packages/udp-tracker-server/src/banning/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/udp-tracker-server/src/banning/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/udp-tracker-server/src/banning/mod.rs b/packages/udp-tracker-server/src/banning/mod.rs new file mode 100644 index 000000000..53f112654 --- /dev/null +++ b/packages/udp-tracker-server/src/banning/mod.rs @@ -0,0 +1 @@ +pub mod event; diff --git a/packages/udp-tracker-server/src/container.rs b/packages/udp-tracker-server/src/container.rs new file mode 100644 index 000000000..365db4ca7 --- /dev/null +++ b/packages/udp-tracker-server/src/container.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use torrust_tracker_configuration::Core; + +use crate::event::bus::EventBus; +use crate::event::sender::Broadcaster; +use crate::event::{self}; +use crate::statistics; +use crate::statistics::repository::Repository; + +pub struct UdpTrackerServerContainer { + pub event_bus: Arc, + pub stats_event_sender: crate::event::sender::Sender, + pub stats_repository: Arc, +} + +impl UdpTrackerServerContainer { + #[must_use] + pub fn initialize(core_config: &Arc) -> Arc { + let udp_tracker_server_services = UdpTrackerServerServices::initialize(core_config); + + Arc::new(Self { + event_bus: udp_tracker_server_services.event_bus.clone(), + stats_event_sender: udp_tracker_server_services.stats_event_sender.clone(), + stats_repository: udp_tracker_server_services.stats_repository.clone(), + }) + } +} + +pub struct UdpTrackerServerServices { + pub event_bus: Arc, + pub stats_event_sender: crate::event::sender::Sender, + pub stats_repository: Arc, +} + +impl UdpTrackerServerServices { + #[must_use] + pub fn initialize(core_config: &Arc) -> Arc { + let udp_server_broadcaster = Broadcaster::default(); + let udp_server_stats_repository = Arc::new(Repository::new()); + let udp_server_stats_event_bus = Arc::new(EventBus::new( + core_config.tracker_usage_statistics.into(), + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = udp_server_stats_event_bus.sender(); + + Arc::new(Self { + event_bus: udp_server_stats_event_bus.clone(), + stats_event_sender: udp_server_stats_event_sender.clone(), + stats_repository: udp_server_stats_repository.clone(), + }) + } +} diff --git a/packages/udp-tracker-server/src/environment.rs b/packages/udp-tracker-server/src/environment.rs new file mode 100644 index 000000000..13e18ba9b --- /dev/null +++ b/packages/udp-tracker-server/src/environment.rs @@ -0,0 +1,237 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use bittorrent_tracker_core::container::TrackerCoreContainer; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_server_lib::registar::Registar; +use torrust_tracker_configuration::{logging, Configuration, DEFAULT_TIMEOUT}; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; + +use crate::container::UdpTrackerServerContainer; +use crate::server::spawner::Spawner; +use crate::server::states::{Running, Stopped}; +use crate::server::Server; + +pub type Started = Environment; + +pub struct Environment +where + S: std::fmt::Debug + std::fmt::Display, +{ + pub container: Arc, + pub registar: Registar, + pub server: Server, + pub udp_core_event_listener_job: Option>, + pub udp_server_stats_event_listener_job: Option>, + pub udp_server_banning_event_listener_job: Option>, + pub cancellation_token: CancellationToken, +} + +impl Environment { + #[allow(dead_code)] + #[must_use] + pub fn new(configuration: &Arc) -> Self { + initialize_global_services(configuration); + + let container = Arc::new(EnvContainer::initialize(configuration)); + + let bind_to = container.udp_tracker_core_container.udp_tracker_config.bind_address; + + let server = Server::new(Spawner::new(bind_to)); + + Self { + container, + registar: Registar::default(), + server, + udp_core_event_listener_job: None, + udp_server_stats_event_listener_job: None, + udp_server_banning_event_listener_job: None, + cancellation_token: CancellationToken::new(), + } + } + + /// Starts the test environment and return a running environment. + /// + /// # Panics + /// + /// Will panic if it cannot start the server. + #[allow(dead_code)] + pub async fn start(self) -> Environment { + let cookie_lifetime = self.container.udp_tracker_core_container.udp_tracker_config.cookie_lifetime; + + // Start the UDP tracker core event listener + let udp_core_event_listener_job = Some(bittorrent_udp_tracker_core::statistics::event::listener::run_event_listener( + self.container.udp_tracker_core_container.event_bus.receiver(), + self.cancellation_token.clone(), + &self.container.udp_tracker_core_container.stats_repository, + )); + + // Start the UDP tracker server event listener (statistics) + let udp_server_stats_event_listener_job = Some(crate::statistics::event::listener::run_event_listener( + self.container.udp_tracker_server_container.event_bus.receiver(), + self.cancellation_token.clone(), + &self.container.udp_tracker_server_container.stats_repository, + )); + + // Start the UDP tracker server event listener (banning) + let udp_server_banning_event_listener_job = Some(crate::banning::event::listener::run_event_listener( + self.container.udp_tracker_server_container.event_bus.receiver(), + self.cancellation_token.clone(), + &self.container.udp_tracker_core_container.ban_service, + &self.container.udp_tracker_server_container.stats_repository, + )); + + // Start the UDP tracker server + let server = self + .server + .start( + self.container.udp_tracker_core_container.clone(), + self.container.udp_tracker_server_container.clone(), + self.registar.give_form(), + cookie_lifetime, + ) + .await + .expect("Failed to start the UDP tracker server"); + + Environment { + container: self.container.clone(), + registar: self.registar.clone(), + server, + udp_core_event_listener_job, + udp_server_stats_event_listener_job, + udp_server_banning_event_listener_job, + cancellation_token: self.cancellation_token, + } + } +} + +impl Environment { + /// # Panics + /// + /// Will panic if it cannot start the server within the timeout. + pub async fn new(configuration: &Arc) -> Self { + tokio::time::timeout(DEFAULT_TIMEOUT, Environment::::new(configuration).start()) + .await + .expect("Failed to create a UDP tracker server running environment within the timeout") + } + + /// Stops the test environment and return a stopped environment. + /// + /// # Panics + /// + /// Will panic if it cannot stop the service within the timeout. + #[allow(dead_code)] + pub async fn stop(self) -> Environment { + // Stop the UDP tracker core event listener + if let Some(udp_core_event_listener_job) = self.udp_core_event_listener_job { + // todo: send a message to the event listener to stop and wait for + // it to finish + udp_core_event_listener_job.abort(); + } + + // Stop the UDP tracker server event listener (statistics) + if let Some(udp_server_stats_event_listener_job) = self.udp_server_stats_event_listener_job { + // todo: send a message to the event listener to stop and wait for + // it to finish + udp_server_stats_event_listener_job.abort(); + } + + // Stop the UDP tracker server event listener (banning) + if let Some(udp_server_banning_event_listener_job) = self.udp_server_banning_event_listener_job { + // todo: send a message to the event listener to stop and wait for + // it to finish + udp_server_banning_event_listener_job.abort(); + } + + // Stop the UDP tracker server + let server = tokio::time::timeout(DEFAULT_TIMEOUT, self.server.stop()) + .await + .expect("Failed to stop the UDP tracker server within the timeout") + .expect("Failed to stop the UDP tracker server"); + + Environment { + container: self.container, + registar: Registar::default(), + server, + udp_core_event_listener_job: None, + udp_server_stats_event_listener_job: None, + udp_server_banning_event_listener_job: None, + cancellation_token: self.cancellation_token, + } + } + + #[must_use] + pub fn bind_address(&self) -> SocketAddr { + self.server.state.local_addr + } +} + +pub struct EnvContainer { + pub tracker_core_container: Arc, + pub udp_tracker_core_container: Arc, + pub udp_tracker_server_container: Arc, +} + +impl EnvContainer { + /// # Panics + /// + /// Will panic if the configuration is missing the UDP tracker configuration. + #[must_use] + pub fn initialize(configuration: &Configuration) -> Self { + let core_config = Arc::new(configuration.core.clone()); + let udp_tracker_configurations = configuration.udp_trackers.clone().expect("missing UDP tracker configuration"); + let udp_tracker_config = Arc::new(udp_tracker_configurations[0].clone()); + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + let udp_tracker_core_container = + UdpTrackerCoreContainer::initialize_from_tracker_core(&tracker_core_container, &udp_tracker_config); + + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); + + Self { + tracker_core_container, + udp_tracker_core_container, + udp_tracker_server_container, + } + } +} + +fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); +} + +fn initialize_static() { + torrust_tracker_clock::initialize_static(); + bittorrent_udp_tracker_core::initialize_static(); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::time::sleep; + use torrust_tracker_test_helpers::{configuration, logging}; + + use crate::environment::Started; + + #[tokio::test] + async fn it_should_make_and_stop_udp_server() { + logging::setup(); + + let env = Started::new(&configuration::ephemeral().into()).await; + sleep(Duration::from_secs(1)).await; + env.stop().await; + sleep(Duration::from_secs(1)).await; + } +} diff --git a/packages/udp-tracker-server/src/error.rs b/packages/udp-tracker-server/src/error.rs new file mode 100644 index 000000000..d260ebfd4 --- /dev/null +++ b/packages/udp-tracker-server/src/error.rs @@ -0,0 +1,100 @@ +//! Error types for the UDP server. +use std::fmt::Display; +use std::panic::Location; + +use aquatic_udp_protocol::{ConnectionId, RequestParseError, TransactionId}; +use bittorrent_udp_tracker_core::services::announce::UdpAnnounceError; +use bittorrent_udp_tracker_core::services::scrape::UdpScrapeError; +use derive_more::derive::Display; +use thiserror::Error; + +#[derive(Display, Debug)] +#[display(":?")] +pub struct ConnectionCookie(pub ConnectionId); + +/// Error returned by the UDP server. +#[derive(Error, Debug, Clone)] +pub enum Error { + /// Error returned when the request is invalid. + #[error("error parsing request: {request_parse_error:?}")] + InvalidRequest { request_parse_error: SendableRequestParseError }, + + /// Error returned when the domain tracker returns an announce error. + #[error("tracker announce error: {source}")] + AnnounceFailed { source: UdpAnnounceError }, + + /// Error returned when the domain tracker returns an scrape error. + #[error("tracker scrape error: {source}")] + ScrapeFailed { source: UdpScrapeError }, + + /// Error returned from a third-party library (`aquatic_udp_protocol`). + #[error("internal server error: {message}, {location}")] + Internal { + location: &'static Location<'static>, + message: String, + }, + + /// Error returned when tracker requires authentication. + #[error("domain tracker requires authentication but is not supported in current UDP implementation. Location: {location}")] + AuthRequired { location: &'static Location<'static> }, +} + +impl From for Error { + fn from(request_parse_error: RequestParseError) -> Self { + Self::InvalidRequest { + request_parse_error: request_parse_error.into(), + } + } +} + +impl From for Error { + fn from(udp_announce_error: UdpAnnounceError) -> Self { + Self::AnnounceFailed { + source: udp_announce_error, + } + } +} + +impl From for Error { + fn from(udp_scrape_error: UdpScrapeError) -> Self { + Self::ScrapeFailed { + source: udp_scrape_error, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SendableRequestParseError { + pub message: String, + pub opt_connection_id: Option, + pub opt_transaction_id: Option, +} + +impl Display for SendableRequestParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SendableRequestParseError: message: {}, connection_id: {:?}, transaction_id: {:?}", + self.message, self.opt_connection_id, self.opt_transaction_id + ) + } +} + +impl From for SendableRequestParseError { + fn from(request_parse_error: RequestParseError) -> Self { + let (message, opt_connection_id, opt_transaction_id) = match request_parse_error { + RequestParseError::Sendable { + connection_id, + transaction_id, + err, + } => ((*err).to_string(), Some(connection_id), Some(transaction_id)), + RequestParseError::Unsendable { err } => (err.to_string(), None, None), + }; + + Self { + message, + opt_connection_id, + opt_transaction_id, + } + } +} diff --git a/packages/udp-tracker-server/src/event.rs b/packages/udp-tracker-server/src/event.rs new file mode 100644 index 000000000..a7634d58e --- /dev/null +++ b/packages/udp-tracker-server/src/event.rs @@ -0,0 +1,192 @@ +use std::fmt; +use std::net::SocketAddr; +use std::time::Duration; + +use aquatic_udp_protocol::AnnounceRequest; +use bittorrent_tracker_core::error::{AnnounceError, ScrapeError}; +use bittorrent_udp_tracker_core::services::announce::UdpAnnounceError; +use bittorrent_udp_tracker_core::services::scrape::UdpScrapeError; +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::label_name; +use torrust_tracker_primitives::service_binding::ServiceBinding; + +use crate::error::Error; + +/// A UDP server event. +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + UdpRequestReceived { + context: ConnectionContext, + }, + UdpRequestAborted { + context: ConnectionContext, + }, + UdpRequestBanned { + context: ConnectionContext, + }, + UdpRequestAccepted { + context: ConnectionContext, + kind: UdpRequestKind, + }, + UdpResponseSent { + context: ConnectionContext, + kind: UdpResponseKind, + req_processing_time: Duration, + }, + UdpError { + context: ConnectionContext, + kind: Option, + error: ErrorKind, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum UdpRequestKind { + Connect, + Announce { announce_request: AnnounceRequest }, + Scrape, +} + +impl From for LabelValue { + fn from(kind: UdpRequestKind) -> Self { + match kind { + UdpRequestKind::Connect => LabelValue::new("connect"), + UdpRequestKind::Announce { .. } => LabelValue::new("announce"), + UdpRequestKind::Scrape => LabelValue::new("scrape"), + } + } +} + +impl fmt::Display for UdpRequestKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let proto_str = match self { + UdpRequestKind::Connect => "connect", + UdpRequestKind::Announce { .. } => "announce", + UdpRequestKind::Scrape => "scrape", + }; + write!(f, "{proto_str}") + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum UdpResponseKind { + Ok { + req_kind: UdpRequestKind, + }, + + /// There was an error handling the request. The error contains the request + /// kind if the request was parsed successfully. + Error { + opt_req_kind: Option, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ConnectionContext { + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, +} + +impl ConnectionContext { + #[must_use] + pub fn new(client_socket_addr: SocketAddr, server_service_binding: ServiceBinding) -> Self { + Self { + client_socket_addr, + server_service_binding, + } + } + + #[must_use] + pub fn client_socket_addr(&self) -> SocketAddr { + self.client_socket_addr + } + + #[must_use] + pub fn server_socket_addr(&self) -> SocketAddr { + self.server_service_binding.bind_address() + } +} + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + label_name!("server_binding_protocol"), + LabelValue::new(&connection_context.server_service_binding.protocol().to_string()), + ), + ( + label_name!("server_binding_ip"), + LabelValue::new(&connection_context.server_service_binding.bind_address().ip().to_string()), + ), + ( + label_name!("server_binding_address_ip_type"), + LabelValue::new(&connection_context.server_service_binding.bind_address_ip_type().to_string()), + ), + ( + label_name!("server_binding_address_ip_family"), + LabelValue::new(&connection_context.server_service_binding.bind_address_ip_family().to_string()), + ), + ( + label_name!("server_binding_port"), + LabelValue::new(&connection_context.server_service_binding.bind_address().port().to_string()), + ), + ]) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ErrorKind { + RequestParse(String), + ConnectionCookie(String), + Whitelist(String), + Database(String), + InternalServer(String), + BadRequest(String), + TrackerAuthentication(String), +} + +impl From for ErrorKind { + fn from(error: Error) -> Self { + match error { + Error::InvalidRequest { request_parse_error } => Self::RequestParse(request_parse_error.to_string()), + Error::AnnounceFailed { source } => match source { + UdpAnnounceError::ConnectionCookieError { source } => Self::ConnectionCookie(source.to_string()), + UdpAnnounceError::TrackerCoreAnnounceError { source } => match source { + AnnounceError::Whitelist(whitelist_error) => Self::Whitelist(whitelist_error.to_string()), + AnnounceError::Database(error) => Self::Database(error.to_string()), + }, + UdpAnnounceError::TrackerCoreWhitelistError { source } => Self::Whitelist(source.to_string()), + }, + Error::ScrapeFailed { source } => match source { + UdpScrapeError::ConnectionCookieError { source } => Self::ConnectionCookie(source.to_string()), + UdpScrapeError::TrackerCoreScrapeError { source } => match source { + ScrapeError::Whitelist(whitelist_error) => Self::Whitelist(whitelist_error.to_string()), + }, + UdpScrapeError::TrackerCoreWhitelistError { source } => Self::Whitelist(source.to_string()), + }, + Error::Internal { location: _, message } => Self::InternalServer(message.clone()), + Error::AuthRequired { location } => Self::TrackerAuthentication(location.to_string()), + } + } +} + +pub mod sender { + use std::sync::Arc; + + use super::Event; + + pub type Sender = Option>>; + pub type Broadcaster = torrust_tracker_events::broadcaster::Broadcaster; +} + +pub mod receiver { + use super::Event; + + pub type Receiver = Box>; +} + +pub mod bus { + use crate::event::Event; + + pub type EventBus = torrust_tracker_events::bus::EventBus; +} diff --git a/packages/udp-tracker-server/src/handlers/announce.rs b/packages/udp-tracker-server/src/handlers/announce.rs new file mode 100644 index 000000000..ea19611ce --- /dev/null +++ b/packages/udp-tracker-server/src/handlers/announce.rs @@ -0,0 +1,986 @@ +//! UDP tracker announce handler. +use std::net::{IpAddr, SocketAddr}; +use std::ops::Range; +use std::sync::Arc; + +use aquatic_udp_protocol::{ + AnnounceInterval, AnnounceRequest, AnnounceResponse, AnnounceResponseFixedData, Ipv4AddrBytes, Ipv6AddrBytes, NumberOfPeers, + Port, Response, ResponsePeer, TransactionId, +}; +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_udp_tracker_core::services::announce::AnnounceService; +use torrust_tracker_configuration::Core; +use torrust_tracker_primitives::core::AnnounceData; +use torrust_tracker_primitives::service_binding::ServiceBinding; +use tracing::{instrument, Level}; +use zerocopy::network_endian::I32; + +use crate::error::Error; +use crate::event::{ConnectionContext, Event, UdpRequestKind}; + +/// It handles the `Announce` request. +/// +/// # Errors +/// +/// If a error happens in the `handle_announce` function, it will just return the `ServerError`. +#[instrument(fields(transaction_id, connection_id, info_hash), skip(announce_service, opt_udp_server_stats_event_sender), ret(level = Level::TRACE))] +pub async fn handle_announce( + announce_service: &Arc, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request: &AnnounceRequest, + core_config: &Arc, + opt_udp_server_stats_event_sender: &crate::event::sender::Sender, + cookie_valid_range: Range, +) -> Result { + tracing::Span::current() + .record("transaction_id", request.transaction_id.0.to_string()) + .record("connection_id", request.connection_id.0.to_string()) + .record("info_hash", InfoHash::from_bytes(&request.info_hash.0).to_hex_string()); + + tracing::trace!("handle announce"); + + if let Some(udp_server_stats_event_sender) = opt_udp_server_stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Announce { + announce_request: *request, + }, + }) + .await; + } + + let announce_data = announce_service + .handle_announce(client_socket_addr, server_service_binding, request, cookie_valid_range) + .await + .map_err(|e| { + ( + e.into(), + request.transaction_id, + UdpRequestKind::Announce { + announce_request: *request, + }, + ) + })?; + + Ok(build_response(client_socket_addr, request, core_config, &announce_data)) +} + +fn build_response( + remote_addr: SocketAddr, + request: &AnnounceRequest, + core_config: &Arc, + announce_data: &AnnounceData, +) -> Response { + #[allow(clippy::cast_possible_truncation)] + if remote_addr.is_ipv4() { + let announce_response = AnnounceResponse { + fixed: AnnounceResponseFixedData { + transaction_id: request.transaction_id, + announce_interval: AnnounceInterval(I32::new(i64::from(core_config.announce_policy.interval) as i32)), + leechers: NumberOfPeers(I32::new(i64::from(announce_data.stats.incomplete) as i32)), + seeders: NumberOfPeers(I32::new(i64::from(announce_data.stats.complete) as i32)), + }, + peers: announce_data + .peers + .iter() + .filter_map(|peer| { + if let IpAddr::V4(ip) = peer.peer_addr.ip() { + Some(ResponsePeer:: { + ip_address: ip.into(), + port: Port(peer.peer_addr.port().into()), + }) + } else { + None + } + }) + .collect(), + }; + + Response::from(announce_response) + } else { + let announce_response = AnnounceResponse { + fixed: AnnounceResponseFixedData { + transaction_id: request.transaction_id, + announce_interval: AnnounceInterval(I32::new(i64::from(core_config.announce_policy.interval) as i32)), + leechers: NumberOfPeers(I32::new(i64::from(announce_data.stats.incomplete) as i32)), + seeders: NumberOfPeers(I32::new(i64::from(announce_data.stats.complete) as i32)), + }, + peers: announce_data + .peers + .iter() + .filter_map(|peer| { + if let IpAddr::V6(ip) = peer.peer_addr.ip() { + Some(ResponsePeer:: { + ip_address: ip.into(), + port: Port(peer.peer_addr.port().into()), + }) + } else { + None + } + }) + .collect(), + }; + + Response::from(announce_response) + } +} + +#[cfg(test)] +pub(crate) mod tests { + + pub mod announce_request { + + use std::net::Ipv4Addr; + use std::num::NonZeroU16; + + use aquatic_udp_protocol::{ + AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectionId, NumberOfBytes, NumberOfPeers, + PeerId as AquaticPeerId, PeerKey, Port, TransactionId, + }; + use bittorrent_udp_tracker_core::connection_cookie::make; + + use crate::handlers::tests::{sample_ipv4_remote_addr_fingerprint, sample_issue_time}; + + pub struct AnnounceRequestBuilder { + request: AnnounceRequest, + } + + impl AnnounceRequestBuilder { + pub fn default() -> AnnounceRequestBuilder { + let client_ip = Ipv4Addr::new(126, 0, 0, 1); + let client_port = 8080; + let info_hash_aquatic = aquatic_udp_protocol::InfoHash([0u8; 20]); + + let default_request = AnnounceRequest { + connection_id: make(sample_ipv4_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + action_placeholder: AnnounceActionPlaceholder::default(), + transaction_id: TransactionId(0i32.into()), + info_hash: info_hash_aquatic, + peer_id: AquaticPeerId([255u8; 20]), + bytes_downloaded: NumberOfBytes(0i64.into()), + bytes_uploaded: NumberOfBytes(0i64.into()), + bytes_left: NumberOfBytes(0i64.into()), + event: AnnounceEvent::Started.into(), + ip_address: client_ip.into(), + key: PeerKey::new(0i32), + peers_wanted: NumberOfPeers::new(1i32), + port: Port::new(NonZeroU16::new(client_port).expect("a non-zero client port")), + }; + AnnounceRequestBuilder { + request: default_request, + } + } + + pub fn with_connection_id(mut self, connection_id: ConnectionId) -> Self { + self.request.connection_id = connection_id; + self + } + + pub fn with_info_hash(mut self, info_hash: aquatic_udp_protocol::InfoHash) -> Self { + self.request.info_hash = info_hash; + self + } + + pub fn with_peer_id(mut self, peer_id: AquaticPeerId) -> Self { + self.request.peer_id = peer_id; + self + } + + pub fn with_ip_address(mut self, ip_address: Ipv4Addr) -> Self { + self.request.ip_address = ip_address.into(); + self + } + + pub fn with_port(mut self, port: u16) -> Self { + self.request.port = Port(port.into()); + self + } + + pub fn into(self) -> AnnounceRequest { + self.request + } + } + + mod using_ipv4 { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{ + AnnounceInterval, AnnounceResponse, AnnounceResponseFixedData, InfoHash as AquaticInfoHash, Ipv4AddrBytes, + Ipv6AddrBytes, NumberOfPeers, PeerId as AquaticPeerId, Response, ResponsePeer, + }; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_udp_tracker_core::connection_cookie::{gen_remote_fingerprint, make}; + use mockall::predicate::eq; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::handlers::handle_announce; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_default_tracker_configuration, + initialize_core_tracker_services_for_public_tracker, sample_cookie_valid_range, sample_ipv4_socket_address, + sample_issue_time, CoreTrackerServices, CoreUdpTrackerServices, MockUdpServerStatsEventSender, + }; + + #[tokio::test] + async fn an_announced_peer_should_be_added_to_the_tracker() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_ip = Ipv4Addr::new(126, 0, 0, 1); + let client_port = 8080; + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + + let client_socket_addr = SocketAddr::new(IpAddr::V4(client_ip), client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(client_ip) + .with_port(client_port) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = core_tracker_services + .in_memory_torrent_repository + .get_torrent_peers(&info_hash.0.into()) + .await; + + let expected_peer = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(SocketAddr::new(IpAddr::V4(client_ip), client_port)) + .updated_on(peers[0].updated) + .into(); + + assert_eq!(peers[0], Arc::new(expected_peer)); + } + + #[tokio::test] + async fn the_announced_peer_should_not_be_included_in_the_response() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .into(); + + let response = handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let empty_peer_vector: Vec> = vec![]; + assert_eq!( + response, + Response::from(AnnounceResponse { + fixed: AnnounceResponseFixedData { + transaction_id: request.transaction_id, + announce_interval: AnnounceInterval(120i32.into()), + leechers: NumberOfPeers(0i32.into()), + seeders: NumberOfPeers(1i32.into()), + }, + peers: empty_peer_vector + }) + ); + } + + #[tokio::test] + async fn the_tracker_should_always_use_the_remote_client_ip_but_not_the_port_in_the_udp_request_header_instead_of_the_peer_address_in_the_announce_request( + ) { + // From the BEP 15 (https://www.bittorrent.org/beps/bep_0015.html): + // "Do note that most trackers will only honor the IP address field under limited circumstances." + + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + let client_port = 8080; + + let remote_client_ip = Ipv4Addr::new(126, 0, 0, 1); + let remote_client_port = 8081; + let peer_address = Ipv4Addr::new(126, 0, 0, 2); + + let client_socket_addr = SocketAddr::new(IpAddr::V4(remote_client_ip), remote_client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(peer_address) + .with_port(client_port) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = core_tracker_services + .in_memory_torrent_repository + .get_torrent_peers(&info_hash.0.into()) + .await; + + assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V4(remote_client_ip), client_port)); + } + + async fn add_a_torrent_peer_using_ipv6(in_memory_torrent_repository: &Arc) { + let info_hash = AquaticInfoHash([0u8; 20]); + + let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); + let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); + let client_port = 8080; + let peer_id = AquaticPeerId([255u8; 20]); + + let peer_using_ipv6 = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(SocketAddr::new(IpAddr::V6(client_ip_v6), client_port)) + .into(); + + in_memory_torrent_repository + .handle_announcement(&info_hash.0.into(), &peer_using_ipv6, None) + .await; + } + + async fn announce_a_new_peer_using_ipv4( + core_tracker_services: Arc, + core_udp_tracker_services: Arc, + ) -> Response { + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = event_bus.sender(); + + let client_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn when_the_announce_request_comes_from_a_client_using_ipv4_the_response_should_not_include_peers_using_ipv6() { + let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + add_a_torrent_peer_using_ipv6(&core_tracker_services.in_memory_torrent_repository).await; + + let response = + announce_a_new_peer_using_ipv4(Arc::new(core_tracker_services), Arc::new(core_udp_tracker_services)).await; + + // The response should not contain the peer using IPV6 + let peers: Option>> = match response { + Response::AnnounceIpv6(announce_response) => Some(announce_response.peers), + _ => None, + }; + let no_ipv6_peers = peers.is_none(); + assert!(no_ipv6_peers); + } + + #[tokio::test] + async fn should_send_the_upd4_announce_event() { + let client_socket_addr = sample_ipv4_socket_address(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + let announce_request = AnnounceRequestBuilder::default().into(); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Announce { announce_request }, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = + Some(Arc::new(udp_server_stats_event_sender_mock)); + + let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_default_tracker_configuration(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &announce_request, + &core_tracker_services.core_config, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + } + + mod from_a_loopback_ip { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{InfoHash as AquaticInfoHash, PeerId as AquaticPeerId}; + use bittorrent_udp_tracker_core::connection_cookie::{gen_remote_fingerprint, make}; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::handlers::handle_announce; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_public_tracker, sample_cookie_valid_range, sample_issue_time, + }; + + #[tokio::test] + async fn the_peer_ip_should_be_changed_to_the_external_ip_in_the_tracker_configuration_if_defined() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_ip = Ipv4Addr::LOCALHOST; + let client_port = 8080; + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + + let client_socket_addr = SocketAddr::new(IpAddr::V4(client_ip), client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(client_ip) + .with_port(client_port) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = core_tracker_services + .in_memory_torrent_repository + .get_torrent_peers(&info_hash.0.into()) + .await; + + let external_ip_in_tracker_configuration = core_tracker_services.core_config.net.external_ip.unwrap(); + + let expected_peer = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(SocketAddr::new(external_ip_in_tracker_configuration, client_port)) + .updated_on(peers[0].updated) + .into(); + + assert_eq!(peers[0], Arc::new(expected_peer)); + } + } + } + + mod using_ipv6 { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{ + AnnounceInterval, AnnounceResponse, AnnounceResponseFixedData, InfoHash as AquaticInfoHash, Ipv4AddrBytes, + Ipv6AddrBytes, NumberOfPeers, PeerId as AquaticPeerId, Response, ResponsePeer, + }; + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist; + use bittorrent_udp_tracker_core::connection_cookie::{gen_remote_fingerprint, make}; + use bittorrent_udp_tracker_core::event::bus::EventBus; + use bittorrent_udp_tracker_core::event::sender::Broadcaster; + use bittorrent_udp_tracker_core::services::announce::AnnounceService; + use mockall::predicate::eq; + use torrust_tracker_configuration::Core; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::handlers::handle_announce; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_default_tracker_configuration, + initialize_core_tracker_services_for_public_tracker, sample_cookie_valid_range, sample_ipv6_remote_addr, + sample_issue_time, MockUdpServerStatsEventSender, + }; + + #[tokio::test] + async fn an_announced_peer_should_be_added_to_the_tracker() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); + let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); + let client_port = 8080; + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + + let client_socket_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(client_ip_v4) + .with_port(client_port) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = core_tracker_services + .in_memory_torrent_repository + .get_torrent_peers(&info_hash.0.into()) + .await; + + let expected_peer = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(SocketAddr::new(IpAddr::V6(client_ip_v6), client_port)) + .updated_on(peers[0].updated) + .into(); + + assert_eq!(peers[0], Arc::new(expected_peer)); + } + + #[tokio::test] + async fn the_announced_peer_should_not_be_included_in_the_response() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); + let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); + + let client_socket_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), 8080); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .into(); + + let response = handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let empty_peer_vector: Vec> = vec![]; + assert_eq!( + response, + Response::from(AnnounceResponse { + fixed: AnnounceResponseFixedData { + transaction_id: request.transaction_id, + announce_interval: AnnounceInterval(120i32.into()), + leechers: NumberOfPeers(0i32.into()), + seeders: NumberOfPeers(1i32.into()), + }, + peers: empty_peer_vector + }) + ); + } + + #[tokio::test] + async fn the_tracker_should_always_use_the_remote_client_ip_but_not_the_port_in_the_udp_request_header_instead_of_the_peer_address_in_the_announce_request( + ) { + // From the BEP 15 (https://www.bittorrent.org/beps/bep_0015.html): + // "Do note that most trackers will only honor the IP address field under limited circumstances." + + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_service) = + initialize_core_tracker_services_for_public_tracker(); + + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + let client_port = 8080; + + let remote_client_ip = "::100".parse().unwrap(); // IPV4 ::0.0.1.0 -> IPV6 = ::100 = ::ffff:0:100 = 0:0:0:0:0:ffff:0:0100 + let remote_client_port = 8081; + let peer_address = "126.0.0.1".parse().unwrap(); + + let client_socket_addr = SocketAddr::new(IpAddr::V6(remote_client_ip), remote_client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(peer_address) + .with_port(client_port) + .into(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_tracker_services.core_config, + &server_udp_tracker_service.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = core_tracker_services + .in_memory_torrent_repository + .get_torrent_peers(&info_hash.0.into()) + .await; + + // When using IPv6 the tracker converts the remote client ip into a IPv4 address + assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V6(remote_client_ip), client_port)); + } + + async fn add_a_torrent_peer_using_ipv4(in_memory_torrent_repository: &Arc) { + let info_hash = AquaticInfoHash([0u8; 20]); + + let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); + let client_port = 8080; + let peer_id = AquaticPeerId([255u8; 20]); + + let peer_using_ipv4 = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(SocketAddr::new(IpAddr::V4(client_ip_v4), client_port)) + .into(); + + in_memory_torrent_repository + .handle_announcement(&info_hash.0.into(), &peer_using_ipv4, None) + .await; + } + + async fn announce_a_new_peer_using_ipv6( + core_config: Arc, + announce_handler: Arc, + whitelist_authorization: Arc, + ) -> Response { + let udp_core_broadcaster = Broadcaster::default(); + let core_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = core_event_bus.sender(); + + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let server_event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = server_event_bus.sender(); + + let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); + let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); + let client_port = 8080; + + let client_socket_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .into(); + + let announce_service = Arc::new(AnnounceService::new( + announce_handler.clone(), + whitelist_authorization.clone(), + udp_core_stats_event_sender.clone(), + )); + + handle_announce( + &announce_service, + client_socket_addr, + server_service_binding, + &request, + &core_config, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn when_the_announce_request_comes_from_a_client_using_ipv6_the_response_should_not_include_peers_using_ipv4() { + let (core_tracker_services, _core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + add_a_torrent_peer_using_ipv4(&core_tracker_services.in_memory_torrent_repository).await; + + let response = announce_a_new_peer_using_ipv6( + core_tracker_services.core_config.clone(), + core_tracker_services.announce_handler.clone(), + core_tracker_services.whitelist_authorization, + ) + .await; + + // The response should not contain the peer using IPV4 + let peers: Option>> = match response { + Response::AnnounceIpv4(announce_response) => Some(announce_response.peers), + _ => None, + }; + let no_ipv4_peers = peers.is_none(); + assert!(no_ipv4_peers); + } + + #[tokio::test] + async fn should_send_the_upd6_announce_event() { + let client_socket_addr = sample_ipv6_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let announce_request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .into(); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Announce { announce_request }, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = + Some(Arc::new(udp_server_stats_event_sender_mock)); + + let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_default_tracker_configuration(); + + handle_announce( + &core_udp_tracker_services.announce_service, + client_socket_addr, + server_service_binding, + &announce_request, + &core_tracker_services.core_config, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + } + + mod from_a_loopback_ip { + use std::future; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{InfoHash as AquaticInfoHash, PeerId as AquaticPeerId}; + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::databases::setup::initialize_database; + use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use bittorrent_udp_tracker_core::connection_cookie::{gen_remote_fingerprint, make}; + use bittorrent_udp_tracker_core::services::announce::AnnounceService; + use bittorrent_udp_tracker_core::{self, event as core_event}; + use mockall::predicate::{self, eq}; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::handlers::handle_announce; + use crate::handlers::tests::{ + sample_cookie_valid_range, sample_issue_time, MockUdpCoreStatsEventSender, MockUdpServerStatsEventSender, + TrackerConfigurationBuilder, + }; + use crate::tests::{announce_events_match, sample_peer}; + + #[tokio::test] + async fn the_peer_ip_should_be_changed_to_the_external_ip_in_the_tracker_configuration() { + let config = Arc::new(TrackerConfigurationBuilder::default().with_external_ip("::126.0.0.1").into()); + + let loopback_ipv4 = Ipv4Addr::LOCALHOST; + let loopback_ipv6 = Ipv6Addr::LOCALHOST; + + let client_ip_v4 = loopback_ipv4; + let client_ip_v6 = loopback_ipv6; + let client_port = 8080; + + let info_hash = AquaticInfoHash([0u8; 20]); + let peer_id = AquaticPeerId([255u8; 20]); + let mut announcement = sample_peer(); + announcement.peer_id = peer_id; + announcement.peer_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7e00, 1)), client_port); + + let client_socket_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); + let mut server_socket_addr = config.udp_trackers.clone().unwrap()[0].bind_address; + if server_socket_addr.port() == 0 { + // Port 0 cannot be use in service binding + server_socket_addr.set_port(6969); + } + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + let server_service_binding_clone = server_service_binding.clone(); + + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = + Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + + let request = AnnounceRequestBuilder::default() + .with_connection_id(make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap()) + .with_info_hash(info_hash) + .with_peer_id(peer_id) + .with_ip_address(client_ip_v4) + .with_port(client_port) + .into(); + + let mut udp_core_stats_event_sender_mock = MockUdpCoreStatsEventSender::new(); + udp_core_stats_event_sender_mock + .expect_send() + .with(predicate::function(move |event| { + let expected_event = core_event::Event::UdpAnnounce { + connection: core_event::ConnectionContext::new( + client_socket_addr, + server_service_binding.clone(), + ), + info_hash: info_hash.into(), + announcement, + }; + + announce_events_match(event, &expected_event) + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_core_stats_event_sender: bittorrent_udp_tracker_core::event::sender::Sender = + Some(Arc::new(udp_core_stats_event_sender_mock)); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding_clone.clone()), + kind: UdpRequestKind::Announce { + announce_request: request, + }, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = + Some(Arc::new(udp_server_stats_event_sender_mock)); + + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + + let core_config = Arc::new(config.core.clone()); + + let announce_service = Arc::new(AnnounceService::new( + announce_handler.clone(), + whitelist_authorization.clone(), + udp_core_stats_event_sender.clone(), + )); + + handle_announce( + &announce_service, + client_socket_addr, + server_service_binding_clone, + &request, + &core_config, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let peers = in_memory_torrent_repository.get_torrent_peers(&info_hash.0.into()).await; + + let external_ip_in_tracker_configuration = core_config.net.external_ip.unwrap(); + + assert!(external_ip_in_tracker_configuration.is_ipv6()); + + // There's a special type of IPv6 addresses that provide compatibility with IPv4. + // The last 32 bits of these addresses represent an IPv4, and are represented like this: + // 1111:2222:3333:4444:5555:6666:1.2.3.4 + // + // ::127.0.0.1 is the IPV6 representation for the IPV4 address 127.0.0.1. + assert_eq!(Ok(peers[0].peer_addr.ip()), "::126.0.0.1".parse()); + } + } + } + } +} diff --git a/packages/udp-tracker-server/src/handlers/connect.rs b/packages/udp-tracker-server/src/handlers/connect.rs new file mode 100644 index 000000000..961189945 --- /dev/null +++ b/packages/udp-tracker-server/src/handlers/connect.rs @@ -0,0 +1,291 @@ +//! UDP tracker connect handler. +use std::net::SocketAddr; +use std::sync::Arc; + +use aquatic_udp_protocol::{ConnectRequest, ConnectResponse, ConnectionId, Response}; +use bittorrent_udp_tracker_core::services::connect::ConnectService; +use torrust_tracker_primitives::service_binding::ServiceBinding; +use tracing::{instrument, Level}; + +use crate::event::{ConnectionContext, Event, UdpRequestKind}; + +/// It handles the `Connect` request. +#[instrument(fields(transaction_id), skip(connect_service, opt_udp_server_stats_event_sender), ret(level = Level::TRACE))] +pub async fn handle_connect( + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request: &ConnectRequest, + connect_service: &Arc, + opt_udp_server_stats_event_sender: &crate::event::sender::Sender, + cookie_issue_time: f64, +) -> Response { + tracing::Span::current().record("transaction_id", request.transaction_id.0.to_string()); + tracing::trace!("handle connect"); + + if let Some(udp_server_stats_event_sender) = opt_udp_server_stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Connect, + }) + .await; + } + + let connection_id = connect_service + .handle_connect(client_socket_addr, server_service_binding, cookie_issue_time) + .await; + + build_response(*request, connection_id) +} + +fn build_response(request: ConnectRequest, connection_id: ConnectionId) -> Response { + let response = ConnectResponse { + transaction_id: request.transaction_id, + connection_id, + }; + + Response::from(response) +} + +#[cfg(test)] +mod tests { + + mod connect_request { + + use std::future; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{ConnectRequest, ConnectResponse, Response, TransactionId}; + use bittorrent_udp_tracker_core::connection_cookie::make; + use bittorrent_udp_tracker_core::event as core_event; + use bittorrent_udp_tracker_core::event::bus::EventBus; + use bittorrent_udp_tracker_core::event::sender::Broadcaster; + use bittorrent_udp_tracker_core::services::connect::ConnectService; + use mockall::predicate::eq; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::handle_connect; + use crate::handlers::tests::{ + sample_ipv4_remote_addr, sample_ipv4_remote_addr_fingerprint, sample_ipv4_socket_address, sample_ipv6_remote_addr, + sample_ipv6_remote_addr_fingerprint, sample_issue_time, MockUdpCoreStatsEventSender, MockUdpServerStatsEventSender, + }; + + fn sample_connect_request() -> ConnectRequest { + ConnectRequest { + transaction_id: TransactionId(0i32.into()), + } + } + + #[tokio::test] + async fn a_connect_response_should_contain_the_same_transaction_id_as_the_connect_request() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let core_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = core_event_bus.sender(); + + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let server_event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = server_event_bus.sender(); + + let request = ConnectRequest { + transaction_id: TransactionId(0i32.into()), + }; + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = handle_connect( + sample_ipv4_remote_addr(), + server_service_binding, + &request, + &connect_service, + &udp_server_stats_event_sender, + sample_issue_time(), + ) + .await; + + assert_eq!( + response, + Response::Connect(ConnectResponse { + connection_id: make(sample_ipv4_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + transaction_id: request.transaction_id + }) + ); + } + + #[tokio::test] + async fn a_connect_response_should_contain_a_new_connection_id() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let core_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = core_event_bus.sender(); + + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let server_event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = server_event_bus.sender(); + + let request = ConnectRequest { + transaction_id: TransactionId(0i32.into()), + }; + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = handle_connect( + sample_ipv4_remote_addr(), + server_service_binding, + &request, + &connect_service, + &udp_server_stats_event_sender, + sample_issue_time(), + ) + .await; + + assert_eq!( + response, + Response::Connect(ConnectResponse { + connection_id: make(sample_ipv4_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + transaction_id: request.transaction_id + }) + ); + } + + #[tokio::test] + async fn a_connect_response_should_contain_a_new_connection_id_ipv6() { + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let udp_core_broadcaster = Broadcaster::default(); + let core_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + + let udp_core_stats_event_sender = core_event_bus.sender(); + + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let server_event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = server_event_bus.sender(); + + let request = ConnectRequest { + transaction_id: TransactionId(0i32.into()), + }; + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + let response = handle_connect( + sample_ipv6_remote_addr(), + server_service_binding, + &request, + &connect_service, + &udp_server_stats_event_sender, + sample_issue_time(), + ) + .await; + + assert_eq!( + response, + Response::Connect(ConnectResponse { + connection_id: make(sample_ipv6_remote_addr_fingerprint(), sample_issue_time()).unwrap(), + transaction_id: request.transaction_id + }) + ); + } + + #[tokio::test] + async fn it_should_send_the_upd4_connect_event_when_a_client_tries_to_connect_using_a_ip4_socket_address() { + let client_socket_addr = sample_ipv4_socket_address(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_core_stats_event_sender_mock = MockUdpCoreStatsEventSender::new(); + udp_core_stats_event_sender_mock + .expect_send() + .with(eq(core_event::Event::UdpConnect { + connection: core_event::ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_core_stats_event_sender: bittorrent_udp_tracker_core::event::sender::Sender = + Some(Arc::new(udp_core_stats_event_sender_mock)); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Connect, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(udp_server_stats_event_sender_mock)); + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + handle_connect( + client_socket_addr, + server_service_binding, + &sample_connect_request(), + &connect_service, + &udp_server_stats_event_sender, + sample_issue_time(), + ) + .await; + } + + #[tokio::test] + async fn it_should_send_the_upd6_connect_event_when_a_client_tries_to_connect_using_a_ip6_socket_address() { + let client_socket_addr = sample_ipv6_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_core_stats_event_sender_mock = MockUdpCoreStatsEventSender::new(); + udp_core_stats_event_sender_mock + .expect_send() + .with(eq(core_event::Event::UdpConnect { + connection: core_event::ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_core_stats_event_sender: bittorrent_udp_tracker_core::event::sender::Sender = + Some(Arc::new(udp_core_stats_event_sender_mock)); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Connect, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = Some(Arc::new(udp_server_stats_event_sender_mock)); + + let connect_service = Arc::new(ConnectService::new(udp_core_stats_event_sender)); + + handle_connect( + client_socket_addr, + server_service_binding, + &sample_connect_request(), + &connect_service, + &udp_server_stats_event_sender, + sample_issue_time(), + ) + .await; + } + } +} diff --git a/packages/udp-tracker-server/src/handlers/error.rs b/packages/udp-tracker-server/src/handlers/error.rs new file mode 100644 index 000000000..7fb4141b2 --- /dev/null +++ b/packages/udp-tracker-server/src/handlers/error.rs @@ -0,0 +1,82 @@ +//! UDP tracker error handling. +use std::net::SocketAddr; +use std::ops::Range; + +use aquatic_udp_protocol::{ErrorResponse, Response, TransactionId}; +use bittorrent_udp_tracker_core::{self, UDP_TRACKER_LOG_TARGET}; +use torrust_tracker_primitives::service_binding::ServiceBinding; +use tracing::{instrument, Level}; +use uuid::Uuid; +use zerocopy::network_endian::I32; + +use crate::error::Error; +use crate::event::{ConnectionContext, Event, UdpRequestKind}; + +#[allow(clippy::too_many_arguments)] +#[instrument(fields(transaction_id), skip(opt_udp_server_stats_event_sender), ret(level = Level::TRACE))] +pub async fn handle_error( + req_kind: Option, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request_id: Uuid, + opt_udp_server_stats_event_sender: &crate::event::sender::Sender, + cookie_valid_range: Range, + error: &Error, + opt_transaction_id: Option, +) -> Response { + tracing::trace!("handle error"); + + let server_socket_addr = server_service_binding.bind_address(); + + log_error(error, client_socket_addr, server_socket_addr, opt_transaction_id, request_id); + + trigger_udp_error_event( + error, + client_socket_addr, + server_service_binding, + opt_udp_server_stats_event_sender, + req_kind, + ) + .await; + + Response::from(ErrorResponse { + transaction_id: opt_transaction_id.unwrap_or(TransactionId(I32::new(0))), + message: error.to_string().into(), + }) +} + +fn log_error( + error: &Error, + client_socket_addr: SocketAddr, + server_socket_addr: SocketAddr, + opt_transaction_id: Option, + request_id: Uuid, +) { + match opt_transaction_id { + Some(transaction_id) => { + let transaction_id = transaction_id.0.to_string(); + tracing::error!(target: UDP_TRACKER_LOG_TARGET, error = %error, %client_socket_addr, %server_socket_addr, %request_id, %transaction_id, "response error"); + } + None => { + tracing::error!(target: UDP_TRACKER_LOG_TARGET, error = %error, %client_socket_addr, %server_socket_addr, %request_id, "response error"); + } + } +} + +async fn trigger_udp_error_event( + error: &Error, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + opt_udp_server_stats_event_sender: &crate::event::sender::Sender, + req_kind: Option, +) { + if let Some(udp_server_stats_event_sender) = opt_udp_server_stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpError { + context: ConnectionContext::new(client_socket_addr, server_service_binding), + kind: req_kind, + error: error.clone().into(), + }) + .await; + } +} diff --git a/packages/udp-tracker-server/src/handlers/mod.rs b/packages/udp-tracker-server/src/handlers/mod.rs new file mode 100644 index 000000000..add576a89 --- /dev/null +++ b/packages/udp-tracker-server/src/handlers/mod.rs @@ -0,0 +1,397 @@ +//! Handlers for the UDP server. +pub mod announce; +pub mod connect; +pub mod error; +pub mod scrape; + +use std::net::SocketAddr; +use std::ops::Range; +use std::sync::Arc; +use std::time::Instant; + +use announce::handle_announce; +use aquatic_udp_protocol::{Request, Response, TransactionId}; +use bittorrent_tracker_core::MAX_SCRAPE_TORRENTS; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use connect::handle_connect; +use error::handle_error; +use scrape::handle_scrape; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_primitives::service_binding::ServiceBinding; +use tracing::{instrument, Level}; +use uuid::Uuid; + +use super::RawRequest; +use crate::container::UdpTrackerServerContainer; +use crate::error::Error; +use crate::event::UdpRequestKind; +use crate::CurrentClock; + +#[derive(Debug, Clone, PartialEq)] +pub struct CookieTimeValues { + pub(super) issue_time: f64, + pub(super) valid_range: Range, +} + +impl CookieTimeValues { + pub(super) fn new(cookie_lifetime: f64) -> Self { + let issue_time = CurrentClock::now().as_secs_f64(); + let expiry_time = issue_time - cookie_lifetime - 1.0; + let tolerance_max_time = issue_time + 1.0; + + Self { + issue_time, + valid_range: expiry_time..tolerance_max_time, + } + } +} + +/// It handles the incoming UDP packets. +/// +/// It's responsible for: +/// +/// - Parsing the incoming packet. +/// - Delegating the request to the correct handler depending on the request type. +/// +/// It will return an `Error` response if the request is invalid. +#[instrument(fields(request_id), skip(udp_request, udp_tracker_core_container, udp_tracker_server_container, cookie_time_values), ret(level = Level::TRACE))] +pub(crate) async fn handle_packet( + udp_request: RawRequest, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + server_service_binding: ServiceBinding, + cookie_time_values: CookieTimeValues, +) -> (Response, Option) { + let request_id = Uuid::new_v4(); + + tracing::Span::current().record("request_id", request_id.to_string()); + tracing::debug!("Handling Packets: {udp_request:?}"); + + let start_time = Instant::now(); + + let (response, opt_req_kind) = + match Request::parse_bytes(&udp_request.payload[..udp_request.payload.len()], MAX_SCRAPE_TORRENTS).map_err(Error::from) { + Ok(request) => match handle_request( + request, + udp_request.from, + server_service_binding.clone(), + udp_tracker_core_container.clone(), + udp_tracker_server_container.clone(), + cookie_time_values.clone(), + ) + .await + { + Ok((response, req_kid)) => return (response, Some(req_kid)), + Err((error, transaction_id, req_kind)) => { + let response = handle_error( + Some(req_kind.clone()), + udp_request.from, + server_service_binding, + request_id, + &udp_tracker_server_container.stats_event_sender, + cookie_time_values.valid_range.clone(), + &error, + Some(transaction_id), + ) + .await; + + (response, Some(req_kind)) + } + }, + Err(e) => { + // The request payload could not be parsed, so we handle it as an error. + + let opt_transaction_id = if let Error::InvalidRequest { request_parse_error } = e.clone() { + request_parse_error.opt_transaction_id + } else { + None + }; + + let response = handle_error( + None, + udp_request.from, + server_service_binding, + request_id, + &udp_tracker_server_container.stats_event_sender, + cookie_time_values.valid_range.clone(), + &e, + opt_transaction_id, + ) + .await; + + (response, None) + } + }; + + let latency = start_time.elapsed(); + tracing::trace!(?latency, "responded"); + + (response, opt_req_kind) +} + +/// It dispatches the request to the correct handler. +/// +/// # Errors +/// +/// If a error happens in the `handle_request` function, it will just return the `ServerError`. +#[instrument(skip( + request, + client_socket_addr, + server_service_binding, + udp_tracker_core_container, + udp_tracker_server_container, + cookie_time_values +))] +pub async fn handle_request( + request: Request, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + cookie_time_values: CookieTimeValues, +) -> Result<(Response, UdpRequestKind), (Error, TransactionId, UdpRequestKind)> { + tracing::trace!("handle request"); + + match request { + Request::Connect(connect_request) => Ok(( + handle_connect( + client_socket_addr, + server_service_binding, + &connect_request, + &udp_tracker_core_container.connect_service, + &udp_tracker_server_container.stats_event_sender, + cookie_time_values.issue_time, + ) + .await, + UdpRequestKind::Connect, + )), + Request::Announce(announce_request) => { + match handle_announce( + &udp_tracker_core_container.announce_service, + client_socket_addr, + server_service_binding, + &announce_request, + &udp_tracker_core_container.tracker_core_container.core_config, + &udp_tracker_server_container.stats_event_sender, + cookie_time_values.valid_range, + ) + .await + { + Ok(response) => Ok((response, UdpRequestKind::Announce { announce_request })), + Err(err) => Err(err), + } + } + Request::Scrape(scrape_request) => { + match handle_scrape( + &udp_tracker_core_container.scrape_service, + client_socket_addr, + server_service_binding, + &scrape_request, + &udp_tracker_server_container.stats_event_sender, + cookie_time_values.valid_range, + ) + .await + { + Ok(response) => Ok((response, UdpRequestKind::Scrape)), + Err(err) => Err(err), + } + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use std::ops::Range; + use std::sync::Arc; + + use bittorrent_tracker_core::announce_handler::AnnounceHandler; + use bittorrent_tracker_core::databases::setup::initialize_database; + use bittorrent_tracker_core::scrape_handler::ScrapeHandler; + use bittorrent_tracker_core::statistics::persisted::downloads::DatabaseDownloadsMetricRepository; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::whitelist; + use bittorrent_tracker_core::whitelist::authorization::WhitelistAuthorization; + use bittorrent_tracker_core::whitelist::repository::in_memory::InMemoryWhitelist; + use bittorrent_udp_tracker_core::connection_cookie::gen_remote_fingerprint; + use bittorrent_udp_tracker_core::event::bus::EventBus; + use bittorrent_udp_tracker_core::event::sender::Broadcaster; + use bittorrent_udp_tracker_core::services::announce::AnnounceService; + use bittorrent_udp_tracker_core::services::scrape::ScrapeService; + use bittorrent_udp_tracker_core::{self, event as core_event}; + use futures::future::BoxFuture; + use mockall::mock; + use torrust_tracker_configuration::{Configuration, Core}; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_events::sender::SendError; + use torrust_tracker_test_helpers::configuration; + + use crate::event as server_event; + + pub(crate) struct CoreTrackerServices { + pub core_config: Arc, + pub announce_handler: Arc, + pub in_memory_torrent_repository: Arc, + pub in_memory_whitelist: Arc, + pub whitelist_authorization: Arc, + } + + pub(crate) struct CoreUdpTrackerServices { + pub announce_service: Arc, + pub scrape_service: Arc, + } + + pub(crate) struct ServerUdpTrackerServices { + pub udp_server_stats_event_sender: crate::event::sender::Sender, + } + + fn default_testing_tracker_configuration() -> Configuration { + configuration::ephemeral() + } + + pub(crate) fn initialize_core_tracker_services_for_default_tracker_configuration( + ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { + initialize_core_tracker_services(&default_testing_tracker_configuration()) + } + + pub(crate) fn initialize_core_tracker_services_for_public_tracker( + ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_public()) + } + + pub(crate) fn initialize_core_tracker_services_for_listed_tracker( + ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { + initialize_core_tracker_services(&configuration::ephemeral_listed()) + } + + fn initialize_core_tracker_services( + config: &Configuration, + ) -> (CoreTrackerServices, CoreUdpTrackerServices, ServerUdpTrackerServices) { + let core_config = Arc::new(config.core.clone()); + let database = initialize_database(&config.core); + let in_memory_whitelist = Arc::new(InMemoryWhitelist::default()); + let whitelist_authorization = Arc::new(WhitelistAuthorization::new(&config.core, &in_memory_whitelist.clone())); + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + let db_downloads_metric_repository = Arc::new(DatabaseDownloadsMetricRepository::new(&database)); + let announce_handler = Arc::new(AnnounceHandler::new( + &config.core, + &whitelist_authorization, + &in_memory_torrent_repository, + &db_downloads_metric_repository, + )); + let scrape_handler = Arc::new(ScrapeHandler::new(&whitelist_authorization, &in_memory_torrent_repository)); + + let udp_core_broadcaster = Broadcaster::default(); + let core_event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_core_broadcaster.clone())); + let udp_core_stats_event_sender = core_event_bus.sender(); + + let udp_server_broadcaster = crate::event::sender::Broadcaster::default(); + let server_event_bus = Arc::new(crate::event::bus::EventBus::new( + SenderStatus::Disabled, + udp_server_broadcaster.clone(), + )); + + let udp_server_stats_event_sender = server_event_bus.sender(); + + let announce_service = Arc::new(AnnounceService::new( + announce_handler.clone(), + whitelist_authorization.clone(), + udp_core_stats_event_sender.clone(), + )); + + let scrape_service = Arc::new(ScrapeService::new( + scrape_handler.clone(), + udp_core_stats_event_sender.clone(), + )); + + ( + CoreTrackerServices { + core_config, + announce_handler, + in_memory_torrent_repository, + in_memory_whitelist, + whitelist_authorization, + }, + CoreUdpTrackerServices { + announce_service, + scrape_service, + }, + ServerUdpTrackerServices { + udp_server_stats_event_sender, + }, + ) + } + + pub(crate) fn sample_ipv4_remote_addr() -> SocketAddr { + sample_ipv4_socket_address() + } + + pub(crate) fn sample_ipv4_remote_addr_fingerprint() -> u64 { + gen_remote_fingerprint(&sample_ipv4_socket_address()) + } + + pub(crate) fn sample_ipv6_remote_addr() -> SocketAddr { + sample_ipv6_socket_address() + } + + pub(crate) fn sample_ipv6_remote_addr_fingerprint() -> u64 { + gen_remote_fingerprint(&sample_ipv6_socket_address()) + } + + pub(crate) fn sample_ipv4_socket_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080) + } + + fn sample_ipv6_socket_address() -> SocketAddr { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080) + } + + pub(crate) fn sample_issue_time() -> f64 { + 1_000_000_000_f64 + } + + pub(crate) fn sample_cookie_valid_range() -> Range { + sample_issue_time() - 10.0..sample_issue_time() + 10.0 + } + + pub(crate) struct TrackerConfigurationBuilder { + configuration: Configuration, + } + + impl TrackerConfigurationBuilder { + pub fn default() -> TrackerConfigurationBuilder { + let default_configuration = default_testing_tracker_configuration(); + TrackerConfigurationBuilder { + configuration: default_configuration, + } + } + + pub fn with_external_ip(mut self, external_ip: &str) -> Self { + self.configuration.core.net.external_ip = Some(external_ip.to_owned().parse().expect("valid IP address")); + self + } + + pub fn into(self) -> Configuration { + self.configuration + } + } + + mock! { + pub(crate) UdpCoreStatsEventSender {} + impl torrust_tracker_events::sender::Sender for UdpCoreStatsEventSender { + type Event = core_event::Event; + + fn send(&self, event: core_event::Event) -> BoxFuture<'static,Option > > > ; + } + } + + mock! { + pub(crate) UdpServerStatsEventSender {} + impl torrust_tracker_events::sender::Sender for UdpServerStatsEventSender { + type Event = server_event::Event; + + fn send(&self, event: server_event::Event) -> BoxFuture<'static,Option > > > ; + } + } +} diff --git a/packages/udp-tracker-server/src/handlers/scrape.rs b/packages/udp-tracker-server/src/handlers/scrape.rs new file mode 100644 index 000000000..8bac05c1e --- /dev/null +++ b/packages/udp-tracker-server/src/handlers/scrape.rs @@ -0,0 +1,461 @@ +//! UDP tracker scrape handler. +use std::net::SocketAddr; +use std::ops::Range; +use std::sync::Arc; + +use aquatic_udp_protocol::{ + NumberOfDownloads, NumberOfPeers, Response, ScrapeRequest, ScrapeResponse, TorrentScrapeStatistics, TransactionId, +}; +use bittorrent_udp_tracker_core::services::scrape::ScrapeService; +use bittorrent_udp_tracker_core::{self}; +use torrust_tracker_primitives::core::ScrapeData; +use torrust_tracker_primitives::service_binding::ServiceBinding; +use tracing::{instrument, Level}; +use zerocopy::network_endian::I32; + +use crate::error::Error; +use crate::event::{ConnectionContext, Event, UdpRequestKind}; + +/// It handles the `Scrape` request. +/// +/// # Errors +/// +/// This function does not ever return an error. +#[instrument(fields(transaction_id, connection_id), skip(scrape_service, opt_udp_server_stats_event_sender), ret(level = Level::TRACE))] +pub async fn handle_scrape( + scrape_service: &Arc, + client_socket_addr: SocketAddr, + server_service_binding: ServiceBinding, + request: &ScrapeRequest, + opt_udp_server_stats_event_sender: &crate::event::sender::Sender, + cookie_valid_range: Range, +) -> Result { + tracing::Span::current() + .record("transaction_id", request.transaction_id.0.to_string()) + .record("connection_id", request.connection_id.0.to_string()); + + tracing::trace!("handle scrape"); + + if let Some(udp_server_stats_event_sender) = opt_udp_server_stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Scrape, + }) + .await; + } + + let scrape_data = scrape_service + .handle_scrape(client_socket_addr, server_service_binding, request, cookie_valid_range) + .await + .map_err(|e| (e.into(), request.transaction_id, UdpRequestKind::Scrape))?; + + Ok(build_response(request, &scrape_data)) +} + +fn build_response(request: &ScrapeRequest, scrape_data: &ScrapeData) -> Response { + let mut torrent_stats: Vec = Vec::new(); + + for file in &scrape_data.files { + let swarm_metadata = file.1; + + #[allow(clippy::cast_possible_truncation)] + let scrape_entry = { + TorrentScrapeStatistics { + seeders: NumberOfPeers(I32::new(i64::from(swarm_metadata.complete) as i32)), + completed: NumberOfDownloads(I32::new(i64::from(swarm_metadata.downloaded) as i32)), + leechers: NumberOfPeers(I32::new(i64::from(swarm_metadata.incomplete) as i32)), + } + }; + + torrent_stats.push(scrape_entry); + } + + let response = ScrapeResponse { + transaction_id: request.transaction_id, + torrent_stats, + }; + + Response::from(response) +} + +#[cfg(test)] +mod tests { + + mod scrape_request { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::sync::Arc; + + use aquatic_udp_protocol::{ + InfoHash, NumberOfDownloads, NumberOfPeers, PeerId, Response, ScrapeRequest, ScrapeResponse, TorrentScrapeStatistics, + TransactionId, + }; + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_udp_tracker_core::connection_cookie::{gen_remote_fingerprint, make}; + use torrust_tracker_events::bus::SenderStatus; + use torrust_tracker_primitives::peer::fixture::PeerBuilder; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::bus::EventBus; + use crate::event::sender::Broadcaster; + use crate::handlers::handle_scrape; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_public_tracker, sample_cookie_valid_range, sample_ipv4_remote_addr, + sample_issue_time, CoreTrackerServices, CoreUdpTrackerServices, + }; + + fn zeroed_torrent_statistics() -> TorrentScrapeStatistics { + TorrentScrapeStatistics { + seeders: NumberOfPeers(0.into()), + completed: NumberOfDownloads(0.into()), + leechers: NumberOfPeers(0.into()), + } + } + + #[tokio::test] + async fn should_return_no_stats_when_the_tracker_does_not_have_any_torrent() { + let (_core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let info_hash = InfoHash([0u8; 20]); + let info_hashes = vec![info_hash]; + + let request = ScrapeRequest { + connection_id: make(gen_remote_fingerprint(&client_socket_addr), sample_issue_time()).unwrap(), + transaction_id: TransactionId(0i32.into()), + info_hashes, + }; + + let response = handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &request, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + + let expected_torrent_stats = vec![zeroed_torrent_statistics()]; + + assert_eq!( + response, + Response::from(ScrapeResponse { + transaction_id: request.transaction_id, + torrent_stats: expected_torrent_stats + }) + ); + } + + async fn add_a_seeder( + in_memory_torrent_repository: Arc, + remote_addr: &SocketAddr, + info_hash: &InfoHash, + ) { + let peer_id = PeerId([255u8; 20]); + + let peer = PeerBuilder::default() + .with_peer_id(&peer_id) + .with_peer_address(*remote_addr) + .with_bytes_left_to_download(0) + .into(); + + in_memory_torrent_repository + .handle_announcement(&info_hash.0.into(), &peer, None) + .await; + } + + fn build_scrape_request(remote_addr: &SocketAddr, info_hash: &InfoHash) -> ScrapeRequest { + let info_hashes = vec![*info_hash]; + + ScrapeRequest { + connection_id: make(gen_remote_fingerprint(remote_addr), sample_issue_time()).unwrap(), + transaction_id: TransactionId::new(0i32), + info_hashes, + } + } + + async fn add_a_sample_seeder_and_scrape( + core_tracker_services: Arc, + core_udp_tracker_services: Arc, + ) -> Response { + let udp_server_broadcaster = Broadcaster::default(); + let event_bus = Arc::new(EventBus::new(SenderStatus::Disabled, udp_server_broadcaster.clone())); + + let udp_server_stats_event_sender = event_bus.sender(); + + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let info_hash = InfoHash([0u8; 20]); + + add_a_seeder( + core_tracker_services.in_memory_torrent_repository.clone(), + &client_socket_addr, + &info_hash, + ) + .await; + + let request = build_scrape_request(&client_socket_addr, &info_hash); + + handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &request, + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap() + } + + fn match_scrape_response(response: Response) -> Option { + match response { + Response::Scrape(scrape_response) => Some(scrape_response), + _ => None, + } + } + + mod with_a_public_tracker { + use aquatic_udp_protocol::{NumberOfDownloads, NumberOfPeers, TorrentScrapeStatistics}; + + use crate::handlers::scrape::tests::scrape_request::{add_a_sample_seeder_and_scrape, match_scrape_response}; + use crate::handlers::tests::initialize_core_tracker_services_for_public_tracker; + + #[tokio::test] + async fn should_return_torrent_statistics_when_the_tracker_has_the_requested_torrent() { + let (core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_public_tracker(); + + let torrent_stats = match_scrape_response( + add_a_sample_seeder_and_scrape(core_tracker_services.into(), core_udp_tracker_services.into()).await, + ); + + let expected_torrent_stats = vec![TorrentScrapeStatistics { + seeders: NumberOfPeers(1.into()), + completed: NumberOfDownloads(0.into()), + leechers: NumberOfPeers(0.into()), + }]; + + assert_eq!(torrent_stats.unwrap().torrent_stats, expected_torrent_stats); + } + } + + mod with_a_whitelisted_tracker { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use aquatic_udp_protocol::{InfoHash, NumberOfDownloads, NumberOfPeers, TorrentScrapeStatistics}; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::handlers::handle_scrape; + use crate::handlers::scrape::tests::scrape_request::{ + add_a_seeder, build_scrape_request, match_scrape_response, zeroed_torrent_statistics, + }; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_listed_tracker, sample_cookie_valid_range, sample_ipv4_remote_addr, + }; + + #[tokio::test] + async fn should_return_the_torrent_statistics_when_the_requested_torrent_is_whitelisted() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_listed_tracker(); + + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let info_hash = InfoHash([0u8; 20]); + + add_a_seeder( + core_tracker_services.in_memory_torrent_repository.clone(), + &client_socket_addr, + &info_hash, + ) + .await; + + core_tracker_services.in_memory_whitelist.add(&info_hash.0.into()).await; + + let request = build_scrape_request(&client_socket_addr, &info_hash); + + let torrent_stats = match_scrape_response( + handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &request, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(), + ) + .unwrap(); + + let expected_torrent_stats = vec![TorrentScrapeStatistics { + seeders: NumberOfPeers(1.into()), + completed: NumberOfDownloads(0.into()), + leechers: NumberOfPeers(0.into()), + }]; + + assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); + } + + #[tokio::test] + async fn should_return_zeroed_statistics_when_the_requested_torrent_is_not_whitelisted() { + let (core_tracker_services, core_udp_tracker_services, server_udp_tracker_services) = + initialize_core_tracker_services_for_listed_tracker(); + + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let info_hash = InfoHash([0u8; 20]); + + add_a_seeder( + core_tracker_services.in_memory_torrent_repository.clone(), + &client_socket_addr, + &info_hash, + ) + .await; + + let request = build_scrape_request(&client_socket_addr, &info_hash); + + let torrent_stats = match_scrape_response( + handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &request, + &server_udp_tracker_services.udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(), + ) + .unwrap(); + + let expected_torrent_stats = vec![zeroed_torrent_statistics()]; + + assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); + } + } + + fn sample_scrape_request(remote_addr: &SocketAddr) -> ScrapeRequest { + let info_hash = InfoHash([0u8; 20]); + let info_hashes = vec![info_hash]; + + ScrapeRequest { + connection_id: make(gen_remote_fingerprint(remote_addr), sample_issue_time()).unwrap(), + transaction_id: TransactionId(0i32.into()), + info_hashes, + } + } + + mod using_ipv4 { + use std::future; + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use mockall::predicate::eq; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::sample_scrape_request; + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::handle_scrape; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_default_tracker_configuration, sample_cookie_valid_range, + sample_ipv4_remote_addr, MockUdpServerStatsEventSender, + }; + + #[tokio::test] + async fn should_send_the_upd4_scrape_event() { + let client_socket_addr = sample_ipv4_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Scrape, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = + Some(Arc::new(udp_server_stats_event_sender_mock)); + + let (_core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_default_tracker_configuration(); + + handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &sample_scrape_request(&client_socket_addr), + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + } + } + + mod using_ipv6 { + use std::future; + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + use std::sync::Arc; + + use mockall::predicate::eq; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use super::sample_scrape_request; + use crate::event::{ConnectionContext, Event, UdpRequestKind}; + use crate::handlers::handle_scrape; + use crate::handlers::tests::{ + initialize_core_tracker_services_for_default_tracker_configuration, sample_cookie_valid_range, + sample_ipv6_remote_addr, MockUdpServerStatsEventSender, + }; + + #[tokio::test] + async fn should_send_the_upd6_scrape_event() { + let client_socket_addr = sample_ipv6_remote_addr(); + let server_socket_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969); + let server_service_binding = ServiceBinding::new(Protocol::UDP, server_socket_addr).unwrap(); + + let mut udp_server_stats_event_sender_mock = MockUdpServerStatsEventSender::new(); + udp_server_stats_event_sender_mock + .expect_send() + .with(eq(Event::UdpRequestAccepted { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + kind: UdpRequestKind::Scrape, + })) + .times(1) + .returning(|_| Box::pin(future::ready(Some(Ok(1))))); + let udp_server_stats_event_sender: crate::event::sender::Sender = + Some(Arc::new(udp_server_stats_event_sender_mock)); + + let (_core_tracker_services, core_udp_tracker_services, _server_udp_tracker_services) = + initialize_core_tracker_services_for_default_tracker_configuration(); + + handle_scrape( + &core_udp_tracker_services.scrape_service, + client_socket_addr, + server_service_binding, + &sample_scrape_request(&client_socket_addr), + &udp_server_stats_event_sender, + sample_cookie_valid_range(), + ) + .await + .unwrap(); + } + } + } +} diff --git a/src/servers/udp/mod.rs b/packages/udp-tracker-server/src/lib.rs similarity index 93% rename from src/servers/udp/mod.rs rename to packages/udp-tracker-server/src/lib.rs index 91b19a91d..58a3830e1 100644 --- a/src/servers/udp/mod.rs +++ b/packages/udp-tracker-server/src/lib.rs @@ -52,8 +52,7 @@ //! is designed to be as simple as possible. It uses a single UDP port and //! supports only three types of requests: `Connect`, `Announce` and `Scrape`. //! -//! Request are parsed from UDP packets using the [`aquatic_udp_protocol`](https://crates.io/crates/aquatic_udp_protocol) -//! crate and then handled by the [`Tracker`](crate::core::Tracker) struct. +//! Request are parsed from UDP packets using the [`aquatic_udp_protocol`](https://crates.io/crates/aquatic_udp_protocol). //! And then the response is also build using the [`aquatic_udp_protocol`](https://crates.io/crates/aquatic_udp_protocol) //! and converted to a UDP packet. //! @@ -106,7 +105,7 @@ //! connection ID = hash(client IP + current time slot + secret seed) //! ``` //! -//! The BEP-15 recommends a two-minute time slot. Refer to [`connection_cookie`] +//! The BEP-15 recommends a two-minute time slot. Refer to [`connection_cookie`](bittorrent_udp_tracker_core::connection_cookie) //! for more information about the connection ID generation with this method. //! //! #### Connect Request @@ -342,7 +341,7 @@ //! > packet. //! //! We are using a wrapper struct for the aquatic [`AnnounceRequest`](aquatic_udp_protocol::request::AnnounceRequest) -//! struct, because we have our internal [`InfoHash`](torrust_tracker_primitives::info_hash::InfoHash) +//! struct, because we have our internal [`InfoHash`](bittorrent_primitives::info_hash::InfoHash) //! struct. //! //! ```text @@ -476,7 +475,7 @@ //! //! > **NOTICE**: up to about 74 torrents can be scraped at once. A full scrape //! > can't be done with this protocol. This is a limitation of the UDP protocol. -//! > Defined with a hardcoded const [`MAX_SCRAPE_TORRENTS`](crate::shared::bit_torrent::common::MAX_SCRAPE_TORRENTS). +//! > Defined with a hardcoded const [`MAX_SCRAPE_TORRENTS`](torrust_udp_tracker_server::MAX_SCRAPE_TORRENTS). //! > Refer to [issue 262](https://github.com/torrust/torrust-tracker/issues/262) //! > for more information about this limitation. //! @@ -635,17 +634,32 @@ //! documentation by [Arvid Norberg](https://github.com/arvidn) was very //! supportive in the development of this documentation. Some descriptions were //! taken from the [libtorrent](https://www.rasterbar.com/products/libtorrent/udp_tracker_protocol.html). - -use std::net::SocketAddr; - -pub mod connection_cookie; +pub mod banning; +pub mod container; +pub mod environment; pub mod error; +pub mod event; pub mod handlers; -pub mod logging; -pub mod peer_builder; pub mod server; +pub mod statistics; + +use std::net::SocketAddr; + +use torrust_tracker_clock::clock; + +/// The maximum number of bytes in a UDP packet. +pub const MAX_PACKET_SIZE: usize = 1496; -pub const UDP_TRACKER_LOG_TARGET: &str = "UDP TRACKER"; +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; /// Number of bytes. pub type Bytes = u64; @@ -660,3 +674,57 @@ pub struct RawRequest { payload: Vec, from: SocketAddr, } + +#[cfg(test)] +pub(crate) mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; + use bittorrent_udp_tracker_core::event::Event; + use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; + + pub fn sample_peer() -> peer::Peer { + peer::Peer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), + uploaded: NumberOfBytes::new(0), + downloaded: NumberOfBytes::new(0), + left: NumberOfBytes::new(0), + event: AnnounceEvent::Started, + } + } + + #[must_use] + pub fn announce_events_match(event: &Event, expected_event: &Event) -> bool { + match (event, expected_event) { + ( + Event::UdpAnnounce { + connection, + info_hash, + announcement, + }, + Event::UdpAnnounce { + connection: expected_connection, + info_hash: expected_info_hash, + announcement: expected_announcement, + }, + ) => { + *connection == *expected_connection + && *info_hash == *expected_info_hash + && announcement.peer_id == expected_announcement.peer_id + && announcement.peer_addr == expected_announcement.peer_addr + // Events can't be compared due to the `updated` field. + // The `announcement.uploaded` contains the current time + // when the test is executed. + // todo: mock time + //&& announcement.updated == expected_announcement.updated + && announcement.uploaded == expected_announcement.uploaded + && announcement.downloaded == expected_announcement.downloaded + && announcement.left == expected_announcement.left + && announcement.event == expected_announcement.event + } + _ => false, + } + } +} diff --git a/src/servers/udp/server/bound_socket.rs b/packages/udp-tracker-server/src/server/bound_socket.rs similarity index 81% rename from src/servers/udp/server/bound_socket.rs rename to packages/udp-tracker-server/src/server/bound_socket.rs index 658589aa6..6b81545d2 100644 --- a/src/servers/udp/server/bound_socket.rs +++ b/packages/udp-tracker-server/src/server/bound_socket.rs @@ -2,10 +2,10 @@ use std::fmt::Debug; use std::net::SocketAddr; use std::ops::Deref; +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use url::Url; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; - /// Wrapper for Tokio [`UdpSocket`][`tokio::net::UdpSocket`] that is bound to a particular socket. pub struct BoundSocket { socket: tokio::net::UdpSocket, @@ -48,6 +48,15 @@ impl BoundSocket { pub fn url(&self) -> Url { Url::parse(&format!("udp://{}", self.address())).expect("UDP socket address should be valid") } + + /// # Panics + /// + /// It should never panic because the conversion to a [`ServiceBinding`] + /// is infallible. + #[must_use] + pub fn service_binding(&self) -> ServiceBinding { + ServiceBinding::new(Protocol::UDP, self.address()).expect("Conversion to ServiceBinding should not fail") + } } impl Deref for BoundSocket { diff --git a/packages/udp-tracker-server/src/server/launcher.rs b/packages/udp-tracker-server/src/server/launcher.rs new file mode 100644 index 000000000..a514921cc --- /dev/null +++ b/packages/udp-tracker-server/src/server/launcher.rs @@ -0,0 +1,253 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use bittorrent_tracker_client::udp::client::check; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use bittorrent_udp_tracker_core::{self, UDP_TRACKER_LOG_TARGET}; +use derive_more::Constructor; +use futures_util::StreamExt; +use tokio::select; +use tokio::sync::oneshot; +use tokio::time::interval; +use torrust_server_lib::logging::STARTED_ON; +use torrust_server_lib::registar::ServiceHealthCheckJob; +use torrust_server_lib::signals::{shutdown_signal_with_message, Halted, Started}; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; +use tracing::instrument; + +use super::request_buffer::ActiveRequests; +use crate::container::UdpTrackerServerContainer; +use crate::event::{ConnectionContext, Event}; +use crate::server::bound_socket::BoundSocket; +use crate::server::processor::Processor; +use crate::server::receiver::Receiver; + +const IP_BANS_RESET_INTERVAL_IN_SECS: u64 = 3600 * 24; + +const TYPE_STRING: &str = "udp_tracker"; +/// A UDP server instance launcher. +#[derive(Constructor)] +pub struct Launcher; + +impl Launcher { + /// It starts the UDP server instance with graceful shutdown. + /// + /// # Panics + /// + /// It panics if unable to bind to udp socket, and get the address from the udp socket. + /// It panics if unable to send address of socket. + /// It panics if the udp server is loaded when the tracker is private. + #[instrument(skip(udp_tracker_core_container, udp_tracker_server_container, bind_to, tx_start, rx_halt))] + pub async fn run_with_graceful_shutdown( + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + bind_to: SocketAddr, + cookie_lifetime: Duration, + tx_start: oneshot::Sender, + rx_halt: oneshot::Receiver, + ) { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Starting on: {bind_to}"); + + if udp_tracker_core_container.tracker_core_container.core_config.private { + tracing::error!("udp services cannot be used for private trackers"); + panic!("it should not use udp if using authentication"); + } + + let socket = tokio::time::timeout(Duration::from_millis(5000), BoundSocket::new(bind_to)) + .await + .expect("it should bind to the socket within five seconds"); + + let bound_socket = match socket { + Ok(socket) => socket, + Err(e) => { + tracing::error!(target: UDP_TRACKER_LOG_TARGET, addr = %bind_to, err = %e, "Udp::run_with_graceful_shutdown panic! (error when building socket)" ); + panic!("could not bind to socket!"); + } + }; + + let service_binding = bound_socket.service_binding().clone(); + let address = bound_socket.address(); + let local_udp_url = bound_socket.url().to_string(); + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "{STARTED_ON}: {local_udp_url}"); + + let receiver = Receiver::new(bound_socket.into()); + + tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (spawning main loop)"); + + let running = { + let local_addr = local_udp_url.clone(); + tokio::task::spawn(async move { + tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_with_graceful_shutdown::task (listening...)"); + let () = Self::run_udp_server_main( + receiver, + udp_tracker_core_container, + udp_tracker_server_container, + cookie_lifetime, + ) + .await; + }) + }; + + tx_start + .send(Started { + service_binding, + address, + }) + .expect("the UDP Tracker service should not be dropped"); + + tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (started)"); + + let stop = running.abort_handle(); + + let halt_task = tokio::task::spawn(shutdown_signal_with_message( + rx_halt, + format!("Halting UDP Service Bound to Socket: {address}"), + )); + + select! { + _ = running => { tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (stopped)"); }, + _ = halt_task => { tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (halting)"); } + } + stop.abort(); + + tokio::task::yield_now().await; // lets allow the other threads to complete. + } + + #[must_use] + #[instrument(skip(service_binding))] + pub fn check(service_binding: &ServiceBinding) -> ServiceHealthCheckJob { + let info = format!("checking the udp tracker health check at: {}", service_binding.bind_address()); + + let service_binding_clone = service_binding.clone(); + + let job = tokio::spawn(async move { check(&service_binding_clone).await }); + + ServiceHealthCheckJob::new(service_binding.clone(), info, TYPE_STRING.to_string(), job) + } + + #[instrument(skip(receiver, udp_tracker_core_container, udp_tracker_server_container))] + async fn run_udp_server_main( + mut receiver: Receiver, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + cookie_lifetime: Duration, + ) { + let active_requests = &mut ActiveRequests::default(); + + let server_socket_addr = receiver.bound_socket_address(); + + let server_service_binding = + ServiceBinding::new(Protocol::UDP, server_socket_addr).expect("Bound socket to service binding should not fail"); + + let local_addr = server_service_binding.clone().to_string(); + + let cookie_lifetime = cookie_lifetime.as_secs_f64(); + + let ban_cleaner = udp_tracker_core_container.ban_service.clone(); + + tokio::spawn(async move { + let mut cleaner_interval = interval(Duration::from_secs(IP_BANS_RESET_INTERVAL_IN_SECS)); + + cleaner_interval.tick().await; + + loop { + cleaner_interval.tick().await; + ban_cleaner.write().await.reset_bans(); + } + }); + + loop { + let server_service_binding = + ServiceBinding::new(Protocol::UDP, server_socket_addr).expect("Bound socket to service binding should not fail"); + + if let Some(req) = { + tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server (wait for request)"); + receiver.next().await + } { + tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server::loop (in)"); + + let req = match req { + Ok(req) => req, + Err(e) => { + if e.kind() == std::io::ErrorKind::Interrupted { + tracing::warn!(target: UDP_TRACKER_LOG_TARGET, local_addr, err = %e, "Udp::run_udp_server::loop (interrupted)"); + return; + } + tracing::error!(target: UDP_TRACKER_LOG_TARGET, local_addr, err = %e, "Udp::run_udp_server::loop break: (got error)"); + break; + } + }; + + let client_socket_addr = req.from; + + if let Some(udp_server_stats_event_sender) = udp_tracker_server_container.stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestReceived { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + }) + .await; + } + + if udp_tracker_core_container.ban_service.read().await.is_banned(&req.from.ip()) { + tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server::loop continue: (banned ip)"); + + if let Some(udp_server_stats_event_sender) = udp_tracker_server_container.stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestBanned { + context: ConnectionContext::new(client_socket_addr, server_service_binding.clone()), + }) + .await; + } + + continue; + } + + let processor = Processor::new( + receiver.socket.clone(), + udp_tracker_core_container.clone(), + udp_tracker_server_container.clone(), + cookie_lifetime, + ); + + /* We spawn the new task even if the active requests buffer is + full. This could seem counterintuitive because we are accepting + more request and consuming more memory even if the server is + already busy. However, we "force_push" the new tasks in the + buffer. That means, in the worst scenario we will abort a + running task to make place for the new task. + + Once concern could be to reach an starvation point were we are + only adding and removing tasks without given them the chance to + finish. However, the buffer is yielding before aborting one + tasks, giving it the chance to finish. */ + let abort_handle: tokio::task::AbortHandle = tokio::task::spawn(processor.process_request(req)).abort_handle(); + + if abort_handle.is_finished() { + continue; + } + + let old_request_aborted = active_requests.force_push(abort_handle, &local_addr).await; + + if old_request_aborted { + // Evicted task from active requests buffer was aborted. + + if let Some(udp_server_stats_event_sender) = udp_tracker_server_container.stats_event_sender.as_deref() { + udp_server_stats_event_sender + .send(Event::UdpRequestAborted { + context: ConnectionContext::new(client_socket_addr, server_service_binding), + }) + .await; + } + } + } else { + tokio::task::yield_now().await; + + // the request iterator returned `None`. + tracing::error!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server breaking: (ran dry, should not happen in production!)"); + break; + } + } + } +} diff --git a/src/servers/udp/server/mod.rs b/packages/udp-tracker-server/src/server/mod.rs similarity index 69% rename from src/servers/udp/server/mod.rs rename to packages/udp-tracker-server/src/server/mod.rs index d81624cb2..f70e28b27 100644 --- a/src/servers/udp/server/mod.rs +++ b/packages/udp-tracker-server/src/server/mod.rs @@ -57,17 +57,40 @@ mod tests { use std::sync::Arc; use std::time::Duration; + use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; + use torrust_server_lib::registar::Registar; + use torrust_tracker_configuration::{logging, Configuration}; use torrust_tracker_test_helpers::configuration::ephemeral_public; use super::spawner::Spawner; use super::Server; - use crate::bootstrap::app::initialize_with_configuration; - use crate::servers::registar::Registar; + use crate::container::UdpTrackerServerContainer; + + fn initialize_global_services(configuration: &Configuration) { + initialize_static(); + logging::setup(&configuration.logging); + } + + fn initialize_static() { + torrust_tracker_clock::initialize_static(); + bittorrent_udp_tracker_core::initialize_static(); + } #[tokio::test] async fn it_should_be_able_to_start_and_stop() { let cfg = Arc::new(ephemeral_public()); - let tracker = initialize_with_configuration(&cfg); + let core_config = Arc::new(cfg.core.clone()); + let udp_tracker_config = Arc::new( + cfg.udp_trackers + .clone() + .expect("no UDP services array config provided") + .first() + .expect("no UDP test service config provided") + .clone(), + ); + + initialize_global_services(&cfg); + let udp_trackers = cfg.udp_trackers.clone().expect("missing UDP trackers configuration"); let config = &udp_trackers[0]; let bind_to = config.bind_address; @@ -75,8 +98,16 @@ mod tests { let stopped = Server::new(Spawner::new(bind_to)); + let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config); + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); + let started = stopped - .start(tracker, register.give_form()) + .start( + udp_tracker_core_container, + udp_tracker_server_container, + register.give_form(), + config.cookie_lifetime, + ) .await .expect("it should start the server"); @@ -90,15 +121,33 @@ mod tests { #[tokio::test] async fn it_should_be_able_to_start_and_stop_with_wait() { let cfg = Arc::new(ephemeral_public()); - let tracker = initialize_with_configuration(&cfg); - let config = &cfg.udp_trackers.as_ref().unwrap().first().unwrap(); - let bind_to = config.bind_address; + let core_config = Arc::new(cfg.core.clone()); + let udp_tracker_config = Arc::new( + cfg.udp_trackers + .clone() + .expect("no UDP services array config provided") + .first() + .expect("no UDP test service config provided") + .clone(), + ); + + initialize_global_services(&cfg); + + let bind_to = udp_tracker_config.bind_address; let register = &Registar::default(); let stopped = Server::new(Spawner::new(bind_to)); + let udp_tracker_core_container = UdpTrackerCoreContainer::initialize(&core_config, &udp_tracker_config); + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); + let started = stopped - .start(tracker, register.give_form()) + .start( + udp_tracker_core_container, + udp_tracker_server_container, + register.give_form(), + udp_tracker_config.cookie_lifetime, + ) .await .expect("it should start the server"); diff --git a/packages/udp-tracker-server/src/server/processor.rs b/packages/udp-tracker-server/src/server/processor.rs new file mode 100644 index 000000000..dd6ba633d --- /dev/null +++ b/packages/udp-tracker-server/src/server/processor.rs @@ -0,0 +1,144 @@ +use std::io::Cursor; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use aquatic_udp_protocol::Response; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use bittorrent_udp_tracker_core::{self}; +use tokio::time::Instant; +use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; +use tracing::{instrument, Level}; + +use super::bound_socket::BoundSocket; +use crate::container::UdpTrackerServerContainer; +use crate::event::{self, ConnectionContext, Event, UdpRequestKind}; +use crate::handlers::CookieTimeValues; +use crate::{handlers, RawRequest}; + +pub struct Processor { + socket: Arc, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + cookie_lifetime: f64, + server_service_binding: ServiceBinding, +} + +impl Processor { + /// # Panics + /// + /// It will panic if a bound socket address port is 0. It should never + /// happen. + pub fn new( + socket: Arc, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + cookie_lifetime: f64, + ) -> Self { + let server_service_binding = + ServiceBinding::new(Protocol::UDP, socket.address()).expect("Bound socket port should't be 0"); + + Self { + socket, + udp_tracker_core_container, + udp_tracker_server_container, + cookie_lifetime, + server_service_binding, + } + } + + #[instrument(skip(self, request))] + pub async fn process_request(self, request: RawRequest) { + let client_socket_addr = request.from; + + let start_time = Instant::now(); + + let (response, opt_req_kind) = handlers::handle_packet( + request, + self.udp_tracker_core_container.clone(), + self.udp_tracker_server_container.clone(), + self.server_service_binding.clone(), + CookieTimeValues::new(self.cookie_lifetime), + ) + .await; + + let elapsed_time = start_time.elapsed(); + + self.send_response(client_socket_addr, response, opt_req_kind, elapsed_time) + .await; + } + + #[instrument(skip(self))] + async fn send_response( + self, + client_socket_addr: SocketAddr, + response: Response, + opt_req_kind: Option, + req_processing_time: Duration, + ) { + tracing::debug!("send response"); + + let response_type = match &response { + Response::Connect(_) => "Connect".to_string(), + Response::AnnounceIpv4(_) => "AnnounceIpv4".to_string(), + Response::AnnounceIpv6(_) => "AnnounceIpv6".to_string(), + Response::Scrape(_) => "Scrape".to_string(), + Response::Error(e) => format!("Error: {e:?}"), + }; + + let udp_response_kind = match &response { + Response::Error(_e) => event::UdpResponseKind::Error { opt_req_kind: None }, + _ => { + if let Some(req_kind) = opt_req_kind { + event::UdpResponseKind::Ok { req_kind } + } else { + // code-review: this case should never happen. + event::UdpResponseKind::Error { opt_req_kind } + } + } + }; + + let mut writer = Cursor::new(Vec::with_capacity(200)); + + match response.write_bytes(&mut writer) { + Ok(()) => { + let bytes_count = writer.get_ref().len(); + let payload = writer.get_ref(); + + let () = match self.send_packet(&client_socket_addr, payload).await { + Ok(sent_bytes) => { + if tracing::event_enabled!(Level::TRACE) { + tracing::debug!(%bytes_count, %sent_bytes, ?payload, "sent {response_type}"); + } else { + tracing::debug!(%bytes_count, %sent_bytes, "sent {response_type}"); + } + + if let Some(udp_server_stats_event_sender) = + self.udp_tracker_server_container.stats_event_sender.as_deref() + { + udp_server_stats_event_sender + .send(Event::UdpResponseSent { + context: ConnectionContext::new(client_socket_addr, self.server_service_binding), + kind: udp_response_kind, + req_processing_time, + }) + .await; + } + } + Err(error) => tracing::warn!(%bytes_count, %error, ?payload, "failed to send"), + }; + } + Err(e) => { + tracing::error!(%e, "error"); + } + } + } + + #[instrument(skip(self))] + async fn send_packet(&self, target: &SocketAddr, payload: &[u8]) -> std::io::Result { + tracing::trace!("send packet"); + + // doesn't matter if it reaches or not + self.socket.send_to(payload, target).await + } +} diff --git a/src/servers/udp/server/receiver.rs b/packages/udp-tracker-server/src/server/receiver.rs similarity index 95% rename from src/servers/udp/server/receiver.rs rename to packages/udp-tracker-server/src/server/receiver.rs index 0176930a4..89fbed081 100644 --- a/src/servers/udp/server/receiver.rs +++ b/packages/udp-tracker-server/src/server/receiver.rs @@ -8,7 +8,7 @@ use futures::Stream; use super::bound_socket::BoundSocket; use super::RawRequest; -use crate::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; +use crate::MAX_PACKET_SIZE; pub struct Receiver { pub socket: Arc, diff --git a/src/servers/udp/server/request_buffer.rs b/packages/udp-tracker-server/src/server/request_buffer.rs similarity index 94% rename from src/servers/udp/server/request_buffer.rs rename to packages/udp-tracker-server/src/server/request_buffer.rs index ffbd9565d..6e420306e 100644 --- a/src/servers/udp/server/request_buffer.rs +++ b/packages/udp-tracker-server/src/server/request_buffer.rs @@ -1,9 +1,8 @@ +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; use ringbuf::traits::{Consumer, Observer, Producer}; use ringbuf::StaticRb; use tokio::task::AbortHandle; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; - /// A ring buffer for managing active UDP request abort handles. /// /// The `ActiveRequests` struct maintains a fixed-size ring buffer of abort @@ -41,6 +40,8 @@ impl ActiveRequests { /// 1. Removing finished tasks. /// 2. Removing the oldest unfinished task if no finished tasks are found. /// + /// Returns `true` if a task was removed, `false` otherwise. + /// /// # Panics /// /// This method will panic if it cannot make space for adding a new handle. @@ -49,17 +50,19 @@ impl ActiveRequests { /// /// * `abort_handle` - The `AbortHandle` for the UDP request processor task. /// * `local_addr` - A string slice representing the local address for logging. - pub async fn force_push(&mut self, new_task: AbortHandle, local_addr: &str) { + pub async fn force_push(&mut self, new_task: AbortHandle, local_addr: &str) -> bool { // Attempt to add the new handle to the buffer. match self.rb.try_push(new_task) { Ok(()) => { // Successfully added the task, no further action needed. + false } Err(new_task) => { // Buffer is full, attempt to make space. let mut finished: u64 = 0; let mut unfinished_task = None; + let mut old_task_aborted = false; for old_task in self.rb.pop_iter() { // We found a finished tasks ... increase the counter and @@ -96,6 +99,7 @@ impl ActiveRequests { if finished == 0 { // We make place aborting this task. old_task.abort(); + old_task_aborted = true; tracing::warn!( target: UDP_TRACKER_LOG_TARGET, @@ -134,7 +138,9 @@ impl ActiveRequests { if !new_task.is_finished() { self.rb.try_push(new_task).expect("it should have space for this new task."); } + + old_task_aborted } - }; + } } } diff --git a/src/servers/udp/server/spawner.rs b/packages/udp-tracker-server/src/server/spawner.rs similarity index 57% rename from src/servers/udp/server/spawner.rs rename to packages/udp-tracker-server/src/server/spawner.rs index dea293ad7..46916f6ae 100644 --- a/src/servers/udp/server/spawner.rs +++ b/packages/udp-tracker-server/src/server/spawner.rs @@ -1,16 +1,17 @@ //! A thin wrapper for tokio spawn to launch the UDP server launcher as a new task. use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; use derive_more::derive::Display; use derive_more::Constructor; use tokio::sync::oneshot; use tokio::task::JoinHandle; +use torrust_server_lib::signals::{Halted, Started}; use super::launcher::Launcher; -use crate::bootstrap::jobs::Started; -use crate::core::Tracker; -use crate::servers::signals::Halted; +use crate::container::UdpTrackerServerContainer; #[derive(Constructor, Copy, Clone, Debug, Display)] #[display("(with socket): {bind_to}")] @@ -24,16 +25,27 @@ impl Spawner { /// # Panics /// /// It would panic if unable to resolve the `local_addr` from the supplied ´socket´. + #[must_use] pub fn spawn_launcher( &self, - tracker: Arc, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + cookie_lifetime: Duration, tx_start: oneshot::Sender, rx_halt: oneshot::Receiver, ) -> JoinHandle { let spawner = Self::new(self.bind_to); tokio::spawn(async move { - Launcher::run_with_graceful_shutdown(tracker, spawner.bind_to, tx_start, rx_halt).await; + Launcher::run_with_graceful_shutdown( + udp_tracker_core_container, + udp_tracker_server_container, + spawner.bind_to, + cookie_lifetime, + tx_start, + rx_halt, + ) + .await; spawner }) } diff --git a/src/servers/udp/server/states.rs b/packages/udp-tracker-server/src/server/states.rs similarity index 71% rename from src/servers/udp/server/states.rs rename to packages/udp-tracker-server/src/server/states.rs index e90c4da54..4ad059095 100644 --- a/src/servers/udp/server/states.rs +++ b/packages/udp-tracker-server/src/server/states.rs @@ -1,20 +1,21 @@ use std::fmt::Debug; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; use derive_more::derive::Display; use derive_more::Constructor; use tokio::task::JoinHandle; +use torrust_server_lib::registar::{ServiceRegistration, ServiceRegistrationForm}; +use torrust_server_lib::signals::{Halted, Started}; use tracing::{instrument, Level}; use super::spawner::Spawner; use super::{Server, UdpError}; -use crate::bootstrap::jobs::Started; -use crate::core::Tracker; -use crate::servers::registar::{ServiceRegistration, ServiceRegistrationForm}; -use crate::servers::signals::Halted; -use crate::servers::udp::server::launcher::Launcher; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; +use crate::container::UdpTrackerServerContainer; +use crate::server::launcher::Launcher; /// A UDP server instance controller with no UDP instance running. #[allow(clippy::module_name_repetitions)] @@ -60,20 +61,34 @@ impl Server { /// # Panics /// /// It panics if unable to receive the bound socket address from service. - /// - #[instrument(skip(self, tracker, form), err, ret(Display, level = Level::INFO))] - pub async fn start(self, tracker: Arc, form: ServiceRegistrationForm) -> Result, std::io::Error> { + #[instrument(skip(self, udp_tracker_core_container, udp_tracker_server_container, form), err, ret(Display, level = Level::INFO))] + pub async fn start( + self, + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + form: ServiceRegistrationForm, + cookie_lifetime: Duration, + ) -> Result, std::io::Error> { let (tx_start, rx_start) = tokio::sync::oneshot::channel::(); let (tx_halt, rx_halt) = tokio::sync::oneshot::channel::(); assert!(!tx_halt.is_closed(), "Halt channel for UDP tracker should be open"); // May need to wrap in a task to about a tokio bug. - let task = self.state.spawner.spawn_launcher(tracker, tx_start, rx_halt); + let task = self.state.spawner.spawn_launcher( + udp_tracker_core_container, + udp_tracker_server_container, + cookie_lifetime, + tx_start, + rx_halt, + ); + + let started = rx_start.await.expect("it should be able to start the service"); - let local_addr = rx_start.await.expect("it should be able to start the service").address; + let service_binding = started.service_binding; + let local_addr = started.address; - form.send(ServiceRegistration::new(local_addr, Launcher::check)) + form.send(ServiceRegistration::new(service_binding, Launcher::check)) .expect("it should be able to send service registration"); let running_udp_server: Server = Server { diff --git a/packages/udp-tracker-server/src/statistics/event/handler/error.rs b/packages/udp-tracker-server/src/statistics/event/handler/error.rs new file mode 100644 index 000000000..63e480ca5 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/error.rs @@ -0,0 +1,142 @@ +use aquatic_udp_protocol::PeerClient; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::{ConnectionContext, ErrorKind, UdpRequestKind}; +use crate::statistics::repository::Repository; +use crate::statistics::{UDP_TRACKER_SERVER_CONNECTION_ID_ERRORS_TOTAL, UDP_TRACKER_SERVER_ERRORS_TOTAL}; + +pub async fn handle_event( + connection_context: ConnectionContext, + opt_udp_request_kind: Option, + error_kind: ErrorKind, + repository: &Repository, + now: DurationSinceUnixEpoch, +) { + update_extendable_metrics(&connection_context, opt_udp_request_kind, error_kind, repository, now).await; +} + +async fn update_extendable_metrics( + connection_context: &ConnectionContext, + opt_udp_request_kind: Option, + error_kind: ErrorKind, + repository: &Repository, + now: DurationSinceUnixEpoch, +) { + update_all_errors_counter(connection_context, opt_udp_request_kind.clone(), repository, now).await; + update_connection_id_errors_counter(opt_udp_request_kind, error_kind, repository, now).await; +} + +async fn update_all_errors_counter( + connection_context: &ConnectionContext, + opt_udp_request_kind: Option, + repository: &Repository, + now: DurationSinceUnixEpoch, +) { + let mut label_set = LabelSet::from(connection_context.clone()); + + if let Some(kind) = opt_udp_request_kind.clone() { + label_set.upsert(label_name!("request_kind"), kind.to_string().into()); + } + + match repository + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + } +} + +async fn update_connection_id_errors_counter( + opt_udp_request_kind: Option, + error_kind: ErrorKind, + repository: &Repository, + now: DurationSinceUnixEpoch, +) { + if let ErrorKind::ConnectionCookie(_) = error_kind { + if let Some(UdpRequestKind::Announce { announce_request }) = opt_udp_request_kind { + let (client_software_name, client_software_version) = extract_name_and_version(&announce_request.peer_id.client()); + + let label_set = LabelSet::from([ + (label_name!("client_software_name"), client_software_name.into()), + (label_name!("client_software_version"), client_software_version.into()), + ]); + + match repository + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_CONNECTION_ID_ERRORS_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; + } + } +} + +fn extract_name_and_version(peer_client: &PeerClient) -> (String, String) { + match peer_client { + PeerClient::BitTorrent(compact_string) => ("BitTorrent".to_string(), compact_string.as_str().to_owned()), + PeerClient::Deluge(compact_string) => ("Deluge".to_string(), compact_string.as_str().to_owned()), + PeerClient::LibTorrentRakshasa(compact_string) => ("lt (rakshasa)".to_string(), compact_string.as_str().to_owned()), + PeerClient::LibTorrentRasterbar(compact_string) => ("lt (rasterbar)".to_string(), compact_string.as_str().to_owned()), + PeerClient::QBitTorrent(compact_string) => ("QBitTorrent".to_string(), compact_string.as_str().to_owned()), + PeerClient::Transmission(compact_string) => ("Transmission".to_string(), compact_string.as_str().to_owned()), + PeerClient::UTorrent(compact_string) => ("µTorrent".to_string(), compact_string.as_str().to_owned()), + PeerClient::UTorrentEmbedded(compact_string) => ("µTorrent Emb.".to_string(), compact_string.as_str().to_owned()), + PeerClient::UTorrentMac(compact_string) => ("µTorrent Mac".to_string(), compact_string.as_str().to_owned()), + PeerClient::UTorrentWeb(compact_string) => ("µTorrent Web".to_string(), compact_string.as_str().to_owned()), + PeerClient::Vuze(compact_string) => ("Vuze".to_string(), compact_string.as_str().to_owned()), + PeerClient::WebTorrent(compact_string) => ("WebTorrent".to_string(), compact_string.as_str().to_owned()), + PeerClient::WebTorrentDesktop(compact_string) => ("WebTorrent Desktop".to_string(), compact_string.as_str().to_owned()), + PeerClient::Mainline(compact_string) => ("Mainline".to_string(), compact_string.as_str().to_owned()), + PeerClient::OtherWithPrefixAndVersion { prefix, version } => { + (format!("Other ({})", prefix.as_str()), version.as_str().to_owned()) + } + PeerClient::OtherWithPrefix(compact_string) => (format!("Other ({compact_string})"), String::new()), + PeerClient::Other => ("Other".to_string(), String::new()), + _ => ("Unknown".to_string(), String::new()), + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::error::ErrorKind; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_udp4_errors_counter_when_it_receives_a_udp4_error_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpError { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: None, + error: ErrorKind::RequestParse("Invalid request format".to_string()), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_errors_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/mod.rs b/packages/udp-tracker-server/src/statistics/event/handler/mod.rs new file mode 100644 index 000000000..9e7f5cd47 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/mod.rs @@ -0,0 +1,40 @@ +mod error; +mod request_aborted; +mod request_accepted; +mod request_banned; +mod request_received; +mod response_sent; + +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::Event; +use crate::statistics::repository::Repository; + +pub async fn handle_event(event: Event, stats_repository: &Repository, now: DurationSinceUnixEpoch) { + match event { + Event::UdpRequestAborted { context } => { + request_aborted::handle_event(context, stats_repository, now).await; + } + Event::UdpRequestBanned { context } => { + request_banned::handle_event(context, stats_repository, now).await; + } + Event::UdpRequestReceived { context } => { + request_received::handle_event(context, stats_repository, now).await; + } + Event::UdpRequestAccepted { context, kind } => { + request_accepted::handle_event(context, kind, stats_repository, now).await; + } + Event::UdpResponseSent { + context, + kind, + req_processing_time, + } => { + response_sent::handle_event(context, kind, req_processing_time, stats_repository, now).await; + } + Event::UdpError { context, kind, error } => { + error::handle_event(context, kind, error, stats_repository, now).await; + } + } + + tracing::debug!("stats: {:?}", stats_repository.get_stats().await); +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/request_aborted.rs b/packages/udp-tracker-server/src/statistics/event/handler/request_aborted.rs new file mode 100644 index 000000000..f340fe51a --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/request_aborted.rs @@ -0,0 +1,82 @@ +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::ConnectionContext; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL; + +pub async fn handle_event(context: ConnectionContext, stats_repository: &Repository, now: DurationSinceUnixEpoch) { + match stats_repository + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), + &LabelSet::from(context), + now, + ) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_number_of_aborted_requests_when_it_receives_a_udp_request_aborted_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAborted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp_requests_aborted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp_abort_counter_when_it_receives_a_udp_abort_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAborted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + let stats = stats_repository.get_stats().await; + assert_eq!(stats.udp_requests_aborted_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/request_accepted.rs b/packages/udp-tracker-server/src/statistics/event/handler/request_accepted.rs new file mode 100644 index 000000000..33971926e --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/request_accepted.rs @@ -0,0 +1,200 @@ +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::{ConnectionContext, UdpRequestKind}; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL; + +pub async fn handle_event( + context: ConnectionContext, + kind: UdpRequestKind, + stats_repository: &Repository, + now: DurationSinceUnixEpoch, +) { + let mut label_set = LabelSet::from(context); + label_set.upsert(label_name!("request_kind"), LabelValue::new(&kind.to_string())); + match stats_repository + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &label_set, now) + .await + { + Ok(()) => { + tracing::debug!("Successfully increased the counter for UDP requests accepted: {}", label_set); + } + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_udp4_connect_requests_counter_when_it_receives_a_udp4_request_event_of_connect_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Connect, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_connect_requests_accepted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp4_announce_requests_counter_when_it_receives_a_udp4_request_event_of_announce_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Announce { + announce_request: AnnounceRequestBuilder::default().into(), + }, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_announce_requests_accepted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp4_scrape_requests_counter_when_it_receives_a_udp4_request_event_of_scrape_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Scrape, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_scrape_requests_accepted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_connect_requests_counter_when_it_receives_a_udp6_request_event_of_connect_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Connect, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_connect_requests_accepted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_announce_requests_counter_when_it_receives_a_udp6_request_event_of_announce_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Announce { + announce_request: AnnounceRequestBuilder::default().into(), + }, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_announce_requests_accepted_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_scrape_requests_counter_when_it_receives_a_udp6_request_event_of_scrape_kind() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestAccepted { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpRequestKind::Scrape, + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_scrape_requests_accepted_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/request_banned.rs b/packages/udp-tracker-server/src/statistics/event/handler/request_banned.rs new file mode 100644 index 000000000..10f6cad88 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/request_banned.rs @@ -0,0 +1,82 @@ +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::ConnectionContext; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL; + +pub async fn handle_event(context: ConnectionContext, stats_repository: &Repository, now: DurationSinceUnixEpoch) { + match stats_repository + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL), + &LabelSet::from(context), + now, + ) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_number_of_banned_requests_when_it_receives_a_udp_request_banned_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestBanned { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp_requests_banned_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp_ban_counter_when_it_receives_a_udp_banned_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestBanned { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + let stats = stats_repository.get_stats().await; + assert_eq!(stats.udp_requests_banned_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/request_received.rs b/packages/udp-tracker-server/src/statistics/event/handler/request_received.rs new file mode 100644 index 000000000..148b9d8da --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/request_received.rs @@ -0,0 +1,59 @@ +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::ConnectionContext; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL; + +pub async fn handle_event(context: ConnectionContext, stats_repository: &Repository, now: DurationSinceUnixEpoch) { + match stats_repository + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), + &LabelSet::from(context), + now, + ) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_number_of_incoming_requests_when_it_receives_a_udp4_incoming_request_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpRequestReceived { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_requests_received_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler/response_sent.rs b/packages/udp-tracker-server/src/statistics/event/handler/response_sent.rs new file mode 100644 index 000000000..b1a046b5b --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/handler/response_sent.rs @@ -0,0 +1,141 @@ +use torrust_tracker_metrics::label::{LabelSet, LabelValue}; +use torrust_tracker_metrics::{label_name, metric_name}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::event::{ConnectionContext, UdpRequestKind, UdpResponseKind}; +use crate::statistics::repository::Repository; +use crate::statistics::UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL; + +pub async fn handle_event( + context: ConnectionContext, + kind: UdpResponseKind, + req_processing_time: std::time::Duration, + stats_repository: &Repository, + now: DurationSinceUnixEpoch, +) { + let (result_label_value, kind_label_value) = match kind { + UdpResponseKind::Ok { req_kind } => match req_kind { + UdpRequestKind::Connect => { + let mut label_set = LabelSet::from(context.clone()); + label_set.upsert(label_name!("request_kind"), LabelValue::new(&req_kind.to_string())); + + let _new_avg = stats_repository + .recalculate_udp_avg_processing_time_ns(req_processing_time, &label_set, now) + .await; + + (LabelValue::new("ok"), UdpRequestKind::Connect.into()) + } + UdpRequestKind::Announce { announce_request } => { + let mut label_set = LabelSet::from(context.clone()); + label_set.upsert(label_name!("request_kind"), LabelValue::new(&req_kind.to_string())); + + let _new_avg = stats_repository + .recalculate_udp_avg_processing_time_ns(req_processing_time, &label_set, now) + .await; + + (LabelValue::new("ok"), UdpRequestKind::Announce { announce_request }.into()) + } + UdpRequestKind::Scrape => { + let mut label_set = LabelSet::from(context.clone()); + label_set.upsert(label_name!("request_kind"), LabelValue::new(&req_kind.to_string())); + + let _new_avg = stats_repository + .recalculate_udp_avg_processing_time_ns(req_processing_time, &label_set, now) + .await; + + (LabelValue::new("ok"), LabelValue::new(&UdpRequestKind::Scrape.to_string())) + } + }, + UdpResponseKind::Error { opt_req_kind: _ } => (LabelValue::new("error"), LabelValue::ignore()), + }; + + // Increase the number of responses sent + let mut label_set = LabelSet::from(context); + if result_label_value == LabelValue::new("ok") { + label_set.upsert(label_name!("request_kind"), kind_label_value); + } + label_set.upsert(label_name!("result"), result_label_value); + match stats_repository + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), &label_set, now) + .await + { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increase the counter: {}", err), + }; +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; + + use crate::event::{ConnectionContext, Event}; + use crate::handlers::announce::tests::announce_request::AnnounceRequestBuilder; + use crate::statistics::event::handler::handle_event; + use crate::statistics::repository::Repository; + use crate::CurrentClock; + + #[tokio::test] + async fn should_increase_the_udp4_responses_counter_when_it_receives_a_udp4_response_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpResponseSent { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpResponseKind::Ok { + req_kind: crate::event::UdpRequestKind::Announce { + announce_request: AnnounceRequestBuilder::default().into(), + }, + }, + req_processing_time: std::time::Duration::from_secs(1), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp4_responses_sent_total(), 1); + } + + #[tokio::test] + async fn should_increase_the_udp6_response_counter_when_it_receives_a_udp6_response_event() { + let stats_repository = Repository::new(); + + handle_event( + Event::UdpResponseSent { + context: ConnectionContext::new( + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 195)), 8080), + ServiceBinding::new( + Protocol::UDP, + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 203, 0, 113, 196)), 6969), + ) + .unwrap(), + ), + kind: crate::event::UdpResponseKind::Ok { + req_kind: crate::event::UdpRequestKind::Announce { + announce_request: AnnounceRequestBuilder::default().into(), + }, + }, + req_processing_time: std::time::Duration::from_secs(1), + }, + &stats_repository, + CurrentClock::now(), + ) + .await; + + let stats = stats_repository.get_stats().await; + + assert_eq!(stats.udp6_responses_sent_total(), 1); + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/listener.rs b/packages/udp-tracker-server/src/statistics/event/listener.rs new file mode 100644 index 000000000..caaf5a2bc --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/listener.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_events::receiver::RecvError; + +use super::handler::handle_event; +use crate::event::receiver::Receiver; +use crate::statistics::repository::Repository; +use crate::CurrentClock; + +#[must_use] +pub fn run_event_listener( + receiver: Receiver, + cancellation_token: CancellationToken, + repository: &Arc, +) -> JoinHandle<()> { + let repository_clone = repository.clone(); + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Starting UDP tracker server event listener"); + + tokio::spawn(async move { + dispatch_events(receiver, cancellation_token, repository_clone).await; + + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "UDP tracker server event listener finished"); + }) +} + +async fn dispatch_events(mut receiver: Receiver, cancellation_token: CancellationToken, stats_repository: Arc) { + loop { + tokio::select! { + biased; + + () = cancellation_token.cancelled() => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Received cancellation request, shutting down UDP tracker server event listener."); + break; + } + + result = receiver.recv() => { + match result { + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, + Err(e) => { + match e { + RecvError::Closed => { + tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker server statistics receiver closed."); + break; + } + RecvError::Lagged(n) => { + tracing::warn!(target: UDP_TRACKER_LOG_TARGET, "Udp tracker server statistics receiver lagged by {} events.", n); + } + } + } + } + } + } + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/mod.rs b/packages/udp-tracker-server/src/statistics/event/mod.rs new file mode 100644 index 000000000..dae683398 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/event/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/packages/udp-tracker-server/src/statistics/metrics.rs b/packages/udp-tracker-server/src/statistics/metrics.rs new file mode 100644 index 000000000..e167dc5ae --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/metrics.rs @@ -0,0 +1,1315 @@ +use std::time::Duration; + +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::aggregate::avg::Avg; +use torrust_tracker_metrics::metric_collection::aggregate::sum::Sum; +use torrust_tracker_metrics::metric_collection::{Error, MetricCollection}; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use crate::statistics::{ + UDP_TRACKER_SERVER_ERRORS_TOTAL, UDP_TRACKER_SERVER_IPS_BANNED_TOTAL, + UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL, UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS, + UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL, UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL, + UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL, UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL, + UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL, +}; + +/// Metrics collected by the UDP tracker server. +#[derive(Debug, PartialEq, Default, Serialize)] +pub struct Metrics { + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn increase_counter( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.increment_counter(metric_name, labels, now) + } + + /// # Errors + /// + /// Returns an error if the metric does not exist and it cannot be created. + pub fn set_gauge( + &mut self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + self.metric_collection.set_gauge(metric_name, labels, value, now) + } +} + +impl Metrics { + #[allow(clippy::cast_precision_loss)] + pub fn recalculate_udp_avg_processing_time_ns( + &mut self, + req_processing_time: Duration, + label_set: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> f64 { + self.increment_udp_processed_requests_total(label_set, now); + + let processed_requests_total = self.udp_processed_requests_total(label_set) as f64; + let previous_avg = self.udp_avg_processing_time_ns(label_set); + let req_processing_time = req_processing_time.as_nanos() as f64; + + // Moving average: https://en.wikipedia.org/wiki/Moving_average + let new_avg = previous_avg as f64 + (req_processing_time - previous_avg as f64) / processed_requests_total; + + tracing::debug!( + "Recalculated UDP average processing time for labels {}: {} ns (previous: {} ns, req_processing_time: {} ns, request_processed_total: {})", + label_set, + new_avg, + previous_avg, + req_processing_time, + processed_requests_total + ); + + self.update_udp_avg_processing_time_ns(new_avg, label_set, now); + + new_avg + } + + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + fn udp_avg_processing_time_ns(&self, label_set: &LabelSet) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + label_set, + ) + .unwrap_or_default() as u64 + } + + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_request_accepted_total(&self, label_set: &LabelSet) -> u64 { + self.metric_collection + .sum(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), label_set) + .unwrap_or_default() as u64 + } + + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + fn udp_processed_requests_total(&self, label_set: &LabelSet) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL), + label_set, + ) + .unwrap_or_default() as u64 + } + + fn update_udp_avg_processing_time_ns(&mut self, new_avg: f64, label_set: &LabelSet, now: DurationSinceUnixEpoch) { + tracing::debug!( + "Updating average processing time metric to {} ns for label set {}", + new_avg, + label_set, + ); + + match self.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + label_set, + new_avg, + now, + ) { + Ok(()) => {} + Err(err) => tracing::error!("Failed to set gauge: {}", err), + } + } + + fn increment_udp_processed_requests_total(&mut self, label_set: &LabelSet, now: DurationSinceUnixEpoch) { + tracing::debug!("Incrementing processed requests total for label set {}", label_set,); + + match self.increase_counter( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL), + label_set, + now, + ) { + Ok(()) => {} + Err(err) => tracing::error!("Failed to increment counter: {}", err), + } + } + + // UDP + /// Total number of UDP (UDP tracker) requests aborted. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_requests_aborted_total(&self) -> u64 { + self.metric_collection + .sum(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &LabelSet::empty()) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) requests banned. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_requests_banned_total(&self) -> u64 { + self.metric_collection + .sum(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL), &LabelSet::empty()) + .unwrap_or_default() as u64 + } + + /// Total number of banned IPs. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_banned_ips_total(&self) -> u64 { + self.metric_collection + .sum(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &LabelSet::empty()) + .unwrap_or_default() as u64 + } + + /// Average processing time for UDP connect requests across all servers (in nanoseconds). + /// This calculates the average of all gauge samples for connect requests. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_avg_connect_processing_time_ns_averaged(&self) -> u64 { + self.metric_collection + .avg( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &[("request_kind", "connect")].into(), + ) + .unwrap_or(0.0) as u64 + } + + /// Average processing time for UDP announce requests across all servers (in nanoseconds). + /// This calculates the average of all gauge samples for announce requests. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_avg_announce_processing_time_ns_averaged(&self) -> u64 { + self.metric_collection + .avg( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &[("request_kind", "announce")].into(), + ) + .unwrap_or(0.0) as u64 + } + + /// Average processing time for UDP scrape requests across all servers (in nanoseconds). + /// This calculates the average of all gauge samples for scrape requests. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp_avg_scrape_processing_time_ns_averaged(&self) -> u64 { + self.metric_collection + .avg( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &[("request_kind", "scrape")].into(), + ) + .unwrap_or(0.0) as u64 + } + + // UDPv4 + /// Total number of UDP (UDP tracker) requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_requests_received_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) connections from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_connect_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "connect")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_announce_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_scrape_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) responses from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_responses_sent_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), + &[("server_binding_address_ip_family", "inet")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `error` requests from IPv4 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp4_errors_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), + &[("server_binding_address_ip_family", "inet")].into(), + ) + .unwrap_or_default() as u64 + } + + // UDPv6 + /// Total number of UDP (UDP tracker) requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_requests_received_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), + &[("server_binding_address_ip_family", "inet6")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_connect_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "connect")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_announce_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_scrape_requests_accepted_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &[("server_binding_address_ip_family", "inet6"), ("request_kind", "scrape")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) responses from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_responses_sent_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), + &[("server_binding_address_ip_family", "inet6")].into(), + ) + .unwrap_or_default() as u64 + } + + /// Total number of UDP (UDP tracker) `error` requests from IPv6 peers. + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + pub fn udp6_errors_total(&self) -> u64 { + self.metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), + &[("server_binding_address_ip_family", "inet6")].into(), + ) + .unwrap_or_default() as u64 + } +} + +#[cfg(test)] +mod tests { + use torrust_tracker_clock::clock::Time; + use torrust_tracker_metrics::metric_name; + + use super::*; + use crate::statistics::{ + UDP_TRACKER_SERVER_ERRORS_TOTAL, UDP_TRACKER_SERVER_IPS_BANNED_TOTAL, + UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL, UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS, + UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL, UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL, + UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL, UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL, + UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL, + }; + use crate::CurrentClock; + + #[test] + fn it_should_implement_default() { + let metrics = Metrics::default(); + // MetricCollection starts with empty collections + assert_eq!(metrics, Metrics::default()); + } + + #[test] + fn it_should_implement_debug() { + let metrics = Metrics::default(); + let debug_string = format!("{metrics:?}"); + assert!(debug_string.contains("Metrics")); + assert!(debug_string.contains("metric_collection")); + } + + #[test] + fn it_should_implement_partial_eq() { + let metrics1 = Metrics::default(); + let metrics2 = Metrics::default(); + assert_eq!(metrics1, metrics2); + } + + #[test] + fn it_should_increase_counter_metric() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + let result = metrics.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_increase_counter_metric_with_labels() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet")]); + + let result = metrics.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels, now); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_set_gauge_metric() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + let result = metrics.set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 42.0, now); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_set_gauge_metric_with_labels() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("request_kind", "connect")]); + + let result = metrics.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels, + 1000.0, + now, + ); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_return_zero_for_udp_processed_requests_total_when_no_data() { + let metrics = Metrics::default(); + let labels = LabelSet::from([("request_kind", "connect")]); + assert_eq!(metrics.udp_processed_requests_total(&labels), 0); + } + + #[test] + fn it_should_increment_processed_requests_total() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("request_kind", "connect")]); + + // Directly increment the counter using the public method + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL), + &labels, + now, + ) + .unwrap(); + + assert_eq!(metrics.udp_processed_requests_total(&labels), 1); + } + + mod udp_general_metrics { + use super::*; + + #[test] + fn it_should_return_zero_for_udp_requests_aborted_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_requests_aborted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp_requests_aborted() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now) + .unwrap(); + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now) + .unwrap(); + + assert_eq!(metrics.udp_requests_aborted_total(), 2); + } + + #[test] + fn it_should_return_zero_for_udp_requests_banned_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_requests_banned_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp_requests_banned() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + for _ in 0..3 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp_requests_banned_total(), 3); + } + + #[test] + fn it_should_return_zero_for_udp_banned_ips_total_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_banned_ips_total(), 0); + } + + #[test] + fn it_should_return_gauge_value_for_udp_banned_ips_total() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + metrics + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 10.0, now) + .unwrap(); + + assert_eq!(metrics.udp_banned_ips_total(), 10); + } + } + + mod udpv4_metrics { + use super::*; + + #[test] + fn it_should_return_zero_for_udp4_requests_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_requests_received_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_requests() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet")]); + + for _ in 0..5 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_requests_received_total(), 5); + } + + #[test] + fn it_should_return_zero_for_udp4_connections_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_connect_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_connections_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "connect")]); + + for _ in 0..3 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_connect_requests_accepted_total(), 3); + } + + #[test] + fn it_should_return_zero_for_udp4_announces_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_announce_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_announces_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "announce")]); + + for _ in 0..7 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_announce_requests_accepted_total(), 7); + } + + #[test] + fn it_should_return_zero_for_udp4_scrapes_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_scrape_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_scrapes_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "scrape")]); + + for _ in 0..4 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_scrape_requests_accepted_total(), 4); + } + + #[test] + fn it_should_return_zero_for_udp4_responses_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_responses_sent_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_responses() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet")]); + + for _ in 0..6 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_responses_sent_total(), 6); + } + + #[test] + fn it_should_return_zero_for_udp4_errors_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp4_errors_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp4_errors_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet")]); + + for _ in 0..2 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_errors_total(), 2); + } + } + + mod udpv6_metrics { + use super::*; + + #[test] + fn it_should_return_zero_for_udp6_requests_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_requests_received_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_requests() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + for _ in 0..8 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_requests_received_total(), 8); + } + + #[test] + fn it_should_return_zero_for_udp6_connections_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_connect_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_connections_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6"), ("request_kind", "connect")]); + + for _ in 0..4 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_connect_requests_accepted_total(), 4); + } + + #[test] + fn it_should_return_zero_for_udp6_announces_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_announce_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_announces_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6"), ("request_kind", "announce")]); + + for _ in 0..9 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_announce_requests_accepted_total(), 9); + } + + #[test] + fn it_should_return_zero_for_udp6_scrapes_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_scrape_requests_accepted_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_scrapes_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6"), ("request_kind", "scrape")]); + + for _ in 0..6 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_scrape_requests_accepted_total(), 6); + } + + #[test] + fn it_should_return_zero_for_udp6_responses_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_responses_sent_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_responses() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + for _ in 0..11 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_responses_sent_total(), 11); + } + + #[test] + fn it_should_return_zero_for_udp6_errors_handled_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp6_errors_total(), 0); + } + + #[test] + fn it_should_return_sum_of_udp6_errors_handled() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + for _ in 0..3 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp6_errors_total(), 3); + } + } + + mod combined_metrics { + use super::*; + + #[test] + fn it_should_distinguish_between_ipv4_and_ipv6_metrics() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + + let ipv4_labels = LabelSet::from([("server_binding_address_ip_family", "inet")]); + let ipv6_labels = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + // Add different counts for IPv4 and IPv6 + for _ in 0..3 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &ipv4_labels, now) + .unwrap(); + } + + for _ in 0..7 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &ipv6_labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_requests_received_total(), 3); + assert_eq!(metrics.udp6_requests_received_total(), 7); + } + + #[test] + fn it_should_distinguish_between_different_request_kinds() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + + let connect_labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "connect")]); + let announce_labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "announce")]); + let scrape_labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "scrape")]); + + // Add different counts for different request kinds + for _ in 0..2 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &connect_labels, + now, + ) + .unwrap(); + } + + for _ in 0..5 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &announce_labels, + now, + ) + .unwrap(); + } + + for _ in 0..1 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &scrape_labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp4_connect_requests_accepted_total(), 2); + assert_eq!(metrics.udp4_announce_requests_accepted_total(), 5); + assert_eq!(metrics.udp4_scrape_requests_accepted_total(), 1); + } + + #[test] + fn it_should_handle_mixed_ipv4_and_ipv6_for_different_request_kinds() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + + let ipv4_connect_labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "connect")]); + let ipv6_connect_labels = + LabelSet::from([("server_binding_address_ip_family", "inet6"), ("request_kind", "connect")]); + let ipv4_announce_labels = + LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "announce")]); + let ipv6_announce_labels = + LabelSet::from([("server_binding_address_ip_family", "inet6"), ("request_kind", "announce")]); + + // Add mixed IPv4/IPv6 counts + for _ in 0..3 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &ipv4_connect_labels, + now, + ) + .unwrap(); + } + + for _ in 0..2 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &ipv6_connect_labels, + now, + ) + .unwrap(); + } + + for _ in 0..4 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &ipv4_announce_labels, + now, + ) + .unwrap(); + } + + for _ in 0..6 { + metrics + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + &ipv6_announce_labels, + now, + ) + .unwrap(); + } + + assert_eq!(metrics.udp4_connect_requests_accepted_total(), 3); + assert_eq!(metrics.udp6_connect_requests_accepted_total(), 2); + assert_eq!(metrics.udp4_announce_requests_accepted_total(), 4); + assert_eq!(metrics.udp6_announce_requests_accepted_total(), 6); + } + } + + mod edge_cases { + use super::*; + + #[test] + fn it_should_handle_large_counter_values() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Add a large number of increments + for _ in 0..1000 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now) + .unwrap(); + } + + assert_eq!(metrics.udp_requests_aborted_total(), 1000); + } + + #[test] + fn it_should_handle_large_gauge_values() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Set a large gauge value + metrics + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 999_999.0, now) + .unwrap(); + + assert_eq!(metrics.udp_banned_ips_total(), 999_999); + } + + #[test] + fn it_should_handle_zero_gauge_values() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + metrics + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 0.0, now) + .unwrap(); + + assert_eq!(metrics.udp_banned_ips_total(), 0); + } + + #[test] + fn it_should_overwrite_gauge_values_when_set_multiple_times() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Set initial value + metrics + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 50.0, now) + .unwrap(); + + assert_eq!(metrics.udp_banned_ips_total(), 50); + + // Overwrite with new value + metrics + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 75.0, now) + .unwrap(); + + assert_eq!(metrics.udp_banned_ips_total(), 75); + } + + #[test] + fn it_should_handle_empty_label_sets() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let empty_labels = LabelSet::empty(); + + let result = metrics.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &empty_labels, now); + + assert!(result.is_ok()); + assert_eq!(metrics.udp_requests_aborted_total(), 1); + } + + #[test] + fn it_should_handle_multiple_labels_on_same_metric() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + + let labels1 = LabelSet::from([("server_binding_address_ip_family", "inet")]); + let labels2 = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + // Add to same metric with different labels + for _ in 0..3 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels1, now) + .unwrap(); + } + + for _ in 0..5 { + metrics + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels2, now) + .unwrap(); + } + + // Should return labeled sums correctly + assert_eq!(metrics.udp4_requests_received_total(), 3); + assert_eq!(metrics.udp6_requests_received_total(), 5); + } + } + + mod error_handling { + use super::*; + + #[test] + fn it_should_return_ok_result_for_valid_counter_operations() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + let result = metrics.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_return_ok_result_for_valid_gauge_operations() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + let result = metrics.set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 42.0, now); + + assert!(result.is_ok()); + } + + #[test] + fn it_should_handle_unknown_metric_names_gracefully() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // This should still work as metrics are created on demand + let result = metrics.increase_counter(&metric_name!("unknown_metric"), &labels, now); + + assert!(result.is_ok()); + } + } + + mod averaged_processing_time_metrics { + use super::*; + + #[test] + fn it_should_return_zero_for_udp_avg_connect_processing_time_ns_averaged_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_avg_connect_processing_time_ns_averaged(), 0); + } + + #[test] + fn it_should_return_averaged_value_for_udp_avg_connect_processing_time_ns_averaged() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels1 = LabelSet::from([("request_kind", "connect"), ("server_id", "server1")]); + let labels2 = LabelSet::from([("request_kind", "connect"), ("server_id", "server2")]); + + // Set different gauge values for connect requests from different servers + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels1, + 1000.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels2, + 2000.0, + now, + ) + .unwrap(); + + // Should return the average: (1000 + 2000) / 2 = 1500 + assert_eq!(metrics.udp_avg_connect_processing_time_ns_averaged(), 1500); + } + + #[test] + fn it_should_return_zero_for_udp_avg_announce_processing_time_ns_averaged_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_avg_announce_processing_time_ns_averaged(), 0); + } + + #[test] + fn it_should_return_averaged_value_for_udp_avg_announce_processing_time_ns_averaged() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels1 = LabelSet::from([("request_kind", "announce"), ("server_id", "server1")]); + let labels2 = LabelSet::from([("request_kind", "announce"), ("server_id", "server2")]); + let labels3 = LabelSet::from([("request_kind", "announce"), ("server_id", "server3")]); + + // Set different gauge values for announce requests from different servers + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels1, + 1500.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels2, + 2500.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels3, + 3000.0, + now, + ) + .unwrap(); + + // Should return the average: (1500 + 2500 + 3000) / 3 = 2333 (truncated) + assert_eq!(metrics.udp_avg_announce_processing_time_ns_averaged(), 2333); + } + + #[test] + fn it_should_return_zero_for_udp_avg_scrape_processing_time_ns_averaged_when_no_data() { + let metrics = Metrics::default(); + assert_eq!(metrics.udp_avg_scrape_processing_time_ns_averaged(), 0); + } + + #[test] + fn it_should_return_averaged_value_for_udp_avg_scrape_processing_time_ns_averaged() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels1 = LabelSet::from([("request_kind", "scrape"), ("server_id", "server1")]); + let labels2 = LabelSet::from([("request_kind", "scrape"), ("server_id", "server2")]); + + // Set different gauge values for scrape requests from different servers + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels1, + 500.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels2, + 1500.0, + now, + ) + .unwrap(); + + // Should return the average: (500 + 1500) / 2 = 1000 + assert_eq!(metrics.udp_avg_scrape_processing_time_ns_averaged(), 1000); + } + + #[test] + fn it_should_handle_fractional_averages_with_truncation() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels1 = LabelSet::from([("request_kind", "connect"), ("server_id", "server1")]); + let labels2 = LabelSet::from([("request_kind", "connect"), ("server_id", "server2")]); + let labels3 = LabelSet::from([("request_kind", "connect"), ("server_id", "server3")]); + + // Set values that will result in a fractional average + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels1, + 1000.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels2, + 1001.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels3, + 1001.0, + now, + ) + .unwrap(); + + // Should return the average: (1000 + 1001 + 1001) / 3 = 1000.666... → 1000 (truncated) + assert_eq!(metrics.udp_avg_connect_processing_time_ns_averaged(), 1000); + } + + #[test] + fn it_should_only_average_matching_request_kinds() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + + // Set values for different request kinds with the same server_id + let connect_labels = LabelSet::from([("request_kind", "connect"), ("server_id", "server1")]); + let announce_labels = LabelSet::from([("request_kind", "announce"), ("server_id", "server1")]); + let scrape_labels = LabelSet::from([("request_kind", "scrape"), ("server_id", "server1")]); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &connect_labels, + 1000.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &announce_labels, + 2000.0, + now, + ) + .unwrap(); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &scrape_labels, + 3000.0, + now, + ) + .unwrap(); + + // Each function should only return the value for its specific request kind + assert_eq!(metrics.udp_avg_connect_processing_time_ns_averaged(), 1000); + assert_eq!(metrics.udp_avg_announce_processing_time_ns_averaged(), 2000); + assert_eq!(metrics.udp_avg_scrape_processing_time_ns_averaged(), 3000); + } + + #[test] + fn it_should_handle_single_server_averaged_metrics() { + let mut metrics = Metrics::default(); + let now = CurrentClock::now(); + let labels = LabelSet::from([("request_kind", "connect"), ("server_id", "single_server")]); + + metrics + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels, + 1234.0, + now, + ) + .unwrap(); + + // With only one server, the average should be the same as the single value + assert_eq!(metrics.udp_avg_connect_processing_time_ns_averaged(), 1234); + } + } +} diff --git a/packages/udp-tracker-server/src/statistics/mod.rs b/packages/udp-tracker-server/src/statistics/mod.rs new file mode 100644 index 000000000..6bd35b9a1 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/mod.rs @@ -0,0 +1,90 @@ +pub mod event; +pub mod metrics; +pub mod repository; +pub mod services; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric_name; +use torrust_tracker_metrics::unit::Unit; + +pub const UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL: &str = "udp_tracker_server_requests_aborted_total"; +pub const UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL: &str = "udp_tracker_server_requests_banned_total"; +pub const UDP_TRACKER_SERVER_IPS_BANNED_TOTAL: &str = "udp_tracker_server_ips_banned_total"; +pub const UDP_TRACKER_SERVER_CONNECTION_ID_ERRORS_TOTAL: &str = "udp_tracker_server_connection_id_errors_total"; +pub const UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL: &str = "udp_tracker_server_requests_received_total"; +pub const UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL: &str = "udp_tracker_server_requests_accepted_total"; +pub const UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL: &str = "udp_tracker_server_responses_sent_total"; +pub const UDP_TRACKER_SERVER_ERRORS_TOTAL: &str = "udp_tracker_server_errors_total"; +pub const UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS: &str = "udp_tracker_server_performance_avg_processing_time_ns"; +pub const UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL: &str = + "udp_tracker_server_performance_avg_processed_requests_total"; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests aborted")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests banned")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of IPs banned from UDP requests")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_CONNECTION_ID_ERRORS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of requests with connection ID errors")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests received")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests accepted")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP responses sent")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new("Total number of errors processing UDP requests")), + ); + + metrics.metric_collection.describe_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + Some(Unit::Nanoseconds), + Some(MetricDescription::new("Average time to process a UDP request in nanoseconds")), + ); + + metrics.metric_collection.describe_counter( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL), + Some(Unit::Count), + Some(MetricDescription::new( + "Total number of UDP requests processed for the average performance metrics", + )), + ); + + metrics +} diff --git a/packages/udp-tracker-server/src/statistics/repository.rs b/packages/udp-tracker-server/src/statistics/repository.rs new file mode 100644 index 000000000..94a86e3ab --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/repository.rs @@ -0,0 +1,775 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::Error; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::describe_metrics; +use super::metrics::Metrics; + +/// A repository for the tracker metrics. +#[derive(Clone)] +pub struct Repository { + pub stats: Arc>, +} + +impl Default for Repository { + fn default() -> Self { + Self::new() + } +} + +impl Repository { + #[must_use] + pub fn new() -> Self { + Self { + stats: Arc::new(RwLock::new(describe_metrics())), + } + } + + pub async fn get_stats(&self) -> RwLockReadGuard<'_, Metrics> { + self.stats.read().await + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increase the counter. + pub async fn increase_counter( + &self, + metric_name: &MetricName, + labels: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.increase_counter(metric_name, labels, now); + + drop(stats_lock); + + result + } + + /// # Errors + /// + /// This function will return an error if the metric collection fails to + /// increase the counter. + pub async fn set_gauge( + &self, + metric_name: &MetricName, + labels: &LabelSet, + value: f64, + now: DurationSinceUnixEpoch, + ) -> Result<(), Error> { + let mut stats_lock = self.stats.write().await; + + let result = stats_lock.set_gauge(metric_name, labels, value, now); + + drop(stats_lock); + + result + } + + pub async fn recalculate_udp_avg_processing_time_ns( + &self, + req_processing_time: Duration, + label_set: &LabelSet, + now: DurationSinceUnixEpoch, + ) -> f64 { + let mut stats_lock = self.stats.write().await; + + let new_avg = stats_lock.recalculate_udp_avg_processing_time_ns(req_processing_time, label_set, now); + + drop(stats_lock); + + new_avg + } +} + +#[cfg(test)] +mod tests { + use core::f64; + use std::time::Duration; + + use torrust_tracker_clock::clock::Time; + use torrust_tracker_metrics::metric_collection::aggregate::sum::Sum; + use torrust_tracker_metrics::metric_name; + + use super::*; + use crate::statistics::*; + use crate::CurrentClock; + + #[test] + fn it_should_implement_default() { + let repo = Repository::default(); + assert!(!std::ptr::eq(&repo.stats, &Repository::new().stats)); + } + + #[test] + fn it_should_be_cloneable() { + let repo = Repository::new(); + let cloned_repo = repo.clone(); + assert!(!std::ptr::eq(&repo.stats, &cloned_repo.stats)); + } + + #[tokio::test] + async fn it_should_be_initialized_with_described_metrics() { + let repo = Repository::new(); + let stats = repo.get_stats().await; + + // Check that the described metrics are present + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL))); + assert!(stats + .metric_collection + .contains_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_CONNECTION_ID_ERRORS_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_RESPONSES_SENT_TOTAL))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_ERRORS_TOTAL))); + assert!(stats + .metric_collection + .contains_gauge(&metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS))); + } + + #[tokio::test] + async fn it_should_return_a_read_guard_to_metrics() { + let repo = Repository::new(); + let stats = repo.get_stats().await; + + // Should be able to read metrics through the guard + assert_eq!(stats.udp_requests_aborted_total(), 0); + assert_eq!(stats.udp_requests_banned_total(), 0); + } + + #[tokio::test] + async fn it_should_allow_increasing_a_counter_metric_successfully() { + let repo = Repository::new(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Increase a counter metric + let result = repo + .increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now) + .await; + + assert!(result.is_ok()); + + // Verify the counter was incremented + let stats = repo.get_stats().await; + assert_eq!(stats.udp_requests_aborted_total(), 1); + } + + #[tokio::test] + async fn it_should_allow_increasing_a_counter_multiple_times() { + let repo = Repository::new(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Increase counter multiple times + for _ in 0..5 { + repo.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), &labels, now) + .await + .unwrap(); + } + + // Verify the counter was incremented correctly + let stats = repo.get_stats().await; + assert_eq!(stats.udp_requests_aborted_total(), 5); + } + + #[tokio::test] + async fn it_should_allow_increasing_a_counter_with_different_labels() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + let labels_ipv4 = LabelSet::from([("server_binding_address_ip_family", "inet")]); + let labels_ipv6 = LabelSet::from([("server_binding_address_ip_family", "inet6")]); + + // Increase counters with different labels + repo.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels_ipv4, now) + .await + .unwrap(); + + repo.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_RECEIVED_TOTAL), &labels_ipv6, now) + .await + .unwrap(); + + // Verify both labeled metrics + let stats = repo.get_stats().await; + assert_eq!(stats.udp4_requests_received_total(), 1); + assert_eq!(stats.udp6_requests_received_total(), 1); + } + + #[tokio::test] + async fn it_should_set_a_gauge_metric_successfully() { + let repo = Repository::new(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Set a gauge metric + let result = repo + .set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 42.0, now) + .await; + + assert!(result.is_ok()); + + // Verify the gauge was set + let stats = repo.get_stats().await; + assert_eq!(stats.udp_banned_ips_total(), 42); + } + + #[tokio::test] + async fn it_should_overwrite_previous_value_when_setting_a_gauge_with_a_previous_value() { + let repo = Repository::new(); + let now = CurrentClock::now(); + let labels = LabelSet::empty(); + + // Set gauge to initial value + repo.set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 10.0, now) + .await + .unwrap(); + + // Overwrite with new value + repo.set_gauge(&metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), &labels, 25.0, now) + .await + .unwrap(); + + // Verify the gauge has the new value + let stats = repo.get_stats().await; + assert_eq!(stats.udp_banned_ips_total(), 25); + } + + #[tokio::test] + async fn it_should_allow_setting_a_gauge_with_different_labels() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + let labels_connect = LabelSet::from([("request_kind", "connect")]); + let labels_announce = LabelSet::from([("request_kind", "announce")]); + + // Set gauges with different labels + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels_connect, + 1000.0, + now, + ) + .await + .unwrap(); + + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &labels_announce, + 2000.0, + now, + ) + .await + .unwrap(); + + // Verify both labeled metrics + let stats = repo.get_stats().await; + + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + let udp_avg_connect_processing_time_ns = stats + .metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &[("request_kind", "connect")].into(), + ) + .unwrap_or_default() as u64; + + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + let udp_avg_announce_processing_time_ns = stats + .metric_collection + .sum( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &[("request_kind", "announce")].into(), + ) + .unwrap_or_default() as u64; + + assert_eq!(udp_avg_connect_processing_time_ns, 1000); + assert_eq!(udp_avg_announce_processing_time_ns, 2000); + } + + #[tokio::test] + async fn it_should_recalculate_the_udp_average_connect_processing_time_in_nanoseconds_using_moving_average() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Set initial average to 1000ns + let connect_labels = LabelSet::from([("request_kind", "connect")]); + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &connect_labels, + 1000.0, + now, + ) + .await + .unwrap(); + + // Calculate new average with processing time of 2000ns + // This will increment the processed requests counter from 0 to 1 + let processing_time = Duration::from_nanos(2000); + let new_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &connect_labels, now) + .await; + + // Moving average: previous_avg + (new_value - previous_avg) / processed_requests_total + // With processed_requests_total = 1 (incremented during the call): + // 1000 + (2000 - 1000) / 1 = 1000 + 1000 = 2000 + let expected_avg = 1000.0 + (2000.0 - 1000.0) / 1.0; + assert!( + (new_avg - expected_avg).abs() < 0.01, + "Expected {expected_avg}, got {new_avg}" + ); + } + + #[tokio::test] + async fn it_should_recalculate_the_udp_average_announce_processing_time_in_nanoseconds_using_moving_average() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Set initial average to 500ns + let announce_labels = LabelSet::from([("request_kind", "announce")]); + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &announce_labels, + 500.0, + now, + ) + .await + .unwrap(); + + // Calculate new average with processing time of 1500ns + // This will increment the processed requests counter from 0 to 1 + let processing_time = Duration::from_nanos(1500); + let new_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &announce_labels, now) + .await; + + // Moving average: previous_avg + (new_value - previous_avg) / processed_requests_total + // With processed_requests_total = 1 (incremented during the call): + // 500 + (1500 - 500) / 1 = 500 + 1000 = 1500 + let expected_avg = 500.0 + (1500.0 - 500.0) / 1.0; + assert!( + (new_avg - expected_avg).abs() < 0.01, + "Expected {expected_avg}, got {new_avg}" + ); + } + + #[tokio::test] + async fn it_should_recalculate_the_udp_average_scrape_processing_time_in_nanoseconds_using_moving_average() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Set initial average to 800ns + let scrape_labels = LabelSet::from([("request_kind", "scrape")]); + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), + &scrape_labels, + 800.0, + now, + ) + .await + .unwrap(); + + // Calculate new average with processing time of 1200ns + // This will increment the processed requests counter from 0 to 1 + let processing_time = Duration::from_nanos(1200); + let new_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &scrape_labels, now) + .await; + + // Moving average: previous_avg + (new_value - previous_avg) / processed_requests_total + // With processed_requests_total = 1 (incremented during the call): + // 800 + (1200 - 800) / 1 = 800 + 400 = 1200 + let expected_avg = 800.0 + (1200.0 - 800.0) / 1.0; + assert!( + (new_avg - expected_avg).abs() < 0.01, + "Expected {expected_avg}, got {new_avg}" + ); + } + + #[tokio::test] + async fn recalculate_average_methods_should_handle_zero_connections_gracefully() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Test with zero connections (should not panic, should handle division by zero) + let processing_time = Duration::from_nanos(1000); + + let connect_labels = LabelSet::from([("request_kind", "connect")]); + let connect_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &connect_labels, now) + .await; + + let announce_labels = LabelSet::from([("request_kind", "announce")]); + let announce_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &announce_labels, now) + .await; + + let scrape_labels = LabelSet::from([("request_kind", "scrape")]); + let scrape_avg = repo + .recalculate_udp_avg_processing_time_ns(processing_time, &scrape_labels, now) + .await; + + // With 0 total connections, the formula becomes 0 + (1000 - 0) / 0 + // This should handle the division by zero case gracefully + assert!((connect_avg - 1000.0).abs() < f64::EPSILON); + assert!((announce_avg - 1000.0).abs() < f64::EPSILON); + assert!((scrape_avg - 1000.0).abs() < f64::EPSILON); + } + + #[tokio::test] + async fn it_should_handle_concurrent_access() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Spawn multiple concurrent tasks + let mut handles = vec![]; + + for i in 0..10 { + let repo_clone = repo.clone(); + let handle = tokio::spawn(async move { + for _ in 0..5 { + repo_clone + .increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), + &LabelSet::empty(), + now, + ) + .await + .unwrap(); + } + i + }); + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap(); + } + + // Verify all increments were properly recorded + let stats = repo.get_stats().await; + assert_eq!(stats.udp_requests_aborted_total(), 50); // 10 tasks * 5 increments each + } + + #[tokio::test] + async fn it_should_handle_large_processing_times() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Set up a connection + let ipv4_labels = LabelSet::from([("server_binding_address_ip_family", "inet"), ("request_kind", "connect")]); + repo.increase_counter(&metric_name!(UDP_TRACKER_SERVER_REQUESTS_ACCEPTED_TOTAL), &ipv4_labels, now) + .await + .unwrap(); + + // Test with very large processing time + let large_duration = Duration::from_secs(1); // 1 second = 1,000,000,000 ns + let connect_labels = LabelSet::from([("request_kind", "connect")]); + let new_avg = repo + .recalculate_udp_avg_processing_time_ns(large_duration, &connect_labels, now) + .await; + + // Should handle large numbers without overflow + assert!(new_avg > 0.0); + assert!(new_avg.is_finite()); + } + + #[tokio::test] + async fn it_should_maintain_consistency_across_operations() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Perform a series of operations + repo.increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_ABORTED_TOTAL), + &LabelSet::empty(), + now, + ) + .await + .unwrap(); + + repo.set_gauge( + &metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), + &LabelSet::empty(), + 10.0, + now, + ) + .await + .unwrap(); + + repo.increase_counter( + &metric_name!(UDP_TRACKER_SERVER_REQUESTS_BANNED_TOTAL), + &LabelSet::empty(), + now, + ) + .await + .unwrap(); + + // Check final state + let stats = repo.get_stats().await; + assert_eq!(stats.udp_requests_aborted_total(), 1); + assert_eq!(stats.udp_banned_ips_total(), 10); + assert_eq!(stats.udp_requests_banned_total(), 1); + } + + #[tokio::test] + async fn it_should_handle_error_cases_gracefully() { + let repo = Repository::new(); + let now = CurrentClock::now(); + + // Test with invalid metric name (this should still work as metrics are created dynamically) + let result = repo + .increase_counter(&metric_name!("non_existent_metric"), &LabelSet::empty(), now) + .await; + + // Should succeed as metrics are created on demand + assert!(result.is_ok()); + + // Test with NaN value for gauge + let result = repo + .set_gauge( + &metric_name!(UDP_TRACKER_SERVER_IPS_BANNED_TOTAL), + &LabelSet::empty(), + f64::NAN, + now, + ) + .await; + + // Should handle NaN values + assert!(result.is_ok()); + } + + mod race_conditions { + + use core::f64; + use std::time::Duration; + + use tokio::task::JoinHandle; + use torrust_tracker_clock::clock::Time; + use torrust_tracker_metrics::metric_name; + + use super::*; + use crate::CurrentClock; + + #[tokio::test] + async fn it_should_handle_race_conditions_when_updating_udp_performance_metrics_in_parallel() { + const REQUESTS_PER_SERVER: usize = 100; + + // ** Set up test data and environment ** + + let repo = Repository::new(); + let now = CurrentClock::now(); + + let server1_labels = create_server_metric_labels("6868"); + let server2_labels = create_server_metric_labels("6969"); + + // ** Execute concurrent metric updates ** + + // Spawn concurrent tasks for server 1 with processing times [1000, 2000, 3000, 4000, 5000] ns + let server1_handles = spawn_server_tasks(&repo, &server1_labels, 1000, now, REQUESTS_PER_SERVER); + + // Spawn concurrent tasks for server 2 with processing times [2000, 3000, 4000, 5000, 6000] ns + let server2_handles = spawn_server_tasks(&repo, &server2_labels, 2000, now, REQUESTS_PER_SERVER); + + // Wait for both servers' results + let (server1_results, server2_results) = tokio::join!( + collect_concurrent_task_results(server1_handles), + collect_concurrent_task_results(server2_handles) + ); + + // ** Verify results and metrics ** + + // Verify correctness of concurrent operations + assert_server_results_are_valid(&server1_results, "Server 1", REQUESTS_PER_SERVER); + assert_server_results_are_valid(&server2_results, "Server 2", REQUESTS_PER_SERVER); + + let stats = repo.get_stats().await; + + // Verify each server's metrics individually + let server1_avg = assert_server_metrics_are_correct(&stats, &server1_labels, "Server 1", REQUESTS_PER_SERVER, 3000.0); + let server2_avg = assert_server_metrics_are_correct(&stats, &server2_labels, "Server 2", REQUESTS_PER_SERVER, 4000.0); + + // Verify relationship between servers + assert_server_metrics_relationship(server1_avg, server2_avg); + + // Verify each server's result consistency individually + assert_server_result_matches_stored_average(&server1_results, &stats, &server1_labels, "Server 1"); + assert_server_result_matches_stored_average(&server2_results, &stats, &server2_labels, "Server 2"); + + // Verify metric collection integrity + assert_metric_collection_integrity(&stats); + } + + // Test helper functions to hide implementation details + + fn create_server_metric_labels(port: &str) -> LabelSet { + LabelSet::from([ + ("request_kind", "connect"), + ("server_binding_address_ip_family", "inet"), + ("server_port", port), + ]) + } + + fn spawn_server_tasks( + repo: &Repository, + labels: &LabelSet, + base_processing_time_ns: usize, + now: DurationSinceUnixEpoch, + requests_per_server: usize, + ) -> Vec> { + let mut handles = vec![]; + + for i in 0..requests_per_server { + let repo_clone = repo.clone(); + let labels_clone = labels.clone(); + let handle = tokio::spawn(async move { + let processing_time_ns = base_processing_time_ns + (i % 5) * 1000; + let processing_time = Duration::from_nanos(processing_time_ns as u64); + repo_clone + .recalculate_udp_avg_processing_time_ns(processing_time, &labels_clone, now) + .await + }); + handles.push(handle); + } + + handles + } + + async fn collect_concurrent_task_results(handles: Vec>) -> Vec { + let mut server_results = Vec::new(); + + for handle in handles { + let result = handle.await.unwrap(); + server_results.push(result); + } + + server_results + } + + fn assert_server_results_are_valid(results: &[f64], server_name: &str, expected_count: usize) { + // Verify all tasks completed + assert_eq!( + results.len(), + expected_count, + "{server_name} should have {expected_count} results" + ); + + // Verify all results are valid numbers + for result in results { + assert!(result.is_finite(), "{server_name} result should be finite: {result}"); + assert!(*result > 0.0, "{server_name} result should be positive: {result}"); + } + } + + fn assert_server_metrics_are_correct( + stats: &Metrics, + labels: &LabelSet, + server_name: &str, + expected_request_count: usize, + expected_avg_ns: f64, + ) -> f64 { + // Verify request count + let processed_requests = get_processed_requests_count(stats, labels); + assert_eq!( + processed_requests, expected_request_count as u64, + "{server_name} should have processed {expected_request_count} requests" + ); + + // Verify average processing time is within expected range + let avg_processing_time = get_average_processing_time(stats, labels); + assert!( + (avg_processing_time - expected_avg_ns).abs() < 50.0, + "{server_name} average should be ~{expected_avg_ns}ns (±50ns), got {avg_processing_time}ns" + ); + + avg_processing_time + } + + fn assert_server_metrics_relationship(server1_avg: f64, server2_avg: f64) { + const MIN_DIFFERENCE_NS: f64 = 950.0; + + assert_averages_are_significantly_different(server1_avg, server2_avg, MIN_DIFFERENCE_NS); + assert_server_ordering_is_correct(server1_avg, server2_avg); + } + + fn assert_averages_are_significantly_different(avg1: f64, avg2: f64, min_difference: f64) { + let difference = (avg1 - avg2).abs(); + assert!( + difference > min_difference, + "Server averages should differ by more than {min_difference}ns, but difference was {difference}ns" + ); + } + + fn assert_server_ordering_is_correct(server1_avg: f64, server2_avg: f64) { + // Server 2 should have higher average since it has higher processing times [2000-6000] vs [1000-5000] + assert!( + server2_avg > server1_avg, + "Server 2 average ({server2_avg}ns) should be higher than Server 1 ({server1_avg}ns) due to higher processing time ranges" + ); + } + + fn assert_server_result_matches_stored_average(results: &[f64], stats: &Metrics, labels: &LabelSet, server_name: &str) { + let final_avg = get_average_processing_time(stats, labels); + let last_result = results.last().copied().unwrap(); + + assert!( + (last_result - final_avg).abs() <= f64::EPSILON, + "{server_name} last result ({last_result}) should match final average ({final_avg}) exactly" + ); + } + + fn assert_metric_collection_integrity(stats: &Metrics) { + assert!(stats + .metric_collection + .contains_gauge(&metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS))); + assert!(stats + .metric_collection + .contains_counter(&metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL))); + } + + fn get_processed_requests_count(stats: &Metrics, labels: &LabelSet) -> u64 { + stats + .metric_collection + .get_counter_value( + &metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSED_REQUESTS_TOTAL), + labels, + ) + .unwrap() + .value() + } + + fn get_average_processing_time(stats: &Metrics, labels: &LabelSet) -> f64 { + stats + .metric_collection + .get_gauge_value(&metric_name!(UDP_TRACKER_SERVER_PERFORMANCE_AVG_PROCESSING_TIME_NS), labels) + .unwrap() + .value() + } + } +} diff --git a/packages/udp-tracker-server/src/statistics/services.rs b/packages/udp-tracker-server/src/statistics/services.rs new file mode 100644 index 000000000..0eac01270 --- /dev/null +++ b/packages/udp-tracker-server/src/statistics/services.rs @@ -0,0 +1,105 @@ +//! Statistics services. +//! +//! It includes: +//! +//! - A [`factory`](crate::statistics::setup::factory) function to build the structs needed to collect the tracker metrics. +//! - A [`get_metrics`] service to get the tracker [`metrics`](crate::statistics::metrics::Metrics). +//! +//! Tracker metrics are collected using a Publisher-Subscribe pattern. +//! +//! The factory function builds two structs: +//! +//! - An statistics event [`Sender`](crate::statistics::event::sender::Sender) +//! - An statistics [`Repository`] +//! +//! ```text +//! let (stats_event_sender, stats_repository) = factory(tracker_usage_statistics); +//! ``` +//! +//! The statistics repository is responsible for storing the metrics in memory. +//! The statistics event sender allows sending events related to metrics. +//! There is an event listener that is receiving all the events and processing them with an event handler. +//! Then, the event handler updates the metrics depending on the received event. +//! +//! For example, if you send the event [`Event::Udp4Connect`](crate::statistics::event::Event::Udp4Connect): +//! +//! ```text +//! let result = event_sender.send_event(Event::Udp4Connect).await; +//! ``` +//! +//! Eventually the counter for UDP connections from IPv4 peers will be increased. +//! +//! ```rust,no_run +//! pub struct Metrics { +//! // ... +//! pub udp4_connections_handled: u64, // This will be incremented +//! // ... +//! } +//! ``` +use std::sync::Arc; + +use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; +use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + +use crate::statistics::metrics::Metrics; +use crate::statistics::repository::Repository; + +/// All the metrics collected by the tracker. +#[derive(Debug, PartialEq)] +pub struct TrackerMetrics { + /// Domain level metrics. + /// + /// General metrics for all torrents (number of seeders, leechers, etcetera) + pub torrents_metrics: AggregateActiveSwarmMetadata, + + /// Application level metrics. Usage statistics/metrics. + /// + /// Metrics about how the tracker is been used (number of udp announce requests, etcetera) + pub protocol_metrics: Metrics, +} + +/// It returns all the [`TrackerMetrics`] +pub async fn get_metrics( + in_memory_torrent_repository: Arc, + stats_repository: Arc, +) -> TrackerMetrics { + let torrents_metrics = in_memory_torrent_repository.get_aggregate_swarm_metadata().await; + let stats = stats_repository.get_stats().await; + + TrackerMetrics { + torrents_metrics, + protocol_metrics: Metrics { + metric_collection: stats.metric_collection.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepository; + use bittorrent_tracker_core::{self}; + use torrust_tracker_primitives::swarm_metadata::AggregateActiveSwarmMetadata; + + use crate::statistics::describe_metrics; + use crate::statistics::repository::Repository; + use crate::statistics::services::{get_metrics, TrackerMetrics}; + + #[tokio::test] + async fn the_statistics_service_should_return_the_tracker_metrics() { + let in_memory_torrent_repository = Arc::new(InMemoryTorrentRepository::default()); + + let stats_repository = Arc::new(Repository::new()); + + let tracker_metrics = get_metrics(in_memory_torrent_repository.clone(), stats_repository.clone()).await; + + assert_eq!( + tracker_metrics, + TrackerMetrics { + torrents_metrics: AggregateActiveSwarmMetadata::default(), + protocol_metrics: describe_metrics(), + } + ); + } +} diff --git a/packages/udp-tracker-server/tests/common/fixtures.rs b/packages/udp-tracker-server/tests/common/fixtures.rs new file mode 100644 index 000000000..f4066c67a --- /dev/null +++ b/packages/udp-tracker-server/tests/common/fixtures.rs @@ -0,0 +1,17 @@ +use aquatic_udp_protocol::TransactionId; +use bittorrent_primitives::info_hash::InfoHash; +use rand::prelude::*; + +/// Returns a random info hash. +pub fn random_info_hash() -> InfoHash { + let mut rng = rand::rng(); + let random_bytes: [u8; 20] = rng.random(); + + InfoHash::from_bytes(&random_bytes) +} + +/// Returns a random transaction id. +pub fn random_transaction_id() -> TransactionId { + let random_value = rand::rng().random(); + TransactionId::new(random_value) +} diff --git a/packages/udp-tracker-server/tests/common/mod.rs b/packages/udp-tracker-server/tests/common/mod.rs new file mode 100644 index 000000000..d327fd14f --- /dev/null +++ b/packages/udp-tracker-server/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod fixtures; +pub mod udp; diff --git a/tests/common/udp.rs b/packages/udp-tracker-server/tests/common/udp.rs similarity index 100% rename from tests/common/udp.rs rename to packages/udp-tracker-server/tests/common/udp.rs diff --git a/packages/udp-tracker-server/tests/integration.rs b/packages/udp-tracker-server/tests/integration.rs new file mode 100644 index 000000000..70b3aeb89 --- /dev/null +++ b/packages/udp-tracker-server/tests/integration.rs @@ -0,0 +1,20 @@ +//! Integration tests. +//! +//! ```text +//! cargo test --test integration +//! ``` +mod common; +mod server; + +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; diff --git a/tests/servers/udp/asserts.rs b/packages/udp-tracker-server/tests/server/asserts.rs similarity index 73% rename from tests/servers/udp/asserts.rs rename to packages/udp-tracker-server/tests/server/asserts.rs index bf8fb6728..37c848e06 100644 --- a/tests/servers/udp/asserts.rs +++ b/packages/udp-tracker-server/tests/server/asserts.rs @@ -1,9 +1,9 @@ use aquatic_udp_protocol::{Response, TransactionId}; -pub fn is_error_response(response: &Response, error_message: &str) -> bool { +pub fn get_error_response_message(response: &Response) -> Option { match response { - Response::Error(error_response) => error_response.message.starts_with(error_message), - _ => false, + Response::Error(error_response) => Some(error_response.message.to_string()), + _ => None, } } diff --git a/packages/udp-tracker-server/tests/server/contract.rs b/packages/udp-tracker-server/tests/server/contract.rs new file mode 100644 index 000000000..e9691c879 --- /dev/null +++ b/packages/udp-tracker-server/tests/server/contract.rs @@ -0,0 +1,354 @@ +// UDP tracker documentation: +// +// BEP 15. UDP Tracker Protocol for BitTorrent +// https://www.bittorrent.org/beps/bep_0015.html + +use core::panic; + +use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; +use bittorrent_tracker_client::udp::client::UdpTrackerClient; +use torrust_tracker_configuration::DEFAULT_TIMEOUT; +use torrust_tracker_test_helpers::{configuration, logging}; +use torrust_udp_tracker_server::MAX_PACKET_SIZE; + +use crate::server::asserts::get_error_response_message; + +fn empty_udp_request() -> [u8; MAX_PACKET_SIZE] { + [0; MAX_PACKET_SIZE] +} + +async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrackerClient) -> ConnectionId { + let connect_request = ConnectRequest { transaction_id }; + + match client.send(connect_request.into()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + let response = match client.receive().await { + Ok(response) => response, + Err(err) => panic!("{err}"), + }; + + match response { + Response::Connect(connect_response) => connect_response.connection_id, + _ => panic!("error connecting to udp server {:?}", response), + } +} + +#[tokio::test] +async fn should_return_a_bad_request_response_when_the_client_sends_an_empty_request() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_client) => udp_client, + Err(err) => panic!("{err}"), + }; + + match client.client.send(&empty_udp_request()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + let response = match client.client.receive().await { + Ok(response) => response, + Err(err) => panic!("{err}"), + }; + + let response = Response::parse_bytes(&response, true).unwrap(); + + assert!(get_error_response_message(&response) + .unwrap() + .contains("Protocol identifier missing")); + + env.stop().await; +} + +mod receiving_a_connection_request { + use aquatic_udp_protocol::{ConnectRequest, TransactionId}; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; + use torrust_tracker_configuration::DEFAULT_TIMEOUT; + use torrust_tracker_test_helpers::{configuration, logging}; + + use crate::server::asserts::is_connect_response; + + #[tokio::test] + async fn should_return_a_connect_response() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_tracker_client) => udp_tracker_client, + Err(err) => panic!("{err}"), + }; + + let connect_request = ConnectRequest { + transaction_id: TransactionId::new(123), + }; + + match client.send(connect_request.into()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + let response = match client.receive().await { + Ok(response) => response, + Err(err) => panic!("{err}"), + }; + + assert!(is_connect_response(&response, TransactionId::new(123))); + + env.stop().await; + } +} + +mod receiving_an_announce_request { + use std::net::Ipv4Addr; + + use aquatic_udp_protocol::{ + AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, + PeerKey, Port, TransactionId, + }; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; + use torrust_tracker_configuration::DEFAULT_TIMEOUT; + use torrust_tracker_test_helpers::logging::logs_contains_a_line_with; + use torrust_tracker_test_helpers::{configuration, logging}; + + use crate::common::fixtures::{random_info_hash, random_transaction_id}; + use crate::server::asserts::is_ipv4_announce_response; + use crate::server::contract::send_connection_request; + + pub async fn assert_send_and_get_announce( + tx_id: TransactionId, + c_id: ConnectionId, + info_hash: bittorrent_primitives::info_hash::InfoHash, + client: &UdpTrackerClient, + ) { + let response = send_and_get_announce(tx_id, c_id, info_hash, client).await; + assert!(is_ipv4_announce_response(&response)); + } + + pub async fn send_and_get_announce( + tx_id: TransactionId, + c_id: ConnectionId, + info_hash: bittorrent_primitives::info_hash::InfoHash, + client: &UdpTrackerClient, + ) -> aquatic_udp_protocol::Response { + let announce_request = + build_sample_announce_request(tx_id, c_id, client.client.socket.local_addr().unwrap().port(), info_hash); + + match client.send(announce_request.into()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + match client.receive().await { + Ok(response) => response, + Err(err) => panic!("{err}"), + } + } + + fn build_sample_announce_request( + tx_id: TransactionId, + c_id: ConnectionId, + port: u16, + info_hash: bittorrent_primitives::info_hash::InfoHash, + ) -> AnnounceRequest { + AnnounceRequest { + connection_id: ConnectionId(c_id.0), + action_placeholder: AnnounceActionPlaceholder::default(), + transaction_id: tx_id, + info_hash: InfoHash(info_hash.0), + peer_id: PeerId([255u8; 20]), + bytes_downloaded: NumberOfBytes(0i64.into()), + bytes_uploaded: NumberOfBytes(0i64.into()), + bytes_left: NumberOfBytes(0i64.into()), + event: AnnounceEvent::Started.into(), + ip_address: Ipv4Addr::UNSPECIFIED.into(), + key: PeerKey::new(0i32), + peers_wanted: NumberOfPeers(1i32.into()), + port: Port(port.into()), + } + } + + #[tokio::test] + async fn should_return_an_announce_response() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_tracker_client) => udp_tracker_client, + Err(err) => panic!("{err}"), + }; + + let tx_id = TransactionId::new(123); + + let c_id = send_connection_request(tx_id, &client).await; + + let info_hash = random_info_hash(); + + assert_send_and_get_announce(tx_id, c_id, info_hash, &client).await; + + env.stop().await; + } + + #[tokio::test] + async fn should_return_many_announce_response() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_tracker_client) => udp_tracker_client, + Err(err) => panic!("{err}"), + }; + + let tx_id = TransactionId::new(123); + + let c_id = send_connection_request(tx_id, &client).await; + + let info_hash = random_info_hash(); + + for x in 0..1000 { + tracing::info!("req no: {x}"); + assert_send_and_get_announce(tx_id, c_id, info_hash, &client).await; + } + + env.stop().await; + } + + #[tokio::test] + async fn should_ban_the_client_ip_if_it_sends_more_than_10_requests_with_a_cookie_value_not_normal() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + let ban_service = env.container.udp_tracker_core_container.ban_service.clone(); + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_tracker_client) => udp_tracker_client, + Err(err) => panic!("{err}"), + }; + + let udp_banned_ips_total_before = ban_service.read().await.get_banned_ips_total(); + + // The eleven first requests should be fine + + let invalid_connection_id = ConnectionId::new(0); // Zero is one of the not normal values. + + let info_hash = random_info_hash(); + + for x in 0..=10 { + tracing::info!("req no: {x}"); + + let tx_id = random_transaction_id(); + + send_and_get_announce(tx_id, invalid_connection_id, info_hash, &client).await; + + let transaction_id = tx_id.0.to_string(); + + assert!( + logs_contains_a_line_with(&["ERROR", "UDP TRACKER", &transaction_id]), + "Expected logs to contain: ERROR ... UDP TRACKER ... transaction_id={transaction_id}" + ); + } + + // The twelfth request should be banned (timeout error) + + let tx_id = random_transaction_id(); + + let announce_request = build_sample_announce_request( + tx_id, + invalid_connection_id, + client.client.socket.local_addr().unwrap().port(), + info_hash, + ); + + let udp_requests_banned_before = env + .container + .udp_tracker_server_container + .stats_repository + .get_stats() + .await + .udp_requests_banned_total(); + + // This should return a timeout error + match client.send(announce_request.into()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + assert!(client.receive().await.is_err()); + + let udp_requests_banned_after = env + .container + .udp_tracker_server_container + .stats_repository + .get_stats() + .await + .udp_requests_banned_total(); + let udp_banned_ips_total_after = ban_service.read().await.get_banned_ips_total(); + + // UDP counter for banned requests should be increased by 1 + assert_eq!(udp_requests_banned_after, udp_requests_banned_before + 1); + + // UDP counter for banned IPs should be increased by 1 + assert_eq!(udp_banned_ips_total_after, udp_banned_ips_total_before + 1); + + env.stop().await; + } +} + +mod receiving_an_scrape_request { + use aquatic_udp_protocol::{ConnectionId, InfoHash, ScrapeRequest, TransactionId}; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; + use torrust_tracker_configuration::DEFAULT_TIMEOUT; + use torrust_tracker_test_helpers::{configuration, logging}; + + use crate::server::asserts::is_scrape_response; + use crate::server::contract::send_connection_request; + + #[tokio::test] + async fn should_return_a_scrape_response() { + logging::setup(); + + let env = torrust_udp_tracker_server::environment::Started::new(&configuration::ephemeral().into()).await; + + let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { + Ok(udp_tracker_client) => udp_tracker_client, + Err(err) => panic!("{err}"), + }; + + let connection_id = send_connection_request(TransactionId::new(123), &client).await; + + // Send scrape request + + // Full scrapes are not allowed you need to pass an array of info hashes otherwise + // it will return "bad request" error with empty vector + + let empty_info_hash = vec![InfoHash([0u8; 20])]; + + let scrape_request = ScrapeRequest { + connection_id: ConnectionId(connection_id.0), + transaction_id: TransactionId::new(123i32), + info_hashes: empty_info_hash, + }; + + match client.send(scrape_request.into()).await { + Ok(_) => (), + Err(err) => panic!("{err}"), + } + + let response = match client.receive().await { + Ok(response) => response, + Err(err) => panic!("{err}"), + }; + + assert!(is_scrape_response(&response)); + + env.stop().await; + } +} diff --git a/packages/udp-tracker-server/tests/server/mod.rs b/packages/udp-tracker-server/tests/server/mod.rs new file mode 100644 index 000000000..e2db6b4ce --- /dev/null +++ b/packages/udp-tracker-server/tests/server/mod.rs @@ -0,0 +1,2 @@ +pub mod asserts; +pub mod contract; diff --git a/share/default/config/tracker.development.sqlite3.toml b/share/default/config/tracker.development.sqlite3.toml index 96addaf87..17a73a1d2 100644 --- a/share/default/config/tracker.development.sqlite3.toml +++ b/share/default/config/tracker.development.sqlite3.toml @@ -7,14 +7,30 @@ schema_version = "2.0.0" threshold = "info" [core] +inactive_peer_cleanup_interval = 120 listed = false private = false +[core.tracker_policy] +max_peer_timeout = 60 +persistent_torrent_completed_stat = true +remove_peerless_torrents = true + +[[udp_trackers]] +bind_address = "0.0.0.0:6868" +tracker_usage_statistics = true + [[udp_trackers]] bind_address = "0.0.0.0:6969" +tracker_usage_statistics = true [[http_trackers]] bind_address = "0.0.0.0:7070" +tracker_usage_statistics = true + +[[http_trackers]] +bind_address = "0.0.0.0:7171" +tracker_usage_statistics = true [http_api] bind_address = "0.0.0.0:1212" diff --git a/share/default/config/tracker.udp.benchmarking.toml b/share/default/config/tracker.udp.benchmarking.toml index c6644d8dc..8a898153a 100644 --- a/share/default/config/tracker.udp.benchmarking.toml +++ b/share/default/config/tracker.udp.benchmarking.toml @@ -18,4 +18,4 @@ persistent_torrent_completed_stat = false remove_peerless_torrents = false [[udp_trackers]] -bind_address = "0.0.0.0:6969" +bind_address = "0.0.0.0:3000" diff --git a/src/app.rs b/src/app.rs index 06fea4d2e..2149a6d4c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,106 +23,274 @@ //! - Tracker REST API: the tracker API can be enabled/disabled. use std::sync::Arc; -use tokio::task::JoinHandle; -use torrust_tracker_configuration::Configuration; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_configuration::{Configuration, HttpTracker, UdpTracker}; use tracing::instrument; -use crate::bootstrap::jobs::{health_check_api, http_tracker, torrent_cleanup, tracker_apis, udp_tracker}; -use crate::servers::registar::Registar; -use crate::{core, servers}; +use crate::bootstrap::jobs::manager::JobManager; +use crate::bootstrap::jobs::{ + self, activity_metrics_updater, health_check_api, http_tracker, torrent_cleanup, tracker_apis, udp_tracker, +}; +use crate::bootstrap::{self}; +use crate::container::AppContainer; +use crate::CurrentClock; +pub async fn run() -> (Arc, JobManager) { + let (config, app_container) = bootstrap::app::setup(); + + let app_container = Arc::new(app_container); + + let jobs = start(&config, &app_container).await; + + (app_container, jobs) +} + +/// Starts the tracker application. +/// /// # Panics /// /// Will panic if: /// /// - Can't retrieve tracker keys from database. /// - Can't load whitelist from database. -#[instrument(skip(config, tracker))] -pub async fn start(config: &Configuration, tracker: Arc) -> Vec> { +#[instrument(skip(config, app_container))] +pub async fn start(config: &Configuration, app_container: &Arc) -> JobManager { + warn_if_no_services_enabled(config); + + load_data_from_database(config, app_container).await; + + start_jobs(config, app_container).await +} + +async fn load_data_from_database(config: &Configuration, app_container: &Arc) { + load_peer_keys(config, app_container).await; + load_whitelisted_torrents(config, app_container).await; + load_torrent_metrics(config, app_container).await; +} + +async fn start_jobs(config: &Configuration, app_container: &Arc) -> JobManager { + let mut job_manager = JobManager::new(); + + start_swarm_coordination_registry_event_listener(config, app_container, &mut job_manager); + start_tracker_core_event_listener(config, app_container, &mut job_manager); + start_http_core_event_listener(config, app_container, &mut job_manager); + start_udp_core_event_listener(config, app_container, &mut job_manager); + start_udp_server_stats_event_listener(config, app_container, &mut job_manager); + start_udp_server_banning_event_listener(app_container, &mut job_manager); + + start_the_udp_instances(config, app_container, &mut job_manager).await; + start_the_http_instances(config, app_container, &mut job_manager).await; + + start_torrent_cleanup(config, app_container, &mut job_manager); + start_peers_inactivity_update(config, app_container, &mut job_manager); + + start_the_http_api(config, app_container, &mut job_manager).await; + start_health_check_api(config, app_container, &mut job_manager).await; + + job_manager +} + +fn warn_if_no_services_enabled(config: &Configuration) { if config.http_api.is_none() && (config.udp_trackers.is_none() || config.udp_trackers.as_ref().map_or(true, std::vec::Vec::is_empty)) && (config.http_trackers.is_none() || config.http_trackers.as_ref().map_or(true, std::vec::Vec::is_empty)) { tracing::warn!("No services enabled in configuration"); } +} - let mut jobs: Vec> = Vec::new(); - - let registar = Registar::default(); - - // Load peer keys - if tracker.is_private() { - tracker - .load_keys_from_database() +async fn load_peer_keys(config: &Configuration, app_container: &Arc) { + if config.core.private { + app_container + .tracker_core_container + .keys_handler + .load_peer_keys_from_database() .await .expect("Could not retrieve keys from database."); } +} - // Load whitelisted torrents - if tracker.is_listed() { - tracker +async fn load_whitelisted_torrents(config: &Configuration, app_container: &Arc) { + if config.core.listed { + app_container + .tracker_core_container + .whitelist_manager .load_whitelist_from_database() .await .expect("Could not load whitelist from database."); } +} + +async fn load_torrent_metrics(config: &Configuration, app_container: &Arc) { + if config.core.tracker_policy.persistent_torrent_completed_stat { + bittorrent_tracker_core::statistics::persisted::load_persisted_metrics( + &app_container.tracker_core_container.stats_repository, + &app_container.tracker_core_container.db_downloads_metric_repository, + CurrentClock::now(), + ) + .await + .expect("Could not load persisted metrics from database."); + } +} + +fn start_swarm_coordination_registry_event_listener( + config: &Configuration, + app_container: &Arc, + job_manager: &mut JobManager, +) { + job_manager.push_opt( + "swarm_coordination_registry_event_listener", + jobs::torrent_repository::start_event_listener(config, app_container, job_manager.new_cancellation_token()), + ); +} - // Start the UDP blocks +fn start_tracker_core_event_listener(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { + job_manager.push_opt( + "tracker_core_event_listener", + jobs::tracker_core::start_event_listener(config, app_container, job_manager.new_cancellation_token()), + ); +} + +fn start_http_core_event_listener(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { + job_manager.push_opt( + "http_core_event_listener", + jobs::http_tracker_core::start_event_listener(config, app_container, job_manager.new_cancellation_token()), + ); +} + +fn start_udp_core_event_listener(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { + job_manager.push_opt( + "udp_core_event_listener", + jobs::udp_tracker_core::start_event_listener(config, app_container, job_manager.new_cancellation_token()), + ); +} + +fn start_udp_server_stats_event_listener( + config: &Configuration, + app_container: &Arc, + job_manager: &mut JobManager, +) { + job_manager.push_opt( + "udp_server_stats_event_listener", + jobs::udp_tracker_server::start_stats_event_listener(config, app_container, job_manager.new_cancellation_token()), + ); +} + +fn start_udp_server_banning_event_listener(app_container: &Arc, job_manager: &mut JobManager) { + job_manager.push( + "udp_server_banning_event_listener", + jobs::udp_tracker_server::start_banning_event_listener(app_container, job_manager.new_cancellation_token()), + ); +} + +async fn start_the_udp_instances(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { if let Some(udp_trackers) = &config.udp_trackers { - for udp_tracker_config in udp_trackers { - if tracker.is_private() { + for (idx, udp_tracker_config) in udp_trackers.iter().enumerate() { + if config.core.private { tracing::warn!( "Could not start UDP tracker on: {} while in private mode. UDP is not safe for private trackers!", udp_tracker_config.bind_address ); } else { - jobs.push(udp_tracker::start_job(udp_tracker_config, tracker.clone(), registar.give_form()).await); + start_udp_instance(idx, udp_tracker_config, app_container, job_manager).await; } } } else { tracing::info!("No UDP blocks in configuration"); } +} + +async fn start_udp_instance( + idx: usize, + udp_tracker_config: &UdpTracker, + app_container: &Arc, + job_manager: &mut JobManager, +) { + let udp_tracker_container = app_container + .udp_tracker_container(udp_tracker_config.bind_address) + .expect("Could not create UDP tracker container"); + let udp_tracker_server_container = app_container.udp_tracker_server_container(); + + let handle = udp_tracker::start_job( + udp_tracker_container, + udp_tracker_server_container, + app_container.registar.give_form(), + ) + .await; - // Start the HTTP blocks + job_manager.push(format!("udp_instance_{}_{}", idx, udp_tracker_config.bind_address), handle); +} + +async fn start_the_http_instances(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { if let Some(http_trackers) = &config.http_trackers { - for http_tracker_config in http_trackers { - if let Some(job) = http_tracker::start_job( - http_tracker_config, - tracker.clone(), - registar.give_form(), - servers::http::Version::V1, - ) - .await - { - jobs.push(job); - }; + for (idx, http_tracker_config) in http_trackers.iter().enumerate() { + start_http_instance(idx, http_tracker_config, app_container, job_manager).await; } } else { tracing::info!("No HTTP blocks in configuration"); } +} - // Start HTTP API +async fn start_http_instance( + idx: usize, + http_tracker_config: &HttpTracker, + app_container: &Arc, + job_manager: &mut JobManager, +) { + let http_tracker_container = app_container + .http_tracker_container(http_tracker_config.bind_address) + .expect("Could not create HTTP tracker container"); + + if let Some(handle) = http_tracker::start_job( + http_tracker_container, + app_container.registar.give_form(), + torrust_axum_http_tracker_server::Version::V1, + ) + .await + { + job_manager.push(format!("http_instance_{}_{}", idx, http_tracker_config.bind_address), handle); + } +} + +async fn start_the_http_api(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { if let Some(http_api_config) = &config.http_api { + let http_api_config = Arc::new(http_api_config.clone()); + let http_api_container = app_container.tracker_http_api_container(&http_api_config); + if let Some(job) = tracker_apis::start_job( - http_api_config, - tracker.clone(), - registar.give_form(), - servers::apis::Version::V1, + http_api_container, + app_container.registar.give_form(), + torrust_axum_rest_tracker_api_server::Version::V1, ) .await { - jobs.push(job); - }; + job_manager.push("http_api", job); + } } else { tracing::info!("No API block in configuration"); } +} - // Start runners to remove torrents without peers, every interval +fn start_torrent_cleanup(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { if config.core.inactive_peer_cleanup_interval > 0 { - jobs.push(torrent_cleanup::start_job(&config.core, &tracker)); + let handle = torrent_cleanup::start_job(&config.core, &app_container.tracker_core_container.torrents_manager); + + job_manager.push("torrent_cleanup", handle); } +} + +fn start_peers_inactivity_update(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { + if config.core.tracker_usage_statistics { + let handle = activity_metrics_updater::start_job(config, app_container); + + job_manager.push("peers_inactivity_update", handle); + } else { + tracing::info!("Peers inactivity update job is disabled."); + } +} - // Start Health Check API - jobs.push(health_check_api::start_job(&config.health_check_api, registar.entries()).await); +async fn start_health_check_api(config: &Configuration, app_container: &Arc, job_manager: &mut JobManager) { + let handle = health_check_api::start_job(&config.health_check_api, app_container.registar.entries()).await; - jobs + job_manager.push("health_check_api", handle); } diff --git a/src/bin/e2e_tests_runner.rs b/src/bin/e2e_tests_runner.rs index eb91c0d86..5787799dc 100644 --- a/src/bin/e2e_tests_runner.rs +++ b/src/bin/e2e_tests_runner.rs @@ -1,5 +1,5 @@ //! Program to run E2E tests. -use torrust_tracker::console::ci::e2e; +use torrust_tracker_lib::console::ci::e2e; fn main() -> anyhow::Result<()> { e2e::runner::run() diff --git a/src/bin/profiling.rs b/src/bin/profiling.rs index bc1ac6526..aca6ab98d 100644 --- a/src/bin/profiling.rs +++ b/src/bin/profiling.rs @@ -1,6 +1,6 @@ //! This binary is used for profiling with [valgrind](https://valgrind.org/) //! and [kcachegrind](https://kcachegrind.github.io/). -use torrust_tracker::console::profiling::run; +use torrust_tracker_lib::console::profiling::run; #[tokio::main] async fn main() { diff --git a/src/bootstrap/app.rs b/src/bootstrap/app.rs index 7c0cf45ac..bcf000dfd 100644 --- a/src/bootstrap/app.rs +++ b/src/bootstrap/app.rs @@ -11,49 +11,57 @@ //! 2. Initialize static variables. //! 3. Initialize logging. //! 4. Initialize the domain tracker. -use std::sync::Arc; - -use torrust_tracker_clock::static_time; +use bittorrent_udp_tracker_core::crypto::keys::{self, Keeper as _}; use torrust_tracker_configuration::validator::Validator; -use torrust_tracker_configuration::Configuration; +use torrust_tracker_configuration::{logging, Configuration}; use tracing::instrument; use super::config::initialize_configuration; -use crate::bootstrap; -use crate::core::services::tracker_factory; -use crate::core::Tracker; -use crate::shared::crypto::ephemeral_instance_keys; +use crate::container::AppContainer; -/// It loads the configuration from the environment and builds the main domain [`Tracker`] struct. +/// It loads the configuration from the environment and builds app container. /// /// # Panics /// /// Setup can file if the configuration is invalid. #[must_use] #[instrument(skip())] -pub fn setup() -> (Configuration, Arc) { +pub fn setup() -> (Configuration, AppContainer) { + #[cfg(not(test))] + check_seed(); + let configuration = initialize_configuration(); if let Err(e) = configuration.validate() { panic!("Configuration error: {e}"); } - let tracker = initialize_with_configuration(&configuration); + initialize_global_services(&configuration); tracing::info!("Configuration:\n{}", configuration.clone().mask_secrets().to_json()); - (configuration, tracker) + let app_container = AppContainer::initialize(&configuration); + + (configuration, app_container) } -/// It initializes the application with the given configuration. +/// checks if the seed is the instance seed in production. /// -/// The configuration may be obtained from the environment (via config file or env vars). -#[must_use] +/// # Panics +/// +/// It would panic if the seed is not the instance seed. +pub fn check_seed() { + let seed = keys::Current::get_seed(); + let instance = keys::Instance::get_seed(); + + assert_eq!(seed, instance, "maybe using zeroed seed in production!?"); +} + +/// It initializes the global services. #[instrument(skip())] -pub fn initialize_with_configuration(configuration: &Configuration) -> Arc { +pub fn initialize_global_services(configuration: &Configuration) { initialize_static(); - initialize_logging(configuration); - Arc::new(initialize_tracker(configuration)) + logging::setup(&configuration.logging); } /// It initializes the application static values. @@ -61,30 +69,10 @@ pub fn initialize_with_configuration(configuration: &Configuration) -> Arc Tracker { - tracker_factory(config) -} - -/// It initializes the log threshold, format and channel. -/// -/// See [the logging setup](crate::bootstrap::logging::setup) for more info about logging. -#[instrument(skip(config))] -pub fn initialize_logging(config: &Configuration) { - bootstrap::logging::setup(config); + torrust_tracker_clock::initialize_static(); + bittorrent_udp_tracker_core::initialize_static(); } diff --git a/src/bootstrap/jobs/activity_metrics_updater.rs b/src/bootstrap/jobs/activity_metrics_updater.rs new file mode 100644 index 000000000..9bbdc3f9b --- /dev/null +++ b/src/bootstrap/jobs/activity_metrics_updater.rs @@ -0,0 +1,27 @@ +//! Job that runs a task on intervals to update peers' activity metrics. +use std::sync::Arc; +use std::time::Duration; + +use tokio::task::JoinHandle; +use torrust_tracker_clock::clock::Time; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; +use crate::CurrentClock; + +#[must_use] +pub fn start_job(config: &Configuration, app_container: &Arc) -> JoinHandle<()> { + torrust_tracker_swarm_coordination_registry::statistics::activity_metrics_updater::start_job( + &app_container.swarm_coordination_registry_container.swarms.clone(), + &app_container.swarm_coordination_registry_container.stats_repository.clone(), + peer_inactivity_cutoff_timestamp(config.core.tracker_policy.max_peer_timeout), + ) +} + +/// Returns the timestamp of the cutoff for inactive peers. +/// +/// Peers that has not been updated for more than `max_peer_timeout` seconds are +/// considered inactive. +fn peer_inactivity_cutoff_timestamp(max_peer_timeout: u32) -> Duration { + CurrentClock::now_sub(&Duration::from_secs(u64::from(max_peer_timeout))).unwrap_or_default() +} diff --git a/src/bootstrap/jobs/health_check_api.rs b/src/bootstrap/jobs/health_check_api.rs index b6250efcc..7c529fadd 100644 --- a/src/bootstrap/jobs/health_check_api.rs +++ b/src/bootstrap/jobs/health_check_api.rs @@ -3,7 +3,7 @@ //! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) //! function starts the Health Check REST API. //! -//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) +//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) //! function spawns a new asynchronous task, that tasks is the "**launcher**". //! The "**launcher**" starts the actual server and sends a message back //! to the main application. @@ -16,15 +16,13 @@ use tokio::sync::oneshot; use tokio::task::JoinHandle; +use torrust_axum_health_check_api_server::{server, HEALTH_CHECK_API_LOG_TARGET}; +use torrust_server_lib::logging::STARTED_ON; +use torrust_server_lib::registar::ServiceRegistry; +use torrust_server_lib::signals::{Halted, Started}; use torrust_tracker_configuration::HealthCheckApi; use tracing::instrument; -use super::Started; -use crate::servers::health_check_api::{server, HEALTH_CHECK_API_LOG_TARGET}; -use crate::servers::logging::STARTED_ON; -use crate::servers::registar::ServiceRegistry; -use crate::servers::signals::Halted; - /// This function starts a new Health Check API server with the provided /// configuration. /// diff --git a/src/bootstrap/jobs/http_tracker.rs b/src/bootstrap/jobs/http_tracker.rs index c55723bc6..013031395 100644 --- a/src/bootstrap/jobs/http_tracker.rs +++ b/src/bootstrap/jobs/http_tracker.rs @@ -14,16 +14,14 @@ use std::net::SocketAddr; use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; +use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; use tokio::task::JoinHandle; -use torrust_tracker_configuration::HttpTracker; +use torrust_axum_http_tracker_server::server::{HttpServer, Launcher}; +use torrust_axum_http_tracker_server::Version; +use torrust_axum_server::tsl::make_rust_tls; +use torrust_server_lib::registar::ServiceRegistrationForm; use tracing::instrument; -use super::make_rust_tls; -use crate::core; -use crate::servers::http::server::{HttpServer, Launcher}; -use crate::servers::http::Version; -use crate::servers::registar::ServiceRegistrationForm; - /// It starts a new HTTP server with the provided configuration and version. /// /// Right now there is only one version but in the future we could support more than one HTTP tracker version at the same time. @@ -32,35 +30,33 @@ use crate::servers::registar::ServiceRegistrationForm; /// # Panics /// /// It would panic if the `config::HttpTracker` struct would contain inappropriate values. -/// -#[instrument(skip(config, tracker, form))] +#[instrument(skip(http_tracker_container, form))] pub async fn start_job( - config: &HttpTracker, - tracker: Arc, + http_tracker_container: Arc, form: ServiceRegistrationForm, version: Version, ) -> Option> { - let socket = config.bind_address; + let socket = http_tracker_container.http_tracker_config.bind_address; - let tls = make_rust_tls(&config.tsl_config) + let tls = make_rust_tls(&http_tracker_container.http_tracker_config.tsl_config) .await .map(|tls| tls.expect("it should have a valid http tracker tls configuration")); match version { - Version::V1 => Some(start_v1(socket, tls, tracker.clone(), form).await), + Version::V1 => Some(start_v1(socket, tls, http_tracker_container, form).await), } } #[allow(clippy::async_yields_async)] -#[instrument(skip(socket, tls, tracker, form))] +#[instrument(skip(socket, tls, http_tracker_container, form))] async fn start_v1( socket: SocketAddr, tls: Option, - tracker: Arc, + http_tracker_container: Arc, form: ServiceRegistrationForm, ) -> JoinHandle<()> { let server = HttpServer::new(Launcher::new(socket, tls)) - .start(tracker, form) + .start(http_tracker_container, form) .await .expect("it should be able to start to the http tracker"); @@ -81,22 +77,28 @@ async fn start_v1( mod tests { use std::sync::Arc; + use bittorrent_http_tracker_core::container::HttpTrackerCoreContainer; + use torrust_axum_http_tracker_server::Version; + use torrust_server_lib::registar::Registar; use torrust_tracker_test_helpers::configuration::ephemeral_public; - use crate::bootstrap::app::initialize_with_configuration; + use crate::bootstrap::app::initialize_global_services; use crate::bootstrap::jobs::http_tracker::start_job; - use crate::servers::http::Version; - use crate::servers::registar::Registar; #[tokio::test] async fn it_should_start_http_tracker() { let cfg = Arc::new(ephemeral_public()); + let core_config = Arc::new(cfg.core.clone()); let http_tracker = cfg.http_trackers.clone().expect("missing HTTP tracker configuration"); - let config = &http_tracker[0]; - let tracker = initialize_with_configuration(&cfg); + let http_tracker_config = Arc::new(http_tracker[0].clone()); + + initialize_global_services(&cfg); + + let http_tracker_container = HttpTrackerCoreContainer::initialize(&core_config, &http_tracker_config); + let version = Version::V1; - start_job(config, tracker, Registar::default().give_form(), version) + start_job(http_tracker_container, Registar::default().give_form(), version) .await .expect("it should be able to join to the http tracker start-job"); } diff --git a/src/bootstrap/jobs/http_tracker_core.rs b/src/bootstrap/jobs/http_tracker_core.rs new file mode 100644 index 000000000..ab71b9a0f --- /dev/null +++ b/src/bootstrap/jobs/http_tracker_core.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; + +pub fn start_event_listener( + config: &Configuration, + app_container: &Arc, + cancellation_token: CancellationToken, +) -> Option> { + if config.core.tracker_usage_statistics { + let job = bittorrent_http_tracker_core::statistics::event::listener::run_event_listener( + app_container.http_tracker_core_services.event_bus.receiver(), + cancellation_token, + &app_container.http_tracker_core_services.stats_repository, + ); + + Some(job) + } else { + tracing::info!("HTTP tracker core event listener job is disabled."); + None + } +} diff --git a/src/bootstrap/jobs/manager.rs b/src/bootstrap/jobs/manager.rs new file mode 100644 index 000000000..565cd7b73 --- /dev/null +++ b/src/bootstrap/jobs/manager.rs @@ -0,0 +1,119 @@ +use std::time::Duration; + +use tokio::task::JoinHandle; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; + +/// Represents a named background job. +#[derive(Debug)] +pub struct Job { + name: String, + handle: JoinHandle<()>, +} + +impl Job { + pub fn new>(name: N, handle: JoinHandle<()>) -> Self { + Self { + name: name.into(), + handle, + } + } +} + +/// Manages multiple background jobs. +#[derive(Debug, Default)] +pub struct JobManager { + jobs: Vec, + cancellation_token: CancellationToken, +} + +impl JobManager { + #[must_use] + pub fn new() -> Self { + Self { + jobs: Vec::new(), + cancellation_token: CancellationToken::new(), + } + } + + pub fn push>(&mut self, name: N, handle: JoinHandle<()>) { + self.jobs.push(Job::new(name, handle)); + } + + pub fn push_opt>(&mut self, name: N, handle: Option>) { + if let Some(handle) = handle { + self.push(name, handle); + } + } + + #[must_use] + pub fn new_cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + + /// Cancels all jobs using the shared cancellation token. + /// + /// Notice that this does not cancel the jobs immediately, but rather + /// signals them to stop. The jobs themselves must handle the cancellation + /// token appropriately. + /// + /// Notice jobs might be pushed into the manager without a cancellation + /// token, so this method will not cancel those jobs. Some tasks might + /// decide to listen for CTRL+c signal directly, or implement their own + /// cancellation logic. + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + /// Waits sequentially for all jobs to complete, with a graceful timeout per + /// job. + pub async fn wait_for_all(mut self, grace_period: Duration) { + for job in self.jobs.drain(..) { + let name = job.name.clone(); + + info!(job = %name, "Waiting for job to finish (timeout of {} seconds) ...", grace_period.as_secs()); + + if let Ok(result) = timeout(grace_period, job.handle).await { + if let Err(e) = result { + warn!(job = %name, "Job return an error: {:?}", e); + } else { + info!(job = %name, "Job completed gracefully"); + } + } else { + warn!(job = %name, "Job did not complete in time"); + } + } + } +} + +#[cfg(test)] +mod tests { + use tokio::time::Duration; + + use super::*; + + #[tokio::test] + async fn it_should_wait_for_all_jobs_to_finish() { + let mut manager = JobManager::new(); + + manager.push("job1", tokio::spawn(async {})); + manager.push("job2", tokio::spawn(async {})); + + manager.wait_for_all(Duration::from_secs(1)).await; + } + + #[tokio::test] + async fn it_should_log_when_a_job_panics() { + let mut manager = JobManager::new(); + + manager.push( + "panic_job", + tokio::spawn(async { + panic!("expected panic"); + }), + ); + + manager.wait_for_all(Duration::from_secs(1)).await; + } +} diff --git a/src/bootstrap/jobs/mod.rs b/src/bootstrap/jobs/mod.rs index 6e18ec3ba..0e9c912af 100644 --- a/src/bootstrap/jobs/mod.rs +++ b/src/bootstrap/jobs/mod.rs @@ -6,102 +6,15 @@ //! 2. Launch all the application services as concurrent jobs. //! //! This modules contains all the functions needed to start those jobs. +pub mod activity_metrics_updater; pub mod health_check_api; pub mod http_tracker; +pub mod http_tracker_core; +pub mod manager; pub mod torrent_cleanup; +pub mod torrent_repository; pub mod tracker_apis; +pub mod tracker_core; pub mod udp_tracker; - -/// This is the message that the "launcher" spawned task sends to the main -/// application process to notify the service was successfully started. -/// -#[derive(Debug)] -pub struct Started { - pub address: std::net::SocketAddr, -} - -#[instrument(skip(opt_tsl_config))] -pub async fn make_rust_tls(opt_tsl_config: &Option) -> Option> { - match opt_tsl_config { - Some(tsl_config) => { - let cert = tsl_config.ssl_cert_path.clone(); - let key = tsl_config.ssl_key_path.clone(); - - if !cert.exists() || !key.exists() { - return Some(Err(Error::MissingTlsConfig { - location: Location::caller(), - })); - } - - tracing::info!("Using https: cert path: {cert}."); - tracing::info!("Using https: key path: {key}."); - - Some( - RustlsConfig::from_pem_file(cert, key) - .await - .map_err(|err| Error::BadTlsConfig { - source: (Arc::new(err) as DynError).into(), - }), - ) - } - None => None, - } -} - -#[cfg(test)] -mod tests { - - use camino::Utf8PathBuf; - use torrust_tracker_configuration::TslConfig; - - use super::{make_rust_tls, Error}; - - #[tokio::test] - async fn it_should_error_on_bad_tls_config() { - let err = make_rust_tls(&Some(TslConfig { - ssl_cert_path: Utf8PathBuf::from("bad cert path"), - ssl_key_path: Utf8PathBuf::from("bad key path"), - })) - .await - .expect("tls_was_enabled") - .expect_err("bad_cert_and_key_files"); - - assert!(matches!(err, Error::MissingTlsConfig { location: _ })); - } - - #[tokio::test] - async fn it_should_error_on_missing_cert_or_key_paths() { - let err = make_rust_tls(&Some(TslConfig { - ssl_cert_path: Utf8PathBuf::from(""), - ssl_key_path: Utf8PathBuf::from(""), - })) - .await - .expect("tls_was_enabled") - .expect_err("missing_config"); - - assert!(matches!(err, Error::MissingTlsConfig { location: _ })); - } -} - -use std::panic::Location; -use std::sync::Arc; - -use axum_server::tls_rustls::RustlsConfig; -use thiserror::Error; -use torrust_tracker_configuration::TslConfig; -use torrust_tracker_located_error::{DynError, LocatedError}; -use tracing::instrument; - -/// Error returned by the Bootstrap Process. -#[derive(Error, Debug)] -pub enum Error { - /// Enabled tls but missing config. - #[error("tls config missing")] - MissingTlsConfig { location: &'static Location<'static> }, - - /// Unable to parse tls Config. - #[error("bad tls config: {source}")] - BadTlsConfig { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, -} +pub mod udp_tracker_core; +pub mod udp_tracker_server; diff --git a/src/bootstrap/jobs/torrent_cleanup.rs b/src/bootstrap/jobs/torrent_cleanup.rs index 6abb4f26b..8a3a71a44 100644 --- a/src/bootstrap/jobs/torrent_cleanup.rs +++ b/src/bootstrap/jobs/torrent_cleanup.rs @@ -12,23 +12,23 @@ use std::sync::Arc; +use bittorrent_tracker_core::torrent::manager::TorrentsManager; use chrono::Utc; use tokio::task::JoinHandle; use torrust_tracker_configuration::Core; use tracing::instrument; -use crate::core; - /// It starts a jobs for cleaning up the torrent data in the tracker. /// /// The cleaning task is executed on an `inactive_peer_cleanup_interval`. /// /// Refer to [`torrust-tracker-configuration documentation`](https://docs.rs/torrust-tracker-configuration) for more info about that option. #[must_use] -#[instrument(skip(config, tracker))] -pub fn start_job(config: &Core, tracker: &Arc) -> JoinHandle<()> { - let weak_tracker = std::sync::Arc::downgrade(tracker); +#[instrument(skip(config, torrents_manager))] +pub fn start_job(config: &Core, torrents_manager: &Arc) -> JoinHandle<()> { + let weak_torrents_manager = std::sync::Arc::downgrade(torrents_manager); let interval = config.inactive_peer_cleanup_interval; + let interval_in_secs = interval; tokio::spawn(async move { let interval = std::time::Duration::from_secs(interval); @@ -38,15 +38,15 @@ pub fn start_job(config: &Core, tracker: &Arc) -> JoinHandle<()> loop { tokio::select! { _ = tokio::signal::ctrl_c() => { - tracing::info!("Stopping torrent cleanup job.."); + tracing::info!("Stopping torrent cleanup job ..."); break; } _ = interval.tick() => { - if let Some(tracker) = weak_tracker.upgrade() { + if let Some(torrents_manager) = weak_torrents_manager.upgrade() { let start_time = Utc::now().time(); - tracing::info!("Cleaning up torrents.."); - tracker.cleanup_torrents(); - tracing::info!("Cleaned up torrents in: {}ms", (Utc::now().time() - start_time).num_milliseconds()); + tracing::info!("Cleaning up torrents (executed every {} secs) ...", interval_in_secs); + torrents_manager.cleanup_torrents().await; + tracing::info!("Cleaned up torrents in: {} ms", (Utc::now().time() - start_time).num_milliseconds()); } else { break; } diff --git a/src/bootstrap/jobs/torrent_repository.rs b/src/bootstrap/jobs/torrent_repository.rs new file mode 100644 index 000000000..e49323735 --- /dev/null +++ b/src/bootstrap/jobs/torrent_repository.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; + +pub fn start_event_listener( + config: &Configuration, + app_container: &Arc, + cancellation_token: CancellationToken, +) -> Option> { + if config.core.tracker_usage_statistics { + let job = torrust_tracker_swarm_coordination_registry::statistics::event::listener::run_event_listener( + app_container.swarm_coordination_registry_container.event_bus.receiver(), + cancellation_token, + &app_container.swarm_coordination_registry_container.stats_repository, + ); + + Some(job) + } else { + tracing::info!("Torrent repository package event listener job is disabled."); + None + } +} diff --git a/src/bootstrap/jobs/tracker_apis.rs b/src/bootstrap/jobs/tracker_apis.rs index 35b13b7ce..9f3964c20 100644 --- a/src/bootstrap/jobs/tracker_apis.rs +++ b/src/bootstrap/jobs/tracker_apis.rs @@ -7,7 +7,7 @@ //! > versions. API consumers can choose which version to use. The API version is //! > part of the URL, for example: `http://localhost:1212/api/v1/stats`. //! -//! The [`tracker_apis::start_job`](crate::bootstrap::jobs::tracker_apis::start_job) +//! The [`tracker_apis::start_job`](crate::bootstrap::jobs::tracker_apis::start_job) //! function spawns a new asynchronous task, that tasks is the "**launcher**". //! The "**launcher**" starts the actual server and sends a message back //! to the main application. The main application waits until receives @@ -25,15 +25,14 @@ use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; use tokio::task::JoinHandle; -use torrust_tracker_configuration::{AccessTokens, HttpApi}; +use torrust_axum_rest_tracker_api_server::server::{ApiServer, Launcher}; +use torrust_axum_rest_tracker_api_server::Version; +use torrust_axum_server::tsl::make_rust_tls; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; +use torrust_server_lib::registar::ServiceRegistrationForm; +use torrust_tracker_configuration::AccessTokens; use tracing::instrument; -use super::make_rust_tls; -use crate::core; -use crate::servers::apis::server::{ApiServer, Launcher}; -use crate::servers::apis::Version; -use crate::servers::registar::ServiceRegistrationForm; - /// This is the message that the "launcher" spawned task sends to the main /// application process to notify the API server was successfully started. /// @@ -54,37 +53,36 @@ pub struct ApiServerJobStarted(); /// It would panic if unable to send the `ApiServerJobStarted` notice. /// /// -#[instrument(skip(config, tracker, form))] +#[instrument(skip(http_api_container, form))] pub async fn start_job( - config: &HttpApi, - tracker: Arc, + http_api_container: Arc, form: ServiceRegistrationForm, version: Version, ) -> Option> { - let bind_to = config.bind_address; + let bind_to = http_api_container.http_api_config.bind_address; - let tls = make_rust_tls(&config.tsl_config) + let tls = make_rust_tls(&http_api_container.http_api_config.tsl_config) .await .map(|tls| tls.expect("it should have a valid tracker api tls configuration")); - let access_tokens = Arc::new(config.access_tokens.clone()); + let access_tokens = Arc::new(http_api_container.http_api_config.access_tokens.clone()); match version { - Version::V1 => Some(start_v1(bind_to, tls, tracker.clone(), form, access_tokens).await), + Version::V1 => Some(start_v1(bind_to, tls, http_api_container, form, access_tokens).await), } } #[allow(clippy::async_yields_async)] -#[instrument(skip(socket, tls, tracker, form, access_tokens))] +#[instrument(skip(socket, tls, http_api_container, form, access_tokens))] async fn start_v1( socket: SocketAddr, tls: Option, - tracker: Arc, + http_api_container: Arc, form: ServiceRegistrationForm, access_tokens: Arc, ) -> JoinHandle<()> { let server = ApiServer::new(Launcher::new(socket, tls)) - .start(tracker, form, access_tokens) + .start(http_api_container, form, access_tokens) .await .expect("it should be able to start to the tracker api"); @@ -98,21 +96,36 @@ async fn start_v1( mod tests { use std::sync::Arc; + use torrust_axum_rest_tracker_api_server::Version; + use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; + use torrust_server_lib::registar::Registar; use torrust_tracker_test_helpers::configuration::ephemeral_public; - use crate::bootstrap::app::initialize_with_configuration; + use crate::bootstrap::app::initialize_global_services; use crate::bootstrap::jobs::tracker_apis::start_job; - use crate::servers::apis::Version; - use crate::servers::registar::Registar; #[tokio::test] async fn it_should_start_http_tracker() { let cfg = Arc::new(ephemeral_public()); - let config = &cfg.http_api.clone().unwrap(); - let tracker = initialize_with_configuration(&cfg); + + let core_config = Arc::new(cfg.core.clone()); + + let http_tracker_config = cfg.http_trackers.clone().expect("missing HTTP tracker configuration"); + let http_tracker_config = Arc::new(http_tracker_config[0].clone()); + + let udp_tracker_configurations = cfg.udp_trackers.clone().expect("missing UDP tracker configuration"); + let udp_tracker_config = Arc::new(udp_tracker_configurations[0].clone()); + + let http_api_config = Arc::new(cfg.http_api.clone().expect("missing HTTP API configuration").clone()); + + initialize_global_services(&cfg); + + let http_api_container = + TrackerHttpApiCoreContainer::initialize(&core_config, &http_tracker_config, &udp_tracker_config, &http_api_config); + let version = Version::V1; - start_job(config, tracker, Registar::default().give_form(), version) + start_job(http_api_container, Registar::default().give_form(), version) .await .expect("it should be able to join to the tracker api start-job"); } diff --git a/src/bootstrap/jobs/tracker_core.rs b/src/bootstrap/jobs/tracker_core.rs new file mode 100644 index 000000000..d881f4cd2 --- /dev/null +++ b/src/bootstrap/jobs/tracker_core.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; + +pub fn start_event_listener( + config: &Configuration, + app_container: &Arc, + cancellation_token: CancellationToken, +) -> Option> { + if config.core.tracker_usage_statistics || config.core.tracker_policy.persistent_torrent_completed_stat { + let job = bittorrent_tracker_core::statistics::event::listener::run_event_listener( + app_container.swarm_coordination_registry_container.event_bus.receiver(), + cancellation_token, + &app_container.tracker_core_container.stats_repository, + &app_container.tracker_core_container.db_downloads_metric_repository, + app_container + .tracker_core_container + .core_config + .tracker_policy + .persistent_torrent_completed_stat, + ); + + Some(job) + } else { + tracing::info!("Tracker core event listener job is disabled."); + None + } +} diff --git a/src/bootstrap/jobs/udp_tracker.rs b/src/bootstrap/jobs/udp_tracker.rs index ca503aa29..2723ad9ab 100644 --- a/src/bootstrap/jobs/udp_tracker.rs +++ b/src/bootstrap/jobs/udp_tracker.rs @@ -8,16 +8,15 @@ //! > for the configuration options. use std::sync::Arc; +use bittorrent_udp_tracker_core::container::UdpTrackerCoreContainer; +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; use tokio::task::JoinHandle; -use torrust_tracker_configuration::UdpTracker; +use torrust_server_lib::registar::ServiceRegistrationForm; +use torrust_udp_tracker_server::container::UdpTrackerServerContainer; +use torrust_udp_tracker_server::server::spawner::Spawner; +use torrust_udp_tracker_server::server::Server; use tracing::instrument; -use crate::core; -use crate::servers::registar::ServiceRegistrationForm; -use crate::servers::udp::server::spawner::Spawner; -use crate::servers::udp::server::Server; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; - /// It starts a new UDP server with the provided configuration. /// /// It spawns a new asynchronous task for the new UDP server. @@ -29,12 +28,22 @@ use crate::servers::udp::UDP_TRACKER_LOG_TARGET; /// It will panic if the task did not finish successfully. #[must_use] #[allow(clippy::async_yields_async)] -#[instrument(skip(config, tracker, form))] -pub async fn start_job(config: &UdpTracker, tracker: Arc, form: ServiceRegistrationForm) -> JoinHandle<()> { - let bind_to = config.bind_address; +#[instrument(skip(udp_tracker_core_container, udp_tracker_server_container, form))] +pub async fn start_job( + udp_tracker_core_container: Arc, + udp_tracker_server_container: Arc, + form: ServiceRegistrationForm, +) -> JoinHandle<()> { + let bind_to = udp_tracker_core_container.udp_tracker_config.bind_address; + let cookie_lifetime = udp_tracker_core_container.udp_tracker_config.cookie_lifetime; let server = Server::new(Spawner::new(bind_to)) - .start(tracker, form) + .start( + udp_tracker_core_container, + udp_tracker_server_container, + form, + cookie_lifetime, + ) .await .expect("it should be able to start the udp tracker"); diff --git a/src/bootstrap/jobs/udp_tracker_core.rs b/src/bootstrap/jobs/udp_tracker_core.rs new file mode 100644 index 000000000..dd7e8c165 --- /dev/null +++ b/src/bootstrap/jobs/udp_tracker_core.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; + +pub fn start_event_listener( + config: &Configuration, + app_container: &Arc, + cancellation_token: CancellationToken, +) -> Option> { + if config.core.tracker_usage_statistics { + let job = bittorrent_udp_tracker_core::statistics::event::listener::run_event_listener( + app_container.udp_tracker_core_services.event_bus.receiver(), + cancellation_token, + &app_container.udp_tracker_core_services.stats_repository, + ); + Some(job) + } else { + tracing::info!("UDP tracker core event listener job is disabled."); + None + } +} diff --git a/src/bootstrap/jobs/udp_tracker_server.rs b/src/bootstrap/jobs/udp_tracker_server.rs new file mode 100644 index 000000000..fc6df9c16 --- /dev/null +++ b/src/bootstrap/jobs/udp_tracker_server.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use torrust_tracker_configuration::Configuration; + +use crate::container::AppContainer; + +pub fn start_stats_event_listener( + config: &Configuration, + app_container: &Arc, + cancellation_token: CancellationToken, +) -> Option> { + if config.core.tracker_usage_statistics { + let job = torrust_udp_tracker_server::statistics::event::listener::run_event_listener( + app_container.udp_tracker_server_container.event_bus.receiver(), + cancellation_token, + &app_container.udp_tracker_server_container.stats_repository, + ); + Some(job) + } else { + tracing::info!("UDP tracker server event listener job is disabled."); + None + } +} + +#[must_use] +pub fn start_banning_event_listener(app_container: &Arc, cancellation_token: CancellationToken) -> JoinHandle<()> { + torrust_udp_tracker_server::banning::event::listener::run_event_listener( + app_container.udp_tracker_server_container.event_bus.receiver(), + cancellation_token, + &app_container.udp_tracker_core_services.ban_service, + &app_container.udp_tracker_server_container.stats_repository, + ) +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 22044aafd..2f7909043 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -8,4 +8,3 @@ pub mod app; pub mod config; pub mod jobs; -pub mod logging; diff --git a/src/console/ci/e2e/docker.rs b/src/console/ci/e2e/docker.rs index ce2b1aa99..89d258d2c 100644 --- a/src/console/ci/e2e/docker.rs +++ b/src/console/ci/e2e/docker.rs @@ -82,7 +82,7 @@ impl Docker { let mut port_args: Vec = vec![]; for port in &options.ports { port_args.push("--publish".to_string()); - port_args.push(port.to_string()); + port_args.push(port.clone()); } let args = [initial_args, env_var_args, port_args, [image.to_string()].to_vec()].concat(); diff --git a/src/console/ci/e2e/logs_parser.rs b/src/console/ci/e2e/logs_parser.rs index 95648a2b5..e8b6b3b8f 100644 --- a/src/console/ci/e2e/logs_parser.rs +++ b/src/console/ci/e2e/logs_parser.rs @@ -1,11 +1,10 @@ //! Utilities to parse Torrust Tracker logs. +use bittorrent_udp_tracker_core::UDP_TRACKER_LOG_TARGET; use regex::Regex; use serde::{Deserialize, Serialize}; - -use crate::servers::health_check_api::HEALTH_CHECK_API_LOG_TARGET; -use crate::servers::http::HTTP_TRACKER_LOG_TARGET; -use crate::servers::logging::STARTED_ON; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; +use torrust_axum_health_check_api_server::HEALTH_CHECK_API_LOG_TARGET; +use torrust_axum_http_tracker_server::HTTP_TRACKER_LOG_TARGET; +use torrust_server_lib::logging::STARTED_ON; const INFO_THRESHOLD: &str = "INFO"; @@ -32,8 +31,8 @@ impl RunningServices { /// 2024-06-10T16:07:39.990303Z INFO HTTP TRACKER: Starting on: http://0.0.0.0:7070 /// 2024-06-10T16:07:39.990439Z INFO HTTP TRACKER: Started on: http://0.0.0.0:7070 /// 2024-06-10T16:07:39.990448Z INFO torrust_tracker::bootstrap::jobs: TLS not enabled - /// 2024-06-10T16:07:39.990563Z INFO API: Starting on http://127.0.0.1:1212 - /// 2024-06-10T16:07:39.990565Z INFO API: Started on http://127.0.0.1:1212 + /// 2024-06-10T16:07:39.990563Z INFO API: Starting on: http://127.0.0.1:1212 + /// 2024-06-10T16:07:39.990565Z INFO API: Started on: http://127.0.0.1:1212 /// 2024-06-10T16:07:39.990577Z INFO HEALTH CHECK API: Starting on: http://127.0.0.1:1313 /// 2024-06-10T16:07:39.990638Z INFO HEALTH CHECK API: Started on: http://127.0.0.1:1313 /// ``` @@ -76,7 +75,7 @@ impl RunningServices { if !line.contains(INFO_THRESHOLD) { continue; - }; + } if line.contains(UDP_TRACKER_LOG_TARGET) { if let Some(captures) = udp_re.captures(&clean_line) { @@ -123,8 +122,8 @@ mod tests { 2024-06-10T16:07:39.990303Z INFO HTTP TRACKER: Starting on: http://0.0.0.0:7070 2024-06-10T16:07:39.990439Z INFO HTTP TRACKER: Started on: http://0.0.0.0:7070 2024-06-10T16:07:39.990448Z INFO torrust_tracker::bootstrap::jobs: TLS not enabled - 2024-06-10T16:07:39.990563Z INFO API: Starting on http://127.0.0.1:1212 - 2024-06-10T16:07:39.990565Z INFO API: Started on http://127.0.0.1:1212 + 2024-06-10T16:07:39.990563Z INFO API: Starting on: http://127.0.0.1:1212 + 2024-06-10T16:07:39.990565Z INFO API: Started on: http://127.0.0.1:1212 2024-06-10T16:07:39.990577Z INFO HEALTH CHECK API: Starting on: http://127.0.0.1:1313 2024-06-10T16:07:39.990638Z INFO HEALTH CHECK API: Started on: http://127.0.0.1:1313 "; diff --git a/src/console/ci/e2e/runner.rs b/src/console/ci/e2e/runner.rs index 118ecda42..6275c144b 100644 --- a/src/console/ci/e2e/runner.rs +++ b/src/console/ci/e2e/runner.rs @@ -77,7 +77,7 @@ pub fn run() -> anyhow::Result<()> { // Besides, if we don't use port 0 we should get the port numbers from the tracker configuration. // We could not use docker, but the intention was to create E2E tests including containerization. let options = RunOptions { - env_vars: vec![("TORRUST_TRACKER_CONFIG_TOML".to_string(), tracker_config.to_string())], + env_vars: vec![("TORRUST_TRACKER_CONFIG_TOML".to_string(), tracker_config.clone())], ports: vec![ "6969:6969/udp".to_string(), "7070:7070/tcp".to_string(), @@ -137,7 +137,7 @@ fn load_tracker_configuration(args: &Args) -> anyhow::Result { } fn load_config_from_file(path: &PathBuf) -> anyhow::Result { - let config = std::fs::read_to_string(path).with_context(|| format!("CSan't read config file {path:?}"))?; + let config = std::fs::read_to_string(path).with_context(|| format!("CSan't read config file {}", path.display()))?; Ok(config) } diff --git a/src/console/ci/e2e/tracker_checker.rs b/src/console/ci/e2e/tracker_checker.rs index 192795e61..a39e68c93 100644 --- a/src/console/ci/e2e/tracker_checker.rs +++ b/src/console/ci/e2e/tracker_checker.rs @@ -7,12 +7,14 @@ use std::process::Command; /// /// Will return an error if the Tracker Checker fails. pub fn run(config_content: &str) -> io::Result<()> { - tracing::info!("Running Tracker Checker: TORRUST_CHECKER_CONFIG=[config] cargo run --bin tracker_checker"); + tracing::info!( + "Running Tracker Checker: TORRUST_CHECKER_CONFIG=[config] cargo run -p torrust-tracker-client --bin tracker_checker" + ); tracing::info!("Tracker Checker config:\n{config_content}"); let status = Command::new("cargo") .env("TORRUST_CHECKER_CONFIG", config_content) - .args(["run", "--bin", "tracker_checker"]) + .args(["run", "-p", "torrust-tracker-client", "--bin", "tracker_checker"]) .status()?; if status.success() { diff --git a/src/console/ci/e2e/tracker_container.rs b/src/console/ci/e2e/tracker_container.rs index 0d15035a8..1a7717a41 100644 --- a/src/console/ci/e2e/tracker_container.rs +++ b/src/console/ci/e2e/tracker_container.rs @@ -1,7 +1,7 @@ use std::time::Duration; -use rand::distributions::Alphanumeric; -use rand::Rng; +use rand::distr::Alphanumeric; +use rand::RngExt; use super::docker::{RunOptions, RunningContainer}; use super::logs_parser::RunningServices; @@ -113,11 +113,7 @@ impl TrackerContainer { } fn generate_random_container_name(prefix: &str) -> String { - let rand_string: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(20) - .map(char::from) - .collect(); + let rand_string: String = rand::rng().sample_iter(&Alphanumeric).take(20).map(char::from).collect(); format!("{prefix}{rand_string}") } diff --git a/src/console/mod.rs b/src/console/mod.rs index dab338e4b..0e0da3fa2 100644 --- a/src/console/mod.rs +++ b/src/console/mod.rs @@ -1,4 +1,3 @@ //! Console apps. pub mod ci; -pub mod clients; pub mod profiling; diff --git a/src/console/profiling.rs b/src/console/profiling.rs index 5fb507197..df44f4009 100644 --- a/src/console/profiling.rs +++ b/src/console/profiling.rs @@ -161,7 +161,7 @@ use std::time::Duration; use tokio::time::sleep; -use crate::{app, bootstrap}; +use crate::app; pub async fn run() { // Parse command line arguments @@ -179,9 +179,7 @@ pub async fn run() { return; }; - let (config, tracker) = bootstrap::app::setup(); - - let jobs = app::start(&config, tracker).await; + let (_app_container, jobs) = app::run().await; // Run the tracker for a fixed duration let run_duration = sleep(Duration::from_secs(duration_secs)); @@ -191,9 +189,9 @@ pub async fn run() { tracing::info!("Torrust timed shutdown.."); }, _ = tokio::signal::ctrl_c() => { - tracing::info!("Torrust shutting down via Ctrl+C ..."); - // Await for all jobs to shutdown - futures::future::join_all(jobs).await; + tracing::info!("Torrust tracker shutting down via Ctrl+C ..."); + + jobs.wait_for_all(Duration::from_secs(10)).await; } } diff --git a/src/container.rs b/src/container.rs new file mode 100644 index 000000000..7112a54e8 --- /dev/null +++ b/src/container.rs @@ -0,0 +1,209 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; + +use bittorrent_http_tracker_core::container::{HttpTrackerCoreContainer, HttpTrackerCoreServices}; +use bittorrent_tracker_core::container::TrackerCoreContainer; +use bittorrent_udp_tracker_core::container::{UdpTrackerCoreContainer, UdpTrackerCoreServices}; +use bittorrent_udp_tracker_core::{self}; +use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; +use torrust_server_lib::registar::Registar; +use torrust_tracker_configuration::{Configuration, HttpApi}; +use torrust_tracker_swarm_coordination_registry::container::SwarmCoordinationRegistryContainer; +use torrust_udp_tracker_server::container::UdpTrackerServerContainer; +use tracing::instrument; + +#[derive(thiserror::Error, Debug, Clone)] +pub enum Error { + #[error("There is not a HTTP tracker server instance bound to the socket address: {bind_address}")] + MissingHttpTrackerCoreContainer { bind_address: SocketAddr }, + + #[error("There is not a UDP tracker server instance bound to the socket address: {bind_address}")] + MissingUdpTrackerCoreContainer { bind_address: SocketAddr }, +} + +pub struct AppContainer { + // Configuration + pub http_api_config: Arc>, + + // Registar + pub registar: Arc, + + // Swarm Coordination Registry Container + pub swarm_coordination_registry_container: Arc, + + // Core + pub tracker_core_container: Arc, + + // HTTP + pub http_tracker_core_services: Arc, + pub http_tracker_instance_containers: Arc>>, + + // UDP + pub udp_tracker_core_services: Arc, + pub udp_tracker_server_container: Arc, + pub udp_tracker_instance_containers: Arc>>, +} + +impl AppContainer { + #[instrument(skip(configuration))] + pub fn initialize(configuration: &Configuration) -> AppContainer { + // Configuration + + let core_config = Arc::new(configuration.core.clone()); + + let http_api_config = Arc::new(configuration.http_api.clone()); + + // Registar + + let registar = Arc::new(Registar::default()); + + // Swarm Coordination Registry Container + + let swarm_coordination_registry_container = Arc::new(SwarmCoordinationRegistryContainer::initialize( + core_config.tracker_usage_statistics.into(), + )); + + // Core + + let tracker_core_container = Arc::new(TrackerCoreContainer::initialize_from( + &core_config, + &swarm_coordination_registry_container, + )); + + // HTTP + + let http_tracker_core_services = HttpTrackerCoreServices::initialize_from(&tracker_core_container); + + let http_tracker_instance_containers = Self::initialize_http_tracker_instance_containers( + configuration, + &tracker_core_container, + &http_tracker_core_services, + ); + + // UDP + + let udp_tracker_core_services = UdpTrackerCoreServices::initialize_from(&tracker_core_container); + + let udp_tracker_server_container = UdpTrackerServerContainer::initialize(&core_config); + + let udp_tracker_instance_containers = + Self::initialize_udp_tracker_instance_containers(configuration, &tracker_core_container, &udp_tracker_core_services); + + AppContainer { + // Configuration + http_api_config, + + // Registar + registar, + + // Swarm Coordination Registry Container + swarm_coordination_registry_container, + + // Core + tracker_core_container, + + // HTTP + http_tracker_core_services, + http_tracker_instance_containers, + + // UDP + udp_tracker_core_services, + udp_tracker_server_container, + udp_tracker_instance_containers, + } + } + + #[must_use] + pub fn udp_tracker_server_container(&self) -> Arc { + self.udp_tracker_server_container.clone() + } + + /// # Errors + /// + /// Return an error if there is no HTTP tracker server instance bound to the + /// socket address. + pub fn http_tracker_container(&self, bind_address: SocketAddr) -> Result, Error> { + match self.http_tracker_instance_containers.get(&bind_address) { + Some(http_tracker_container) => Ok(http_tracker_container.clone()), + None => Err(Error::MissingHttpTrackerCoreContainer { bind_address }), + } + } + + /// # Errors + /// + /// Return an error if there is no UDP tracker server instance bound to the + /// socket address. + pub fn udp_tracker_container(&self, bind_address: SocketAddr) -> Result, Error> { + match self.udp_tracker_instance_containers.get(&bind_address) { + Some(udp_tracker_container) => Ok(udp_tracker_container.clone()), + None => Err(Error::MissingUdpTrackerCoreContainer { bind_address }), + } + } + + #[must_use] + pub fn tracker_http_api_container(&self, http_api_config: &Arc) -> Arc { + TrackerHttpApiCoreContainer { + http_api_config: http_api_config.clone(), + + swarm_coordination_registry_container: self.swarm_coordination_registry_container.clone(), + + tracker_core_container: self.tracker_core_container.clone(), + + http_stats_repository: self.http_tracker_core_services.stats_repository.clone(), + + ban_service: self.udp_tracker_core_services.ban_service.clone(), + udp_core_stats_repository: self.udp_tracker_core_services.stats_repository.clone(), + udp_server_stats_repository: self.udp_tracker_server_container.stats_repository.clone(), + } + .into() + } + + #[must_use] + fn initialize_http_tracker_instance_containers( + configuration: &Configuration, + tracker_core_container: &Arc, + http_tracker_core_services: &Arc, + ) -> Arc>> { + let mut http_tracker_instance_containers = HashMap::new(); + + if let Some(http_trackers) = &configuration.http_trackers { + for http_tracker_config in http_trackers { + http_tracker_instance_containers.insert( + http_tracker_config.bind_address, + HttpTrackerCoreContainer::initialize_from_services( + tracker_core_container, + http_tracker_core_services, + &Arc::new(http_tracker_config.clone()), + ), + ); + } + } + + Arc::new(http_tracker_instance_containers) + } + + #[must_use] + fn initialize_udp_tracker_instance_containers( + configuration: &Configuration, + tracker_core_container: &Arc, + udp_tracker_core_services: &Arc, + ) -> Arc>> { + let mut udp_tracker_instance_containers = HashMap::new(); + + if let Some(udp_trackers) = &configuration.udp_trackers { + for udp_tracker_config in udp_trackers { + udp_tracker_instance_containers.insert( + udp_tracker_config.bind_address, + UdpTrackerCoreContainer::initialize_from_services( + tracker_core_container, + udp_tracker_core_services, + &Arc::new(udp_tracker_config.clone()), + ), + ); + } + } + + Arc::new(udp_tracker_instance_containers) + } +} diff --git a/src/core/auth.rs b/src/core/auth.rs deleted file mode 100644 index 0243fceb4..000000000 --- a/src/core/auth.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Tracker authentication services and structs. -//! -//! This module contains functions to handle tracker keys. -//! Tracker keys are tokens used to authenticate the tracker clients when the tracker runs -//! in `private` or `private_listed` modes. -//! -//! There are services to [`generate_key`] and [`verify_key_expiration`] authentication keys. -//! -//! Authentication keys are used only by [`HTTP`](crate::servers::http) trackers. All keys have an expiration time, that means -//! they are only valid during a period of time. After that time the expiring key will no longer be valid. -//! -//! Keys are stored in this struct: -//! -//! ```rust,no_run -//! use torrust_tracker::core::auth::Key; -//! use torrust_tracker_primitives::DurationSinceUnixEpoch; -//! -//! pub struct ExpiringKey { -//! /// Random 32-char string. For example: `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ` -//! pub key: Key, -//! /// Timestamp, the key will be no longer valid after this timestamp -//! pub valid_until: Option, -//! } -//! ``` -//! -//! You can generate a new key valid for `9999` seconds and `0` nanoseconds from the current time with the following: -//! -//! ```rust,no_run -//! use torrust_tracker::core::auth; -//! use std::time::Duration; -//! -//! let expiring_key = auth::generate_key(Some(Duration::new(9999, 0))); -//! -//! // And you can later verify it with: -//! -//! assert!(auth::verify_key_expiration(&expiring_key).is_ok()); -//! ``` - -use std::panic::Location; -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; - -use derive_more::Display; -use rand::distributions::Alphanumeric; -use rand::{thread_rng, Rng}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use torrust_tracker_clock::clock::Time; -use torrust_tracker_clock::conv::convert_from_timestamp_to_datetime_utc; -use torrust_tracker_located_error::{DynError, LocatedError}; -use torrust_tracker_primitives::DurationSinceUnixEpoch; - -use crate::shared::bit_torrent::common::AUTH_KEY_LENGTH; -use crate::CurrentClock; - -/// It generates a new permanent random key [`PeerKey`]. -#[must_use] -pub fn generate_permanent_key() -> PeerKey { - generate_key(None) -} - -/// It generates a new random 32-char authentication [`PeerKey`]. -/// -/// It can be an expiring or permanent key. -/// -/// # Panics -/// -/// It would panic if the `lifetime: Duration` + Duration is more than `Duration::MAX`. -/// -/// # Arguments -/// -/// * `lifetime`: if `None` the key will be permanent. -#[must_use] -pub fn generate_key(lifetime: Option) -> PeerKey { - let random_id: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(AUTH_KEY_LENGTH) - .map(char::from) - .collect(); - - if let Some(lifetime) = lifetime { - tracing::debug!("Generated key: {}, valid for: {:?} seconds", random_id, lifetime); - - PeerKey { - key: random_id.parse::().unwrap(), - valid_until: Some(CurrentClock::now_add(&lifetime).unwrap()), - } - } else { - tracing::debug!("Generated key: {}, permanent", random_id); - - PeerKey { - key: random_id.parse::().unwrap(), - valid_until: None, - } - } -} - -/// It verifies an [`PeerKey`]. It checks if the expiration date has passed. -/// Permanent keys without duration (`None`) do not expire. -/// -/// # Errors -/// -/// Will return: -/// -/// - `Error::KeyExpired` if `auth_key.valid_until` is past the `current_time`. -/// - `Error::KeyInvalid` if `auth_key.valid_until` is past the `None`. -pub fn verify_key_expiration(auth_key: &PeerKey) -> Result<(), Error> { - let current_time: DurationSinceUnixEpoch = CurrentClock::now(); - - match auth_key.valid_until { - Some(valid_until) => { - if valid_until < current_time { - Err(Error::KeyExpired { - location: Location::caller(), - }) - } else { - Ok(()) - } - } - None => Ok(()), // Permanent key - } -} - -/// An authentication key which can potentially have an expiration time. -/// After that time is will automatically become invalid. -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] -pub struct PeerKey { - /// Random 32-char string. For example: `YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ` - pub key: Key, - - /// Timestamp, the key will be no longer valid after this timestamp. - /// If `None` the keys will not expire (permanent key). - pub valid_until: Option, -} - -impl std::fmt::Display for PeerKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.expiry_time() { - Some(expire_time) => write!(f, "key: `{}`, valid until `{}`", self.key, expire_time), - None => write!(f, "key: `{}`, permanent", self.key), - } - } -} - -impl PeerKey { - #[must_use] - pub fn key(&self) -> Key { - self.key.clone() - } - - /// It returns the expiry time. For example, for the starting time for Unix Epoch - /// (timestamp 0) it will return a `DateTime` whose string representation is - /// `1970-01-01 00:00:00 UTC`. - /// - /// # Panics - /// - /// Will panic when the key timestamp overflows the internal i64 type. - /// (this will naturally happen in 292.5 billion years) - #[must_use] - pub fn expiry_time(&self) -> Option> { - self.valid_until.map(convert_from_timestamp_to_datetime_utc) - } -} - -/// A token used for authentication. -/// -/// - It contains only ascii alphanumeric chars: lower and uppercase letters and -/// numbers. -/// - It's a 32-char string. -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone, Display, Hash)] -pub struct Key(String); - -impl Key { - /// # Errors - /// - /// Will return an error is the string represents an invalid key. - /// Valid keys can only contain 32 chars including 0-9, a-z and A-Z. - pub fn new(value: &str) -> Result { - if value.len() != AUTH_KEY_LENGTH { - return Err(ParseKeyError::InvalidKeyLength); - } - - if !value.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(ParseKeyError::InvalidChars); - } - - Ok(Self(value.to_owned())) - } - - #[must_use] - pub fn value(&self) -> &str { - &self.0 - } -} - -/// Error returned when a key cannot be parsed from a string. -/// -/// ```rust,no_run -/// use torrust_tracker::core::auth::Key; -/// use std::str::FromStr; -/// -/// let key_string = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"; -/// let key = Key::from_str(key_string); -/// -/// assert!(key.is_ok()); -/// assert_eq!(key.unwrap().to_string(), key_string); -/// ``` -/// -/// If the string does not contains a valid key, the parser function will return -/// this error. -#[derive(Debug, Error)] -pub enum ParseKeyError { - #[error("Invalid key length. Key must be have 32 chars")] - InvalidKeyLength, - #[error("Invalid chars for key. Key can only alphanumeric chars (0-9, a-z, A-Z)")] - InvalidChars, -} - -impl FromStr for Key { - type Err = ParseKeyError; - - fn from_str(s: &str) -> Result { - Key::new(s)?; - Ok(Self(s.to_string())) - } -} - -/// Verification error. Error returned when an [`PeerKey`] cannot be -/// verified with the (`crate::core::auth::verify_key`) function. -#[derive(Debug, Error)] -#[allow(dead_code)] -pub enum Error { - #[error("Key could not be verified: {source}")] - KeyVerificationError { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - #[error("Failed to read key: {key}, {location}")] - UnableToReadKey { - location: &'static Location<'static>, - key: Box, - }, - #[error("Key has expired, {location}")] - KeyExpired { location: &'static Location<'static> }, -} - -impl From for Error { - fn from(e: r2d2_sqlite::rusqlite::Error) -> Self { - Error::KeyVerificationError { - source: (Arc::new(e) as DynError).into(), - } - } -} - -#[cfg(test)] -mod tests { - - mod key { - use std::str::FromStr; - - use crate::core::auth::Key; - - #[test] - fn should_be_parsed_from_an_string() { - let key_string = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"; - let key = Key::from_str(key_string); - - assert!(key.is_ok()); - assert_eq!(key.unwrap().to_string(), key_string); - } - - #[test] - fn length_should_be_32() { - let key = Key::new(""); - assert!(key.is_err()); - - let string_longer_than_32 = "012345678901234567890123456789012"; // DevSkim: ignore DS173237 - let key = Key::new(string_longer_than_32); - assert!(key.is_err()); - } - - #[test] - fn should_only_include_alphanumeric_chars() { - let key = Key::new("%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"); - assert!(key.is_err()); - } - } - - mod expiring_auth_key { - use std::str::FromStr; - use std::time::Duration; - - use torrust_tracker_clock::clock; - use torrust_tracker_clock::clock::stopped::Stopped as _; - - use crate::core::auth; - - #[test] - fn should_be_parsed_from_an_string() { - let key_string = "YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ"; - let auth_key = auth::Key::from_str(key_string); - - assert!(auth_key.is_ok()); - assert_eq!(auth_key.unwrap().to_string(), key_string); - } - - #[test] - fn should_be_displayed() { - // Set the time to the current time. - clock::Stopped::local_set_to_unix_epoch(); - - let expiring_key = auth::generate_key(Some(Duration::from_secs(0))); - - assert_eq!( - expiring_key.to_string(), - format!("key: `{}`, valid until `1970-01-01 00:00:00 UTC`", expiring_key.key) // cspell:disable-line - ); - } - - #[test] - fn should_be_generated_with_a_expiration_time() { - let expiring_key = auth::generate_key(Some(Duration::new(9999, 0))); - - assert!(auth::verify_key_expiration(&expiring_key).is_ok()); - } - - #[test] - fn should_be_generate_and_verified() { - // Set the time to the current time. - clock::Stopped::local_set_to_system_time_now(); - - // Make key that is valid for 19 seconds. - let expiring_key = auth::generate_key(Some(Duration::from_secs(19))); - - // Mock the time has passed 10 sec. - clock::Stopped::local_add(&Duration::from_secs(10)).unwrap(); - - assert!(auth::verify_key_expiration(&expiring_key).is_ok()); - - // Mock the time has passed another 10 sec. - clock::Stopped::local_add(&Duration::from_secs(10)).unwrap(); - - assert!(auth::verify_key_expiration(&expiring_key).is_err()); - } - } -} diff --git a/src/core/databases/driver.rs b/src/core/databases/driver.rs deleted file mode 100644 index a456a2650..000000000 --- a/src/core/databases/driver.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Database driver factory. -//! -//! See [`databases::driver::build`](crate::core::databases::driver::build) -//! function for more information. -use serde::{Deserialize, Serialize}; - -use super::error::Error; -use super::mysql::Mysql; -use super::sqlite::Sqlite; -use super::{Builder, Database}; - -/// The database management system used by the tracker. -/// -/// Refer to: -/// -/// - [Torrust Tracker Configuration](https://docs.rs/torrust-tracker-configuration). -/// - [Torrust Tracker](https://docs.rs/torrust-tracker). -/// -/// For more information about persistence. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, derive_more::Display, Clone)] -pub enum Driver { - /// The Sqlite3 database driver. - Sqlite3, - /// The `MySQL` database driver. - MySQL, -} - -/// It builds a new database driver. -/// -/// Example for `SQLite3`: -/// -/// ```rust,no_run -/// use torrust_tracker::core::databases; -/// use torrust_tracker::core::databases::driver::Driver; -/// -/// let db_driver = Driver::Sqlite3; -/// let db_path = "./storage/tracker/lib/database/sqlite3.db".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Example for `MySQL`: -/// -/// ```rust,no_run -/// use torrust_tracker::core::databases; -/// use torrust_tracker::core::databases::driver::Driver; -/// -/// let db_driver = Driver::MySQL; -/// let db_path = "mysql://db_user:db_user_secret_password@mysql:3306/torrust_tracker".to_string(); -/// let database = databases::driver::build(&db_driver, &db_path); -/// ``` -/// -/// Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) -/// for more information about the database configuration. -/// -/// > **WARNING**: The driver instantiation runs database migrations. -/// -/// # Errors -/// -/// This function will return an error if unable to connect to the database. -/// -/// # Panics -/// -/// This function will panic if unable to create database tables. -pub fn build(driver: &Driver, db_path: &str) -> Result, Error> { - let database = match driver { - Driver::Sqlite3 => Builder::::build(db_path), - Driver::MySQL => Builder::::build(db_path), - }?; - - database.create_database_tables().expect("Could not create database tables."); - - Ok(database) -} diff --git a/src/core/databases/error.rs b/src/core/databases/error.rs deleted file mode 100644 index 4d64baf48..000000000 --- a/src/core/databases/error.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Database errors. -//! -//! This module contains the [Database errors](crate::core::databases::error::Error). -use std::panic::Location; -use std::sync::Arc; - -use r2d2_mysql::mysql::UrlError; -use torrust_tracker_located_error::{DynError, Located, LocatedError}; - -use super::driver::Driver; - -#[derive(thiserror::Error, Debug, Clone)] -pub enum Error { - /// The query unexpectedly returned nothing. - #[error("The {driver} query unexpectedly returned nothing: {source}")] - QueryReturnedNoRows { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - driver: Driver, - }, - - /// The query was malformed. - #[error("The {driver} query was malformed: {source}")] - InvalidQuery { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - driver: Driver, - }, - - /// Unable to insert a record into the database - #[error("Unable to insert record into {driver} database, {location}")] - InsertFailed { - location: &'static Location<'static>, - driver: Driver, - }, - - /// Unable to delete a record into the database - #[error("Failed to remove record from {driver} database, error-code: {error_code}, {location}")] - DeleteFailed { - location: &'static Location<'static>, - error_code: usize, - driver: Driver, - }, - - /// Unable to connect to the database - #[error("Failed to connect to {driver} database: {source}")] - ConnectionError { - source: LocatedError<'static, UrlError>, - driver: Driver, - }, - - /// Unable to create a connection pool - #[error("Failed to create r2d2 {driver} connection pool: {source}")] - ConnectionPool { - source: LocatedError<'static, r2d2::Error>, - driver: Driver, - }, -} - -impl From for Error { - #[track_caller] - fn from(err: r2d2_sqlite::rusqlite::Error) -> Self { - match err { - r2d2_sqlite::rusqlite::Error::QueryReturnedNoRows => Error::QueryReturnedNoRows { - source: (Arc::new(err) as DynError).into(), - driver: Driver::Sqlite3, - }, - _ => Error::InvalidQuery { - source: (Arc::new(err) as DynError).into(), - driver: Driver::Sqlite3, - }, - } - } -} - -impl From for Error { - #[track_caller] - fn from(err: r2d2_mysql::mysql::Error) -> Self { - let e: DynError = Arc::new(err); - Error::InvalidQuery { - source: e.into(), - driver: Driver::MySQL, - } - } -} - -impl From for Error { - #[track_caller] - fn from(err: UrlError) -> Self { - Self::ConnectionError { - source: Located(err).into(), - driver: Driver::MySQL, - } - } -} - -impl From<(r2d2::Error, Driver)> for Error { - #[track_caller] - fn from(e: (r2d2::Error, Driver)) -> Self { - let (err, driver) = e; - Self::ConnectionPool { - source: Located(err).into(), - driver, - } - } -} diff --git a/src/core/databases/mod.rs b/src/core/databases/mod.rs deleted file mode 100644 index f559eb80e..000000000 --- a/src/core/databases/mod.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! The persistence module. -//! -//! Persistence is currently implemented with one [`Database`] trait. -//! -//! There are two implementations of the trait (two drivers): -//! -//! - [`Mysql`](crate::core::databases::mysql::Mysql) -//! - [`Sqlite`](crate::core::databases::sqlite::Sqlite) -//! -//! > **NOTICE**: There are no database migrations. If there are any changes, -//! > we will implemented them or provide a script to migrate to the new schema. -//! -//! The persistent objects are: -//! -//! - [Torrent metrics](#torrent-metrics) -//! - [Torrent whitelist](torrent-whitelist) -//! - [Authentication keys](authentication-keys) -//! -//! # Torrent metrics -//! -//! Field | Sample data | Description -//! ---|---|--- -//! `id` | 1 | Autoincrement id -//! `info_hash` | `c1277613db1d28709b034a017ab2cae4be07ae10` | `BitTorrent` infohash V1 -//! `completed` | 20 | The number of peers that have ever completed downloading the torrent associated to this entry. See [`Entry`](torrust_tracker_torrent_repository::entry::Entry) for more information. -//! -//! > **NOTICE**: The peer list for a torrent is not persisted. Since peer have to re-announce themselves on intervals, the data is be -//! > regenerated again after some minutes. -//! -//! # Torrent whitelist -//! -//! Field | Sample data | Description -//! ---|---|--- -//! `id` | 1 | Autoincrement id -//! `info_hash` | `c1277613db1d28709b034a017ab2cae4be07ae10` | `BitTorrent` infohash V1 -//! -//! # Authentication keys -//! -//! Field | Sample data | Description -//! ---|---|--- -//! `id` | 1 | Autoincrement id -//! `key` | `IrweYtVuQPGbG9Jzx1DihcPmJGGpVy82` | Token -//! `valid_until` | 1672419840 | Timestamp for the expiring date -//! -//! > **NOTICE**: All keys must have an expiration date. -pub mod driver; -pub mod error; -pub mod mysql; -pub mod sqlite; - -use std::marker::PhantomData; - -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::PersistentTorrents; - -use self::error::Error; -use crate::core::auth::{self, Key}; - -struct Builder -where - T: Database, -{ - phantom: PhantomData, -} - -impl Builder -where - T: Database + 'static, -{ - /// . - /// - /// # Errors - /// - /// Will return `r2d2::Error` if `db_path` is not able to create a database. - pub(self) fn build(db_path: &str) -> Result, Error> { - Ok(Box::new(T::new(db_path)?)) - } -} - -/// The persistence trait. It contains all the methods to interact with the database. -pub trait Database: Sync + Send { - /// It instantiates a new database driver. - /// - /// # Errors - /// - /// Will return `r2d2::Error` if `db_path` is not able to create a database. - fn new(db_path: &str) -> Result - where - Self: std::marker::Sized; - - // Schema - - /// It generates the database tables. SQL queries are hardcoded in the trait - /// implementation. - /// - /// # Context: Schema - /// - /// # Errors - /// - /// Will return `Error` if unable to create own tables. - fn create_database_tables(&self) -> Result<(), Error>; - - /// It drops the database tables. - /// - /// # Context: Schema - /// - /// # Errors - /// - /// Will return `Err` if unable to drop tables. - fn drop_database_tables(&self) -> Result<(), Error>; - - // Torrent Metrics - - /// It loads the torrent metrics data from the database. - /// - /// It returns an array of tuples with the torrent - /// [`InfoHash`] and the - /// [`downloaded`](torrust_tracker_torrent_repository::entry::Torrent::downloaded) counter - /// which is the number of times the torrent has been downloaded. - /// See [`Entry::downloaded`](torrust_tracker_torrent_repository::entry::Torrent::downloaded). - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn load_persistent_torrents(&self) -> Result; - - /// It saves the torrent metrics data into the database. - /// - /// # Context: Torrent Metrics - /// - /// # Errors - /// - /// Will return `Err` if unable to save. - fn save_persistent_torrent(&self, info_hash: &InfoHash, downloaded: u32) -> Result<(), Error>; - - // Whitelist - - /// It loads the whitelisted torrents from the database. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn load_whitelist(&self) -> Result, Error>; - - /// It checks if the torrent is whitelisted. - /// - /// It returns `Some(InfoHash)` if the torrent is whitelisted, `None` otherwise. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error>; - - /// It adds the torrent to the whitelist. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return `Err` if unable to save. - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result; - - /// It checks if the torrent is whitelisted. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn is_info_hash_whitelisted(&self, info_hash: InfoHash) -> Result { - Ok(self.get_info_hash_from_whitelist(info_hash)?.is_some()) - } - - /// It removes the torrent from the whitelist. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return `Err` if unable to save. - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result; - - // Authentication keys - - /// It loads the expiring authentication keys from the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn load_keys(&self) -> Result, Error>; - - /// It gets an expiring authentication key from the database. - /// - /// It returns `Some(PeerKey)` if a [`PeerKey`](crate::core::auth::PeerKey) - /// with the input [`Key`] exists, `None` otherwise. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn get_key_from_keys(&self, key: &Key) -> Result, Error>; - - /// It adds an expiring authentication key to the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Will return `Err` if unable to save. - fn add_key_to_keys(&self, auth_key: &auth::PeerKey) -> Result; - - /// It removes an expiring authentication key from the database. - /// - /// # Context: Authentication Keys - /// - /// # Errors - /// - /// Will return `Err` if unable to load. - fn remove_key_from_keys(&self, key: &Key) -> Result; -} diff --git a/src/core/databases/mysql.rs b/src/core/databases/mysql.rs deleted file mode 100644 index 28a5f363b..000000000 --- a/src/core/databases/mysql.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! The `MySQL` database driver. -use std::str::FromStr; -use std::time::Duration; - -use r2d2::Pool; -use r2d2_mysql::mysql::prelude::Queryable; -use r2d2_mysql::mysql::{params, Opts, OptsBuilder}; -use r2d2_mysql::MySqlConnectionManager; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::PersistentTorrents; - -use super::driver::Driver; -use super::{Database, Error}; -use crate::core::auth::{self, Key}; -use crate::shared::bit_torrent::common::AUTH_KEY_LENGTH; - -const DRIVER: Driver = Driver::MySQL; - -pub struct Mysql { - pool: Pool, -} - -impl Database for Mysql { - /// It instantiates a new `MySQL` database driver. - /// - /// Refer to [`databases::Database::new`](crate::core::databases::Database::new). - /// - /// # Errors - /// - /// Will return `r2d2::Error` if `db_path` is not able to create `MySQL` database. - fn new(db_path: &str) -> Result { - let opts = Opts::from_url(db_path)?; - let builder = OptsBuilder::from_opts(opts); - let manager = MySqlConnectionManager::new(builder); - let pool = r2d2::Pool::builder().build(manager).map_err(|e| (e, DRIVER))?; - - Ok(Self { pool }) - } - - /// Refer to [`databases::Database::create_database_tables`](crate::core::databases::Database::create_database_tables). - fn create_database_tables(&self) -> Result<(), Error> { - let create_whitelist_table = " - CREATE TABLE IF NOT EXISTS whitelist ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE - );" - .to_string(); - - let create_torrents_table = " - CREATE TABLE IF NOT EXISTS torrents ( - id integer PRIMARY KEY AUTO_INCREMENT, - info_hash VARCHAR(40) NOT NULL UNIQUE, - completed INTEGER DEFAULT 0 NOT NULL - );" - .to_string(); - - let create_keys_table = format!( - " - CREATE TABLE IF NOT EXISTS `keys` ( - `id` INT NOT NULL AUTO_INCREMENT, - `key` VARCHAR({}) NOT NULL, - `valid_until` INT(10), - PRIMARY KEY (`id`), - UNIQUE (`key`) - );", - i8::try_from(AUTH_KEY_LENGTH).expect("auth::Auth Key Length Should fit within a i8!") - ); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&create_torrents_table) - .expect("Could not create torrents table."); - conn.query_drop(&create_keys_table).expect("Could not create keys table."); - conn.query_drop(&create_whitelist_table) - .expect("Could not create whitelist table."); - - Ok(()) - } - - /// Refer to [`databases::Database::drop_database_tables`](crate::core::databases::Database::drop_database_tables). - fn drop_database_tables(&self) -> Result<(), Error> { - let drop_whitelist_table = " - DROP TABLE `whitelist`;" - .to_string(); - - let drop_torrents_table = " - DROP TABLE `torrents`;" - .to_string(); - - let drop_keys_table = " - DROP TABLE `keys`;" - .to_string(); - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.query_drop(&drop_whitelist_table) - .expect("Could not drop `whitelist` table."); - conn.query_drop(&drop_torrents_table) - .expect("Could not drop `torrents` table."); - conn.query_drop(&drop_keys_table).expect("Could not drop `keys` table."); - - Ok(()) - } - - /// Refer to [`databases::Database::load_persistent_torrents`](crate::core::databases::Database::load_persistent_torrents). - fn load_persistent_torrents(&self) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let torrents = conn.query_map( - "SELECT info_hash, completed FROM torrents", - |(info_hash_string, completed): (String, u32)| { - let info_hash = InfoHash::from_str(&info_hash_string).unwrap(); - (info_hash, completed) - }, - )?; - - Ok(torrents.iter().copied().collect()) - } - - /// Refer to [`databases::Database::load_keys`](crate::core::databases::Database::load_keys). - fn load_keys(&self) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let keys = conn.query_map( - "SELECT `key`, valid_until FROM `keys`", - |(key, valid_until): (String, Option)| match valid_until { - Some(valid_until) => auth::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => auth::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - }, - )?; - - Ok(keys) - } - - /// Refer to [`databases::Database::load_whitelist`](crate::core::databases::Database::load_whitelist). - fn load_whitelist(&self) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hashes = conn.query_map("SELECT info_hash FROM whitelist", |info_hash: String| { - InfoHash::from_str(&info_hash).unwrap() - })?; - - Ok(info_hashes) - } - - /// Refer to [`databases::Database::save_persistent_torrent`](crate::core::databases::Database::save_persistent_torrent). - fn save_persistent_torrent(&self, info_hash: &InfoHash, completed: u32) -> Result<(), Error> { - const COMMAND : &str = "INSERT INTO torrents (info_hash, completed) VALUES (:info_hash_str, :completed) ON DUPLICATE KEY UPDATE completed = VALUES(completed)"; - - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - tracing::debug!("{}", info_hash_str); - - Ok(conn.exec_drop(COMMAND, params! { info_hash_str, completed })?) - } - - /// Refer to [`databases::Database::get_info_hash_from_whitelist`](crate::core::databases::Database::get_info_hash_from_whitelist). - fn get_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let select = conn.exec_first::( - "SELECT info_hash FROM whitelist WHERE info_hash = :info_hash", - params! { "info_hash" => info_hash.to_hex_string() }, - )?; - - let info_hash = select.map(|f| InfoHash::from_str(&f).expect("Failed to decode InfoHash String from DB!")); - - Ok(info_hash) - } - - /// Refer to [`databases::Database::add_info_hash_to_whitelist`](crate::core::databases::Database::add_info_hash_to_whitelist). - fn add_info_hash_to_whitelist(&self, info_hash: InfoHash) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash_str = info_hash.to_string(); - - conn.exec_drop( - "INSERT INTO whitelist (info_hash) VALUES (:info_hash_str)", - params! { info_hash_str }, - )?; - - Ok(1) - } - - /// Refer to [`databases::Database::remove_info_hash_from_whitelist`](crate::core::databases::Database::remove_info_hash_from_whitelist). - fn remove_info_hash_from_whitelist(&self, info_hash: InfoHash) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let info_hash = info_hash.to_string(); - - conn.exec_drop("DELETE FROM whitelist WHERE info_hash = :info_hash", params! { info_hash })?; - - Ok(1) - } - - /// Refer to [`databases::Database::get_key_from_keys`](crate::core::databases::Database::get_key_from_keys). - fn get_key_from_keys(&self, key: &Key) -> Result, Error> { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let query = conn.exec_first::<(String, Option), _, _>( - "SELECT `key`, valid_until FROM `keys` WHERE `key` = :key", - params! { "key" => key.to_string() }, - ); - - let key = query?; - - Ok(key.map(|(key, opt_valid_until)| match opt_valid_until { - Some(valid_until) => auth::PeerKey { - key: key.parse::().unwrap(), - valid_until: Some(Duration::from_secs(valid_until.unsigned_abs())), - }, - None => auth::PeerKey { - key: key.parse::().unwrap(), - valid_until: None, - }, - })) - } - - /// Refer to [`databases::Database::add_key_to_keys`](crate::core::databases::Database::add_key_to_keys). - fn add_key_to_keys(&self, auth_key: &auth::PeerKey) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - let key = auth_key.key.to_string(); - let valid_until = match auth_key.valid_until { - Some(valid_until) => valid_until.as_secs().to_string(), - None => todo!(), - }; - - conn.exec_drop( - "INSERT INTO `keys` (`key`, valid_until) VALUES (:key, :valid_until)", - params! { key, valid_until }, - )?; - - Ok(1) - } - - /// Refer to [`databases::Database::remove_key_from_keys`](crate::core::databases::Database::remove_key_from_keys). - fn remove_key_from_keys(&self, key: &Key) -> Result { - let mut conn = self.pool.get().map_err(|e| (e, DRIVER))?; - - conn.exec_drop("DELETE FROM `keys` WHERE key = :key", params! { "key" => key.to_string() })?; - - Ok(1) - } -} diff --git a/src/core/error.rs b/src/core/error.rs deleted file mode 100644 index d89b030c4..000000000 --- a/src/core/error.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Error returned by the core `Tracker`. -//! -//! Error | Context | Description -//! ---|---|--- -//! `PeerKeyNotValid` | Authentication | The supplied key is not valid. It may not be registered or expired. -//! `PeerNotAuthenticated` | Authentication | The peer did not provide the authentication key. -//! `TorrentNotWhitelisted` | Authorization | The action cannot be perform on a not-whitelisted torrent (it only applies for trackers running in `listed` or `private_listed` modes). -//! -use std::panic::Location; - -use torrust_tracker_located_error::LocatedError; -use torrust_tracker_primitives::info_hash::InfoHash; - -use super::auth::ParseKeyError; -use super::databases; - -/// Authentication or authorization error returned by the core `Tracker` -#[derive(thiserror::Error, Debug, Clone)] -pub enum Error { - // Authentication errors - #[error("The supplied key: {key:?}, is not valid: {source}")] - PeerKeyNotValid { - key: super::auth::Key, - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - - #[error("The peer is not authenticated, {location}")] - PeerNotAuthenticated { location: &'static Location<'static> }, - - // Authorization errors - #[error("The torrent: {info_hash}, is not whitelisted, {location}")] - TorrentNotWhitelisted { - info_hash: InfoHash, - location: &'static Location<'static>, - }, -} - -/// Errors related to peers keys. -#[allow(clippy::module_name_repetitions)] -#[derive(thiserror::Error, Debug, Clone)] -pub enum PeerKeyError { - #[error("Invalid peer key duration: {seconds_valid:?}, is not valid")] - DurationOverflow { seconds_valid: u64 }, - - #[error("Invalid key: {key}")] - InvalidKey { - key: String, - source: LocatedError<'static, ParseKeyError>, - }, - - #[error("Can't persist key: {source}")] - DatabaseError { - source: LocatedError<'static, databases::error::Error>, - }, -} diff --git a/src/core/mod.rs b/src/core/mod.rs deleted file mode 100644 index f12eb9a3d..000000000 --- a/src/core/mod.rs +++ /dev/null @@ -1,2221 +0,0 @@ -//! The core `tracker` module contains the generic `BitTorrent` tracker logic which is independent of the delivery layer. -//! -//! It contains the tracker services and their dependencies. It's a domain layer which does not -//! specify how the end user should connect to the `Tracker`. -//! -//! Typically this module is intended to be used by higher modules like: -//! -//! - A UDP tracker -//! - A HTTP tracker -//! - A tracker REST API -//! -//! ```text -//! Delivery layer Domain layer -//! -//! HTTP tracker | -//! UDP tracker |> Core tracker -//! Tracker REST API | -//! ``` -//! -//! # Table of contents -//! -//! - [Tracker](#tracker) -//! - [Announce request](#announce-request) -//! - [Scrape request](#scrape-request) -//! - [Torrents](#torrents) -//! - [Peers](#peers) -//! - [Configuration](#configuration) -//! - [Services](#services) -//! - [Authentication](#authentication) -//! - [Statistics](#statistics) -//! - [Persistence](#persistence) -//! -//! # Tracker -//! -//! The `Tracker` is the main struct in this module. `The` tracker has some groups of responsibilities: -//! -//! - **Core tracker**: it handles the information about torrents and peers. -//! - **Authentication**: it handles authentication keys which are used by HTTP trackers. -//! - **Authorization**: it handles the permission to perform requests. -//! - **Whitelist**: when the tracker runs in `listed` or `private_listed` mode all operations are restricted to whitelisted torrents. -//! - **Statistics**: it keeps and serves the tracker statistics. -//! -//! Refer to [torrust-tracker-configuration](https://docs.rs/torrust-tracker-configuration) crate docs to get more information about the tracker settings. -//! -//! ## Announce request -//! -//! Handling `announce` requests is the most important task for a `BitTorrent` tracker. -//! -//! A `BitTorrent` swarm is a network of peers that are all trying to download the same torrent. -//! When a peer wants to find other peers it announces itself to the swarm via the tracker. -//! The peer sends its data to the tracker so that the tracker can add it to the swarm. -//! The tracker responds to the peer with the list of other peers in the swarm so that -//! the peer can contact them to start downloading pieces of the file from them. -//! -//! Once you have instantiated the `Tracker` you can `announce` a new [`peer::Peer`] with: -//! -//! ```rust,no_run -//! use std::net::SocketAddr; -//! use std::net::IpAddr; -//! use std::net::Ipv4Addr; -//! use std::str::FromStr; -//! -//! use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; -//! use torrust_tracker_primitives::DurationSinceUnixEpoch; -//! use torrust_tracker_primitives::peer; -//! use torrust_tracker_primitives::info_hash::InfoHash; -//! -//! let info_hash = InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(); -//! -//! let peer = peer::Peer { -//! peer_id: PeerId(*b"-qB00000000000000001"), -//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), -//! updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), -//! uploaded: NumberOfBytes::new(0), -//! downloaded: NumberOfBytes::new(0), -//! left: NumberOfBytes::new(0), -//! event: AnnounceEvent::Completed, -//! }; -//! -//! let peer_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); -//! ``` -//! -//! ```text -//! let announce_data = tracker.announce(&info_hash, &mut peer, &peer_ip).await; -//! ``` -//! -//! The `Tracker` returns the list of peers for the torrent with the infohash `3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0`, -//! filtering out the peer that is making the `announce` request. -//! -//! > **NOTICE**: that the peer argument is mutable because the `Tracker` can change the peer IP if the peer is using a loopback IP. -//! -//! The `peer_ip` argument is the resolved peer ip. It's a common practice that trackers ignore the peer ip in the `announce` request params, -//! and resolve the peer ip using the IP of the client making the request. As the tracker is a domain service, the peer IP must be provided -//! for the `Tracker` user, which is usually a higher component with access the the request metadata, for example, connection data, proxy headers, -//! etcetera. -//! -//! The returned struct is: -//! -//! ```rust,no_run -//! use torrust_tracker_primitives::peer; -//! use torrust_tracker_configuration::AnnouncePolicy; -//! -//! pub struct AnnounceData { -//! pub peers: Vec, -//! pub swarm_stats: SwarmMetadata, -//! pub policy: AnnouncePolicy, // the tracker announce policy. -//! } -//! -//! pub struct SwarmMetadata { -//! pub completed: u32, // The number of peers that have ever completed downloading -//! pub seeders: u32, // The number of active peers that have completed downloading (seeders) -//! pub leechers: u32, // The number of active peers that have not completed downloading (leechers) -//! } -//! -//! // Core tracker configuration -//! pub struct AnnounceInterval { -//! // ... -//! pub interval: u32, // Interval in seconds that the client should wait between sending regular announce requests to the tracker -//! pub interval_min: u32, // Minimum announce interval. Clients must not reannounce more frequently than this -//! // ... -//! } -//! ``` -//! -//! Refer to `BitTorrent` BEPs and other sites for more information about the `announce` request: -//! -//! - [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) -//! - [BEP 23. Tracker Returns Compact Peer Lists](https://www.bittorrent.org/beps/bep_0023.html) -//! - [Vuze docs](https://wiki.vuze.com/w/Announce) -//! -//! ## Scrape request -//! -//! The `scrape` request allows clients to query metadata about the swarm in bulk. -//! -//! An `scrape` request includes a list of infohashes whose swarm metadata you want to collect. -//! -//! The returned struct is: -//! -//! ```rust,no_run -//! use torrust_tracker_primitives::info_hash::InfoHash; -//! use std::collections::HashMap; -//! -//! pub struct ScrapeData { -//! pub files: HashMap, -//! } -//! -//! pub struct SwarmMetadata { -//! pub complete: u32, // The number of active peers that have completed downloading (seeders) -//! pub downloaded: u32, // The number of peers that have ever completed downloading -//! pub incomplete: u32, // The number of active peers that have not completed downloading (leechers) -//! } -//! ``` -//! -//! The JSON representation of a sample `scrape` response would be like the following: -//! -//! ```json -//! { -//! 'files': { -//! 'xxxxxxxxxxxxxxxxxxxx': {'complete': 11, 'downloaded': 13772, 'incomplete': 19}, -//! 'yyyyyyyyyyyyyyyyyyyy': {'complete': 21, 'downloaded': 206, 'incomplete': 20} -//! } -//! } -//! ``` -//! -//! `xxxxxxxxxxxxxxxxxxxx` and `yyyyyyyyyyyyyyyyyyyy` are 20-byte infohash arrays. -//! There are two data structures for infohashes: byte arrays and hex strings: -//! -//! ```rust,no_run -//! use torrust_tracker_primitives::info_hash::InfoHash; -//! use std::str::FromStr; -//! -//! let info_hash: InfoHash = [255u8; 20].into(); -//! -//! assert_eq!( -//! info_hash, -//! InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() -//! ); -//! ``` -//! Refer to `BitTorrent` BEPs and other sites for more information about the `scrape` request: -//! -//! - [BEP 48. Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html) -//! - [BEP 15. UDP Tracker Protocol for `BitTorrent`. Scrape section](https://www.bittorrent.org/beps/bep_0015.html) -//! - [Vuze docs](https://wiki.vuze.com/w/Scrape) -//! -//! ## Torrents -//! -//! The [`torrent`] module contains all the data structures stored by the `Tracker` except for peers. -//! -//! We can represent the data stored in memory internally by the `Tracker` with this JSON object: -//! -//! ```json -//! { -//! "c1277613db1d28709b034a017ab2cae4be07ae10": { -//! "completed": 0, -//! "peers": { -//! "-qB00000000000000001": { -//! "peer_id": "-qB00000000000000001", -//! "peer_addr": "2.137.87.41:1754", -//! "updated": 1672419840, -//! "uploaded": 120, -//! "downloaded": 60, -//! "left": 60, -//! "event": "started" -//! }, -//! "-qB00000000000000002": { -//! "peer_id": "-qB00000000000000002", -//! "peer_addr": "23.17.287.141:2345", -//! "updated": 1679415984, -//! "uploaded": 80, -//! "downloaded": 20, -//! "left": 40, -//! "event": "started" -//! } -//! } -//! } -//! } -//! ``` -//! -//! The `Tracker` maintains an indexed-by-info-hash list of torrents. For each torrent, it stores a torrent `Entry`. -//! The torrent entry has two attributes: -//! -//! - `completed`: which is hte number of peers that have completed downloading the torrent file/s. As they have completed downloading, -//! they have a full version of the torrent data, and they can provide the full data to other peers. That's why they are also known as "seeders". -//! - `peers`: an indexed and orderer list of peer for the torrent. Each peer contains the data received from the peer in the `announce` request. -//! -//! The [`torrent`] module not only contains the original data obtained from peer via `announce` requests, it also contains -//! aggregate data that can be derived from the original data. For example: -//! -//! ```rust,no_run -//! pub struct SwarmMetadata { -//! pub complete: u32, // The number of active peers that have completed downloading (seeders) -//! pub downloaded: u32, // The number of peers that have ever completed downloading -//! pub incomplete: u32, // The number of active peers that have not completed downloading (leechers) -//! } -//! -//! ``` -//! -//! > **NOTICE**: that `complete` or `completed` peers are the peers that have completed downloading, but only the active ones are considered "seeders". -//! -//! `SwarmMetadata` struct follows name conventions for `scrape` responses. See [BEP 48](https://www.bittorrent.org/beps/bep_0048.html), while `SwarmMetadata` -//! is used for the rest of cases. -//! -//! Refer to [`torrent`] module for more details about these data structures. -//! -//! ## Peers -//! -//! A `Peer` is the struct used by the `Tracker` to keep peers data: -//! -//! ```rust,no_run -//! use std::net::SocketAddr; - -//! use aquatic_udp_protocol::PeerId; -//! use torrust_tracker_primitives::DurationSinceUnixEpoch; -//! use aquatic_udp_protocol::NumberOfBytes; -//! use aquatic_udp_protocol::AnnounceEvent; -//! -//! pub struct Peer { -//! pub peer_id: PeerId, // The peer ID -//! pub peer_addr: SocketAddr, // Peer socket address -//! pub updated: DurationSinceUnixEpoch, // Last time (timestamp) when the peer was updated -//! pub uploaded: NumberOfBytes, // Number of bytes the peer has uploaded so far -//! pub downloaded: NumberOfBytes, // Number of bytes the peer has downloaded so far -//! pub left: NumberOfBytes, // The number of bytes this peer still has to download -//! pub event: AnnounceEvent, // The event the peer has announced: `started`, `completed`, `stopped` -//! } -//! ``` -//! -//! Notice that most of the attributes are obtained from the `announce` request. -//! For example, an HTTP announce request would contain the following `GET` parameters: -//! -//! -//! -//! The `Tracker` keeps an in-memory ordered data structure with all the torrents and a list of peers for each torrent, together with some swarm metrics. -//! -//! We can represent the data stored in memory with this JSON object: -//! -//! ```json -//! { -//! "c1277613db1d28709b034a017ab2cae4be07ae10": { -//! "completed": 0, -//! "peers": { -//! "-qB00000000000000001": { -//! "peer_id": "-qB00000000000000001", -//! "peer_addr": "2.137.87.41:1754", -//! "updated": 1672419840, -//! "uploaded": 120, -//! "downloaded": 60, -//! "left": 60, -//! "event": "started" -//! }, -//! "-qB00000000000000002": { -//! "peer_id": "-qB00000000000000002", -//! "peer_addr": "23.17.287.141:2345", -//! "updated": 1679415984, -//! "uploaded": 80, -//! "downloaded": 20, -//! "left": 40, -//! "event": "started" -//! } -//! } -//! } -//! } -//! ``` -//! -//! That JSON object does not exist, it's only a representation of the `Tracker` torrents data. -//! -//! `c1277613db1d28709b034a017ab2cae4be07ae10` is the torrent infohash and `completed` contains the number of peers -//! that have a full version of the torrent data, also known as seeders. -//! -//! Refer to [`peer`] module for more information about peers. -//! -//! # Configuration -//! -//! You can control the behavior of this module with the module settings: -//! -//! ```toml -//! [logging] -//! threshold = "debug" -//! -//! [core] -//! inactive_peer_cleanup_interval = 600 -//! listed = false -//! private = false -//! tracker_usage_statistics = true -//! -//! [core.announce_policy] -//! interval = 120 -//! interval_min = 120 -//! -//! [core.database] -//! driver = "sqlite3" -//! path = "./storage/tracker/lib/database/sqlite3.db" -//! -//! [core.net] -//! on_reverse_proxy = false -//! external_ip = "2.137.87.41" -//! -//! [core.tracker_policy] -//! max_peer_timeout = 900 -//! persistent_torrent_completed_stat = false -//! remove_peerless_torrents = true -//! ``` -//! -//! Refer to the [`configuration` module documentation](https://docs.rs/torrust-tracker-configuration) to get more information about all options. -//! -//! # Services -//! -//! Services are domain services on top of the core tracker. Right now there are two types of service: -//! -//! - For statistics -//! - For torrents -//! -//! Services usually format the data inside the tracker to make it easier to consume by other parts. -//! They also decouple the internal data structure, used by the tracker, from the way we deliver that data to the consumers. -//! The internal data structure is designed for performance or low memory consumption. And it should be changed -//! without affecting the external consumers. -//! -//! Services can include extra features like pagination, for example. -//! -//! Refer to [`services`] module for more information about services. -//! -//! # Authentication -//! -//! One of the core `Tracker` responsibilities is to create and keep authentication keys. Auth keys are used by HTTP trackers -//! when the tracker is running in `private` or `private_listed` mode. -//! -//! HTTP tracker's clients need to obtain an auth key before starting requesting the tracker. Once the get one they have to include -//! a `PATH` param with the key in all the HTTP requests. For example, when a peer wants to `announce` itself it has to use the -//! HTTP tracker endpoint `GET /announce/:key`. -//! -//! The common way to obtain the keys is by using the tracker API directly or via other applications like the [Torrust Index](https://github.com/torrust/torrust-index). -//! -//! To learn more about tracker authentication, refer to the following modules : -//! -//! - [`auth`] module. -//! - [`core`](crate::core) module. -//! - [`http`](crate::servers::http) module. -//! -//! # Statistics -//! -//! The `Tracker` keeps metrics for some events: -//! -//! ```rust,no_run -//! pub struct Metrics { -//! // IP version 4 -//! -//! // HTTP tracker -//! pub tcp4_connections_handled: u64, -//! pub tcp4_announces_handled: u64, -//! pub tcp4_scrapes_handled: u64, -//! -//! // UDP tracker -//! pub udp4_connections_handled: u64, -//! pub udp4_announces_handled: u64, -//! pub udp4_scrapes_handled: u64, -//! -//! // IP version 6 -//! -//! // HTTP tracker -//! pub tcp6_connections_handled: u64, -//! pub tcp6_announces_handled: u64, -//! pub tcp6_scrapes_handled: u64, -//! -//! // UDP tracker -//! pub udp6_connections_handled: u64, -//! pub udp6_announces_handled: u64, -//! pub udp6_scrapes_handled: u64, -//! } -//! ``` -//! -//! The metrics maintained by the `Tracker` are: -//! -//! - `connections_handled`: number of connections handled by the tracker -//! - `announces_handled`: number of `announce` requests handled by the tracker -//! - `scrapes_handled`: number of `scrape` handled requests by the tracker -//! -//! > **NOTICE**: as the HTTP tracker does not have an specific `connection` request like the UDP tracker, `connections_handled` are -//! > increased on every `announce` and `scrape` requests. -//! -//! The tracker exposes an event sender API that allows the tracker users to send events. When a higher application service handles a -//! `connection` , `announce` or `scrape` requests, it notifies the `Tracker` by sending statistics events. -//! -//! For example, the HTTP tracker would send an event like the following when it handles an `announce` request received from a peer using IP version 4. -//! -//! ```text -//! tracker.send_stats_event(statistics::Event::Tcp4Announce).await -//! ``` -//! -//! Refer to [`statistics`] module for more information about statistics. -//! -//! # Persistence -//! -//! Right now the `Tracker` is responsible for storing and load data into and -//! from the database, when persistence is enabled. -//! -//! There are three types of persistent object: -//! -//! - Authentication keys (only expiring keys) -//! - Torrent whitelist -//! - Torrent metrics -//! -//! Refer to [`databases`] module for more information about persistence. -pub mod auth; -pub mod databases; -pub mod error; -pub mod services; -pub mod statistics; -pub mod torrent; - -pub mod peer_tests; - -use std::cmp::max; -use std::collections::HashMap; -use std::net::IpAddr; -use std::panic::Location; -use std::sync::Arc; -use std::time::Duration; - -use auth::PeerKey; -use databases::driver::Driver; -use derive_more::Constructor; -use error::PeerKeyError; -use tokio::sync::mpsc::error::SendError; -use torrust_tracker_clock::clock::Time; -use torrust_tracker_configuration::v2_0_0::database; -use torrust_tracker_configuration::{AnnouncePolicy, Core, TORRENT_PEERS_LIMIT}; -use torrust_tracker_located_error::Located; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; -use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; -use torrust_tracker_torrent_repository::entry::EntrySync; -use torrust_tracker_torrent_repository::repository::Repository; - -use self::auth::Key; -use self::error::Error; -use self::torrent::Torrents; -use crate::core::databases::Database; -use crate::CurrentClock; - -/// The domain layer tracker service. -/// -/// Its main responsibility is to handle the `announce` and `scrape` requests. -/// But it's also a container for the `Tracker` configuration, persistence, -/// authentication and other services. -/// -/// > **NOTICE**: the `Tracker` is not responsible for handling the network layer. -/// > Typically, the `Tracker` is used by a higher application service that handles -/// > the network layer. -pub struct Tracker { - /// The tracker configuration. - config: Core, - - /// A database driver implementation: [`Sqlite3`](crate::core::databases::sqlite) - /// or [`MySQL`](crate::core::databases::mysql) - database: Arc>, - - /// Tracker users' keys. Only for private trackers. - keys: tokio::sync::RwLock>, - - /// The list of allowed torrents. Only for listed trackers. - whitelist: tokio::sync::RwLock>, - - /// The in-memory torrents repository. - torrents: Arc, - - /// Service to send stats events. - stats_event_sender: Option>, - - /// The in-memory stats repo. - stats_repository: statistics::Repo, -} - -/// Structure that holds the data returned by the `announce` request. -#[derive(Clone, Debug, PartialEq, Constructor, Default)] -pub struct AnnounceData { - /// The list of peers that are downloading the same torrent. - /// It excludes the peer that made the request. - pub peers: Vec>, - /// Swarm statistics - pub stats: SwarmMetadata, - pub policy: AnnouncePolicy, -} - -/// How many peers the peer announcing wants in the announce response. -#[derive(Clone, Debug, PartialEq, Default)] -pub enum PeersWanted { - /// The peer wants as many peers as possible in the announce response. - #[default] - All, - /// The peer only wants a certain amount of peers in the announce response. - Only { amount: usize }, -} - -impl PeersWanted { - #[must_use] - pub fn only(limit: u32) -> Self { - let amount: usize = match limit.try_into() { - Ok(amount) => amount, - Err(_) => TORRENT_PEERS_LIMIT, - }; - - Self::Only { amount } - } - - fn limit(&self) -> usize { - match self { - PeersWanted::All => TORRENT_PEERS_LIMIT, - PeersWanted::Only { amount } => *amount, - } - } -} - -impl From for PeersWanted { - fn from(value: i32) -> Self { - if value > 0 { - match value.try_into() { - Ok(peers_wanted) => Self::Only { amount: peers_wanted }, - Err(_) => Self::All, - } - } else { - Self::All - } - } -} - -/// Structure that holds the data returned by the `scrape` request. -#[derive(Debug, PartialEq, Default)] -pub struct ScrapeData { - /// A map of infohashes and swarm metadata for each torrent. - pub files: HashMap, -} - -impl ScrapeData { - /// Creates a new empty `ScrapeData` with no files (torrents). - #[must_use] - pub fn empty() -> Self { - let files: HashMap = HashMap::new(); - Self { files } - } - - /// Creates a new `ScrapeData` with zeroed metadata for each torrent. - #[must_use] - pub fn zeroed(info_hashes: &Vec) -> Self { - let mut scrape_data = Self::empty(); - - for info_hash in info_hashes { - scrape_data.add_file(info_hash, SwarmMetadata::zeroed()); - } - - scrape_data - } - - /// Adds a torrent to the `ScrapeData`. - pub fn add_file(&mut self, info_hash: &InfoHash, swarm_metadata: SwarmMetadata) { - self.files.insert(*info_hash, swarm_metadata); - } - - /// Adds a torrent to the `ScrapeData` with zeroed metadata. - pub fn add_file_with_zeroed_metadata(&mut self, info_hash: &InfoHash) { - self.files.insert(*info_hash, SwarmMetadata::zeroed()); - } -} - -/// This type contains the info needed to add a new tracker key. -/// -/// You can upload a pre-generated key or let the app to generate a new one. -/// You can also set an expiration date or leave it empty (`None`) if you want -/// to create a permanent key that does not expire. -#[derive(Debug)] -pub struct AddKeyRequest { - /// The pre-generated key. Use `None` to generate a random key. - pub opt_key: Option, - - /// How long the key will be valid in seconds. Use `None` for permanent keys. - pub opt_seconds_valid: Option, -} - -impl Tracker { - /// `Tracker` constructor. - /// - /// # Errors - /// - /// Will return a `databases::error::Error` if unable to connect to database. The `Tracker` is responsible for the persistence. - pub fn new( - config: &Core, - stats_event_sender: Option>, - stats_repository: statistics::Repo, - ) -> Result { - let driver = match config.database.driver { - database::Driver::Sqlite3 => Driver::Sqlite3, - database::Driver::MySQL => Driver::MySQL, - }; - - let database = Arc::new(databases::driver::build(&driver, &config.database.path)?); - - Ok(Tracker { - config: config.clone(), - keys: tokio::sync::RwLock::new(std::collections::HashMap::new()), - whitelist: tokio::sync::RwLock::new(std::collections::HashSet::new()), - torrents: Arc::default(), - stats_event_sender, - stats_repository, - database, - }) - } - - /// Returns `true` is the tracker is in public mode. - pub fn is_public(&self) -> bool { - !self.config.private - } - - /// Returns `true` is the tracker is in private mode. - pub fn is_private(&self) -> bool { - self.config.private - } - - /// Returns `true` is the tracker is in whitelisted mode. - pub fn is_listed(&self) -> bool { - self.config.listed - } - - /// Returns `true` if the tracker requires authentication. - pub fn requires_authentication(&self) -> bool { - self.is_private() - } - - /// Returns `true` is the tracker is in whitelisted mode. - pub fn is_behind_reverse_proxy(&self) -> bool { - self.config.net.on_reverse_proxy - } - - pub fn get_announce_policy(&self) -> AnnouncePolicy { - self.config.announce_policy - } - - pub fn get_maybe_external_ip(&self) -> Option { - self.config.net.external_ip - } - - /// It handles an announce request. - /// - /// # Context: Tracker - /// - /// BEP 03: [The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html). - pub fn announce( - &self, - info_hash: &InfoHash, - peer: &mut peer::Peer, - remote_client_ip: &IpAddr, - peers_wanted: &PeersWanted, - ) -> AnnounceData { - // code-review: maybe instead of mutating the peer we could just return - // a tuple with the new peer and the announce data: (Peer, AnnounceData). - // It could even be a different struct: `StoredPeer` or `PublicPeer`. - - // code-review: in the `scrape` function we perform an authorization check. - // We check if the torrent is whitelisted. Should we also check authorization here? - // I think so because the `Tracker` has the responsibility for checking authentication and authorization. - // The `Tracker` has delegated that responsibility to the handlers - // (because we want to return a friendly error response) but that does not mean we should - // double-check authorization at this domain level too. - // I would propose to return a `Result` here. - // Besides, regarding authentication the `Tracker` is also responsible for authentication but - // we are actually handling authentication at the handlers level. So I would extract that - // responsibility into another authentication service. - - tracing::debug!("Before: {peer:?}"); - peer.change_ip(&assign_ip_address_to_peer(remote_client_ip, self.config.net.external_ip)); - tracing::debug!("After: {peer:?}"); - - let stats = self.upsert_peer_and_get_stats(info_hash, peer); - - let peers = self.get_peers_for(info_hash, peer, peers_wanted.limit()); - - AnnounceData { - peers, - stats, - policy: self.get_announce_policy(), - } - } - - /// It handles a scrape request. - /// - /// # Context: Tracker - /// - /// BEP 48: [Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html). - pub async fn scrape(&self, info_hashes: &Vec) -> ScrapeData { - let mut scrape_data = ScrapeData::empty(); - - for info_hash in info_hashes { - let swarm_metadata = match self.authorize(info_hash).await { - Ok(()) => self.get_swarm_metadata(info_hash), - Err(_) => SwarmMetadata::zeroed(), - }; - scrape_data.add_file(info_hash, swarm_metadata); - } - - scrape_data - } - - /// It returns the data for a `scrape` response. - fn get_swarm_metadata(&self, info_hash: &InfoHash) -> SwarmMetadata { - match self.torrents.get(info_hash) { - Some(torrent_entry) => torrent_entry.get_swarm_metadata(), - None => SwarmMetadata::default(), - } - } - - /// It loads the torrents from database into memory. It only loads the torrent entry list with the number of seeders for each torrent. - /// Peers data is not persisted. - /// - /// # Context: Tracker - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to load the list of `persistent_torrents` from the database. - pub fn load_torrents_from_database(&self) -> Result<(), databases::error::Error> { - let persistent_torrents = self.database.load_persistent_torrents()?; - - self.torrents.import_persistent(&persistent_torrents); - - Ok(()) - } - - /// # Context: Tracker - /// - /// Get torrent peers for a given torrent and client. - /// - /// It filters out the client making the request. - fn get_peers_for(&self, info_hash: &InfoHash, peer: &peer::Peer, limit: usize) -> Vec> { - match self.torrents.get(info_hash) { - None => vec![], - Some(entry) => entry.get_peers_for_client(&peer.peer_addr, Some(max(limit, TORRENT_PEERS_LIMIT))), - } - } - - /// # Context: Tracker - /// - /// Get torrent peers for a given torrent. - pub fn get_torrent_peers(&self, info_hash: &InfoHash) -> Vec> { - match self.torrents.get(info_hash) { - None => vec![], - Some(entry) => entry.get_peers(Some(TORRENT_PEERS_LIMIT)), - } - } - - /// It updates the torrent entry in memory, it also stores in the database - /// the torrent info data which is persistent, and finally return the data - /// needed for a `announce` request response. - /// - /// # Context: Tracker - pub fn upsert_peer_and_get_stats(&self, info_hash: &InfoHash, peer: &peer::Peer) -> SwarmMetadata { - let swarm_metadata_before = match self.torrents.get_swarm_metadata(info_hash) { - Some(swarm_metadata) => swarm_metadata, - None => SwarmMetadata::zeroed(), - }; - - self.torrents.upsert_peer(info_hash, peer); - - let swarm_metadata_after = match self.torrents.get_swarm_metadata(info_hash) { - Some(swarm_metadata) => swarm_metadata, - None => SwarmMetadata::zeroed(), - }; - - if swarm_metadata_before != swarm_metadata_after { - self.persist_stats(info_hash, &swarm_metadata_after); - } - - swarm_metadata_after - } - - /// It stores the torrents stats into the database (if persistency is enabled). - /// - /// # Context: Tracker - fn persist_stats(&self, info_hash: &InfoHash, swarm_metadata: &SwarmMetadata) { - if self.config.tracker_policy.persistent_torrent_completed_stat { - let completed = swarm_metadata.downloaded; - let info_hash = *info_hash; - - drop(self.database.save_persistent_torrent(&info_hash, completed)); - } - } - - /// It calculates and returns the general `Tracker` - /// [`TorrentsMetrics`] - /// - /// # Context: Tracker - /// - /// # Panics - /// Panics if unable to get the torrent metrics. - pub fn get_torrents_metrics(&self) -> TorrentsMetrics { - self.torrents.get_metrics() - } - - /// Remove inactive peers and (optionally) peerless torrents. - /// - /// # Context: Tracker - pub fn cleanup_torrents(&self) { - let current_cutoff = CurrentClock::now_sub(&Duration::from_secs(u64::from(self.config.tracker_policy.max_peer_timeout))) - .unwrap_or_default(); - - self.torrents.remove_inactive_peers(current_cutoff); - - if self.config.tracker_policy.remove_peerless_torrents { - self.torrents.remove_peerless_torrents(&self.config.tracker_policy); - } - } - - /// It authenticates the peer `key` against the `Tracker` authentication - /// key list. - /// - /// # Errors - /// - /// Will return an error if the the authentication key cannot be verified. - /// - /// # Context: Authentication - pub async fn authenticate(&self, key: &Key) -> Result<(), auth::Error> { - if self.is_private() { - self.verify_auth_key(key).await - } else { - Ok(()) - } - } - - /// Adds new peer keys to the tracker. - /// - /// Keys can be pre-generated or randomly created. They can also be permanent or expire. - /// - /// # Errors - /// - /// Will return an error if: - /// - /// - The key duration overflows the duration type maximum value. - /// - The provided pre-generated key is invalid. - /// - The key could not been persisted due to database issues. - pub async fn add_peer_key(&self, add_key_req: AddKeyRequest) -> Result { - // code-review: all methods related to keys should be moved to a new independent "keys" service. - - match add_key_req.opt_key { - // Upload pre-generated key - Some(pre_existing_key) => { - if let Some(seconds_valid) = add_key_req.opt_seconds_valid { - // Expiring key - let Some(valid_until) = CurrentClock::now_add(&Duration::from_secs(seconds_valid)) else { - return Err(PeerKeyError::DurationOverflow { seconds_valid }); - }; - - let key = pre_existing_key.parse::(); - - match key { - Ok(key) => match self.add_auth_key(key, Some(valid_until)).await { - Ok(auth_key) => Ok(auth_key), - Err(err) => Err(PeerKeyError::DatabaseError { - source: Located(err).into(), - }), - }, - Err(err) => Err(PeerKeyError::InvalidKey { - key: pre_existing_key, - source: Located(err).into(), - }), - } - } else { - // Permanent key - let key = pre_existing_key.parse::(); - - match key { - Ok(key) => match self.add_permanent_auth_key(key).await { - Ok(auth_key) => Ok(auth_key), - Err(err) => Err(PeerKeyError::DatabaseError { - source: Located(err).into(), - }), - }, - Err(err) => Err(PeerKeyError::InvalidKey { - key: pre_existing_key, - source: Located(err).into(), - }), - } - } - } - // Generate a new random key - None => match add_key_req.opt_seconds_valid { - // Expiring key - Some(seconds_valid) => match self.generate_auth_key(Some(Duration::from_secs(seconds_valid))).await { - Ok(auth_key) => Ok(auth_key), - Err(err) => Err(PeerKeyError::DatabaseError { - source: Located(err).into(), - }), - }, - // Permanent key - None => match self.generate_permanent_auth_key().await { - Ok(auth_key) => Ok(auth_key), - Err(err) => Err(PeerKeyError::DatabaseError { - source: Located(err).into(), - }), - }, - }, - } - } - - /// It generates a new permanent authentication key. - /// - /// Authentication keys are used by HTTP trackers. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to add the `auth_key` to the database. - pub async fn generate_permanent_auth_key(&self) -> Result { - self.generate_auth_key(None).await - } - - /// It generates a new expiring authentication key. - /// - /// Authentication keys are used by HTTP trackers. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to add the `auth_key` to the database. - /// - /// # Arguments - /// - /// * `lifetime` - The duration in seconds for the new key. The key will be - /// no longer valid after `lifetime` seconds. - pub async fn generate_auth_key(&self, lifetime: Option) -> Result { - let auth_key = auth::generate_key(lifetime); - - self.database.add_key_to_keys(&auth_key)?; - self.keys.write().await.insert(auth_key.key.clone(), auth_key.clone()); - Ok(auth_key) - } - - /// It adds a pre-generated permanent authentication key. - /// - /// Authentication keys are used by HTTP trackers. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to add the `auth_key` to the - /// database. For example, if the key already exist. - /// - /// # Arguments - /// - /// * `key` - The pre-generated key. - pub async fn add_permanent_auth_key(&self, key: Key) -> Result { - self.add_auth_key(key, None).await - } - - /// It adds a pre-generated authentication key. - /// - /// Authentication keys are used by HTTP trackers. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to add the `auth_key` to the - /// database. For example, if the key already exist. - /// - /// # Arguments - /// - /// * `key` - The pre-generated key. - /// * `lifetime` - The duration in seconds for the new key. The key will be - /// no longer valid after `lifetime` seconds. - pub async fn add_auth_key( - &self, - key: Key, - valid_until: Option, - ) -> Result { - let auth_key = PeerKey { key, valid_until }; - - // code-review: should we return a friendly error instead of the DB - // constrain error when the key already exist? For now, it's returning - // the specif error for each DB driver when a UNIQUE constrain fails. - self.database.add_key_to_keys(&auth_key)?; - self.keys.write().await.insert(auth_key.key.clone(), auth_key.clone()); - Ok(auth_key) - } - - /// It removes an authentication key. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to remove the `key` to the database. - pub async fn remove_auth_key(&self, key: &Key) -> Result<(), databases::error::Error> { - self.database.remove_key_from_keys(key)?; - self.keys.write().await.remove(key); - Ok(()) - } - - /// It verifies an authentication key. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `key::Error` if unable to get any `auth_key`. - async fn verify_auth_key(&self, key: &Key) -> Result<(), auth::Error> { - match self.keys.read().await.get(key) { - None => Err(auth::Error::UnableToReadKey { - location: Location::caller(), - key: Box::new(key.clone()), - }), - Some(key) => match self.config.private_mode { - Some(private_mode) => { - if private_mode.check_keys_expiration { - return auth::verify_key_expiration(key); - } - - Ok(()) - } - None => auth::verify_key_expiration(key), - }, - } - } - - /// The `Tracker` stores the authentication keys in memory and in the database. - /// In case you need to restart the `Tracker` you can load the keys from the database - /// into memory with this function. Keys are automatically stored in the database when they - /// are generated. - /// - /// # Context: Authentication - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to `load_keys` from the database. - pub async fn load_keys_from_database(&self) -> Result<(), databases::error::Error> { - let keys_from_database = self.database.load_keys()?; - let mut keys = self.keys.write().await; - - keys.clear(); - - for key in keys_from_database { - keys.insert(key.key.clone(), key); - } - - Ok(()) - } - - /// Right now, there is only authorization when the `Tracker` runs in - /// `listed` or `private_listed` modes. - /// - /// # Context: Authorization - /// - /// # Errors - /// - /// Will return an error if the tracker is running in `listed` mode - /// and the infohash is not whitelisted. - pub async fn authorize(&self, info_hash: &InfoHash) -> Result<(), Error> { - if !self.is_listed() { - return Ok(()); - } - - if self.is_info_hash_whitelisted(info_hash).await { - return Ok(()); - } - - Err(Error::TorrentNotWhitelisted { - info_hash: *info_hash, - location: Location::caller(), - }) - } - - /// It adds a torrent to the whitelist. - /// Adding torrents is not relevant to public trackers. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to add the `info_hash` into the whitelist database. - pub async fn add_torrent_to_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - self.add_torrent_to_database_whitelist(info_hash)?; - self.add_torrent_to_memory_whitelist(info_hash).await; - Ok(()) - } - - /// It adds a torrent to the whitelist if it has not been whitelisted previously - fn add_torrent_to_database_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; - - if is_whitelisted { - return Ok(()); - } - - self.database.add_info_hash_to_whitelist(*info_hash)?; - - Ok(()) - } - - pub async fn add_torrent_to_memory_whitelist(&self, info_hash: &InfoHash) -> bool { - self.whitelist.write().await.insert(*info_hash) - } - - /// It removes a torrent from the whitelist. - /// Removing torrents is not relevant to public trackers. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to remove the `info_hash` from the whitelist database. - pub async fn remove_torrent_from_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - self.remove_torrent_from_database_whitelist(info_hash)?; - self.remove_torrent_from_memory_whitelist(info_hash).await; - Ok(()) - } - - /// It removes a torrent from the whitelist in the database. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to remove the `info_hash` from the whitelist database. - pub fn remove_torrent_from_database_whitelist(&self, info_hash: &InfoHash) -> Result<(), databases::error::Error> { - let is_whitelisted = self.database.is_info_hash_whitelisted(*info_hash)?; - - if !is_whitelisted { - return Ok(()); - } - - self.database.remove_info_hash_from_whitelist(*info_hash)?; - - Ok(()) - } - - /// It removes a torrent from the whitelist in memory. - /// - /// # Context: Whitelist - pub async fn remove_torrent_from_memory_whitelist(&self, info_hash: &InfoHash) -> bool { - self.whitelist.write().await.remove(info_hash) - } - - /// It checks if a torrent is whitelisted. - /// - /// # Context: Whitelist - pub async fn is_info_hash_whitelisted(&self, info_hash: &InfoHash) -> bool { - self.whitelist.read().await.contains(info_hash) - } - - /// It loads the whitelist from the database. - /// - /// # Context: Whitelist - /// - /// # Errors - /// - /// Will return a `database::Error` if unable to load the list whitelisted `info_hash`s from the database. - pub async fn load_whitelist_from_database(&self) -> Result<(), databases::error::Error> { - let whitelisted_torrents_from_database = self.database.load_whitelist()?; - let mut whitelist = self.whitelist.write().await; - - whitelist.clear(); - - for info_hash in whitelisted_torrents_from_database { - let _: bool = whitelist.insert(info_hash); - } - - Ok(()) - } - - /// It return the `Tracker` [`statistics::Metrics`]. - /// - /// # Context: Statistics - pub async fn get_stats(&self) -> tokio::sync::RwLockReadGuard<'_, statistics::Metrics> { - self.stats_repository.get_stats().await - } - - /// It allows to send a statistic events which eventually will be used to update [`statistics::Metrics`]. - /// - /// # Context: Statistics - pub async fn send_stats_event(&self, event: statistics::Event) -> Option>> { - match &self.stats_event_sender { - None => None, - Some(stats_event_sender) => stats_event_sender.send_event(event).await, - } - } - - /// It drops the database tables. - /// - /// # Errors - /// - /// Will return `Err` if unable to drop tables. - pub fn drop_database_tables(&self) -> Result<(), databases::error::Error> { - // todo: this is only used for testing. WE have to pass the database - // reference directly to the tests instead of via the tracker. - self.database.drop_database_tables() - } -} - -#[must_use] -fn assign_ip_address_to_peer(remote_client_ip: &IpAddr, tracker_external_ip: Option) -> IpAddr { - if let Some(host_ip) = tracker_external_ip.filter(|_| remote_client_ip.is_loopback()) { - host_ip - } else { - *remote_client_ip - } -} - -#[cfg(test)] -mod tests { - - mod the_tracker { - - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::str::FromStr; - use std::sync::Arc; - - use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; - use torrust_tracker_configuration::TORRENT_PEERS_LIMIT; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_primitives::DurationSinceUnixEpoch; - use torrust_tracker_test_helpers::configuration; - - use crate::core::peer::Peer; - use crate::core::services::tracker_factory; - use crate::core::{TorrentsMetrics, Tracker}; - use crate::shared::bit_torrent::info_hash::fixture::gen_seeded_infohash; - - fn public_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_public()) - } - - fn private_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_private()) - } - - fn whitelisted_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_listed()) - } - - pub fn tracker_persisting_torrents_in_database() -> Tracker { - let mut configuration = configuration::ephemeral(); - configuration.core.tracker_policy.persistent_torrent_completed_stat = true; - tracker_factory(&configuration) - } - - fn sample_info_hash() -> InfoHash { - "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap() - } - - // The client peer IP - fn peer_ip() -> IpAddr { - IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()) - } - - /// Sample peer whose state is not relevant for the tests - fn sample_peer() -> Peer { - complete_peer() - } - - /// Sample peer when for tests that need more than one peer - fn sample_peer_1() -> Peer { - Peer { - peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), - event: AnnounceEvent::Completed, - } - } - - /// Sample peer when for tests that need more than one peer - fn sample_peer_2() -> Peer { - Peer { - peer_id: PeerId(*b"-qB00000000000000002"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), - event: AnnounceEvent::Completed, - } - } - - fn seeder() -> Peer { - complete_peer() - } - - fn leecher() -> Peer { - incomplete_peer() - } - - fn started_peer() -> Peer { - incomplete_peer() - } - - fn completed_peer() -> Peer { - complete_peer() - } - - /// A peer that counts as `complete` is swarm metadata - /// IMPORTANT!: it only counts if the it has been announce at least once before - /// announcing the `AnnounceEvent::Completed` event. - fn complete_peer() -> Peer { - Peer { - peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), // No bytes left to download - event: AnnounceEvent::Completed, - } - } - - /// A peer that counts as `incomplete` is swarm metadata - fn incomplete_peer() -> Peer { - Peer { - peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(1000), // Still bytes to download - event: AnnounceEvent::Started, - } - } - - #[tokio::test] - async fn should_collect_torrent_metrics() { - let tracker = public_tracker(); - - let torrents_metrics = tracker.get_torrents_metrics(); - - assert_eq!( - torrents_metrics, - TorrentsMetrics { - complete: 0, - downloaded: 0, - incomplete: 0, - torrents: 0 - } - ); - } - - #[tokio::test] - async fn it_should_return_the_peers_for_a_given_torrent() { - let tracker = public_tracker(); - - let info_hash = sample_info_hash(); - let peer = sample_peer(); - - tracker.upsert_peer_and_get_stats(&info_hash, &peer); - - let peers = tracker.get_torrent_peers(&info_hash); - - assert_eq!(peers, vec![Arc::new(peer)]); - } - - /// It generates a peer id from a number where the number is the last - /// part of the peer ID. For example, for `12` it returns - /// `-qB00000000000000012`. - fn numeric_peer_id(two_digits_value: i32) -> PeerId { - // Format idx as a string with leading zeros, ensuring it has exactly 2 digits - let idx_str = format!("{two_digits_value:02}"); - - // Create the base part of the peer ID. - let base = b"-qB00000000000000000"; - - // Concatenate the base with idx bytes, ensuring the total length is 20 bytes. - let mut peer_id_bytes = [0u8; 20]; - peer_id_bytes[..base.len()].copy_from_slice(base); - peer_id_bytes[base.len() - idx_str.len()..].copy_from_slice(idx_str.as_bytes()); - - PeerId(peer_id_bytes) - } - - #[tokio::test] - async fn it_should_return_74_peers_at_the_most_for_a_given_torrent() { - let tracker = public_tracker(); - - let info_hash = sample_info_hash(); - - for idx in 1..=75 { - let peer = Peer { - peer_id: numeric_peer_id(idx), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), // No bytes left to download - event: AnnounceEvent::Completed, - }; - - tracker.upsert_peer_and_get_stats(&info_hash, &peer); - } - - let peers = tracker.get_torrent_peers(&info_hash); - - assert_eq!(peers.len(), 74); - } - - #[tokio::test] - async fn it_should_return_the_peers_for_a_given_torrent_excluding_a_given_peer() { - let tracker = public_tracker(); - - let info_hash = sample_info_hash(); - let peer = sample_peer(); - - tracker.upsert_peer_and_get_stats(&info_hash, &peer); - - let peers = tracker.get_peers_for(&info_hash, &peer, TORRENT_PEERS_LIMIT); - - assert_eq!(peers, vec![]); - } - - #[tokio::test] - async fn it_should_return_74_peers_at_the_most_for_a_given_torrent_when_it_filters_out_a_given_peer() { - let tracker = public_tracker(); - - let info_hash = sample_info_hash(); - - let excluded_peer = sample_peer(); - - tracker.upsert_peer_and_get_stats(&info_hash, &excluded_peer); - - // Add 74 peers - for idx in 2..=75 { - let peer = Peer { - peer_id: numeric_peer_id(idx), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), // No bytes left to download - event: AnnounceEvent::Completed, - }; - - tracker.upsert_peer_and_get_stats(&info_hash, &peer); - } - - let peers = tracker.get_peers_for(&info_hash, &excluded_peer, TORRENT_PEERS_LIMIT); - - assert_eq!(peers.len(), 74); - } - - #[tokio::test] - async fn it_should_return_the_torrent_metrics() { - let tracker = public_tracker(); - - tracker.upsert_peer_and_get_stats(&sample_info_hash(), &leecher()); - - let torrent_metrics = tracker.get_torrents_metrics(); - - assert_eq!( - torrent_metrics, - TorrentsMetrics { - complete: 0, - downloaded: 0, - incomplete: 1, - torrents: 1, - } - ); - } - - #[tokio::test] - async fn it_should_get_many_the_torrent_metrics() { - let tracker = public_tracker(); - - let start_time = std::time::Instant::now(); - for i in 0..1_000_000 { - tracker.upsert_peer_and_get_stats(&gen_seeded_infohash(&i), &leecher()); - } - let result_a = start_time.elapsed(); - - let start_time = std::time::Instant::now(); - let torrent_metrics = tracker.get_torrents_metrics(); - let result_b = start_time.elapsed(); - - assert_eq!( - (torrent_metrics), - (TorrentsMetrics { - complete: 0, - downloaded: 0, - incomplete: 1_000_000, - torrents: 1_000_000, - }), - "{result_a:?} {result_b:?}" - ); - } - - mod for_all_config_modes { - - mod handling_an_announce_request { - - use std::sync::Arc; - - use crate::core::tests::the_tracker::{ - peer_ip, public_tracker, sample_info_hash, sample_peer, sample_peer_1, sample_peer_2, - }; - use crate::core::PeersWanted; - - mod should_assign_the_ip_to_the_peer { - - use std::net::{IpAddr, Ipv4Addr}; - - use crate::core::assign_ip_address_to_peer; - - #[test] - fn using_the_source_ip_instead_of_the_ip_in_the_announce_request() { - let remote_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, None); - - assert_eq!(peer_ip, remote_ip); - } - - mod and_when_the_client_ip_is_a_ipv4_loopback_ip { - - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use std::str::FromStr; - - use crate::core::assign_ip_address_to_peer; - - #[test] - fn it_should_use_the_loopback_ip_if_the_tracker_does_not_have_the_external_ip_configuration() { - let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, None); - - assert_eq!(peer_ip, remote_ip); - } - - #[test] - fn it_should_use_the_external_tracker_ip_in_tracker_configuration_if_it_is_defined() { - let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); - - let tracker_external_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); - - assert_eq!(peer_ip, tracker_external_ip); - } - - #[test] - fn it_should_use_the_external_ip_in_the_tracker_configuration_if_it_is_defined_even_if_the_external_ip_is_an_ipv6_ip( - ) { - let remote_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); - - let tracker_external_ip = - IpAddr::V6(Ipv6Addr::from_str("2345:0425:2CA1:0000:0000:0567:5673:23b5").unwrap()); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); - - assert_eq!(peer_ip, tracker_external_ip); - } - } - - mod and_when_client_ip_is_a_ipv6_loopback_ip { - - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use std::str::FromStr; - - use crate::core::assign_ip_address_to_peer; - - #[test] - fn it_should_use_the_loopback_ip_if_the_tracker_does_not_have_the_external_ip_configuration() { - let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, None); - - assert_eq!(peer_ip, remote_ip); - } - - #[test] - fn it_should_use_the_external_ip_in_tracker_configuration_if_it_is_defined() { - let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); - - let tracker_external_ip = - IpAddr::V6(Ipv6Addr::from_str("2345:0425:2CA1:0000:0000:0567:5673:23b5").unwrap()); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); - - assert_eq!(peer_ip, tracker_external_ip); - } - - #[test] - fn it_should_use_the_external_ip_in_the_tracker_configuration_if_it_is_defined_even_if_the_external_ip_is_an_ipv4_ip( - ) { - let remote_ip = IpAddr::V6(Ipv6Addr::LOCALHOST); - - let tracker_external_ip = IpAddr::V4(Ipv4Addr::from_str("126.0.0.1").unwrap()); - - let peer_ip = assign_ip_address_to_peer(&remote_ip, Some(tracker_external_ip)); - - assert_eq!(peer_ip, tracker_external_ip); - } - } - } - - #[tokio::test] - async fn it_should_return_the_announce_data_with_an_empty_peer_list_when_it_is_the_first_announced_peer() { - let tracker = public_tracker(); - - let mut peer = sample_peer(); - - let announce_data = tracker.announce(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::All); - - assert_eq!(announce_data.peers, vec![]); - } - - #[tokio::test] - async fn it_should_return_the_announce_data_with_the_previously_announced_peers() { - let tracker = public_tracker(); - - let mut previously_announced_peer = sample_peer_1(); - tracker.announce( - &sample_info_hash(), - &mut previously_announced_peer, - &peer_ip(), - &PeersWanted::All, - ); - - let mut peer = sample_peer_2(); - let announce_data = tracker.announce(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::All); - - assert_eq!(announce_data.peers, vec![Arc::new(previously_announced_peer)]); - } - - mod it_should_update_the_swarm_stats_for_the_torrent { - - use crate::core::tests::the_tracker::{ - completed_peer, leecher, peer_ip, public_tracker, sample_info_hash, seeder, started_peer, - }; - use crate::core::PeersWanted; - - #[tokio::test] - async fn when_the_peer_is_a_seeder() { - let tracker = public_tracker(); - - let mut peer = seeder(); - - let announce_data = tracker.announce(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::All); - - assert_eq!(announce_data.stats.complete, 1); - } - - #[tokio::test] - async fn when_the_peer_is_a_leecher() { - let tracker = public_tracker(); - - let mut peer = leecher(); - - let announce_data = tracker.announce(&sample_info_hash(), &mut peer, &peer_ip(), &PeersWanted::All); - - assert_eq!(announce_data.stats.incomplete, 1); - } - - #[tokio::test] - async fn when_a_previously_announced_started_peer_has_completed_downloading() { - let tracker = public_tracker(); - - // We have to announce with "started" event because peer does not count if peer was not previously known - let mut started_peer = started_peer(); - tracker.announce(&sample_info_hash(), &mut started_peer, &peer_ip(), &PeersWanted::All); - - let mut completed_peer = completed_peer(); - let announce_data = - tracker.announce(&sample_info_hash(), &mut completed_peer, &peer_ip(), &PeersWanted::All); - - assert_eq!(announce_data.stats.downloaded, 1); - } - } - } - - mod handling_a_scrape_request { - - use std::net::{IpAddr, Ipv4Addr}; - - use torrust_tracker_primitives::info_hash::InfoHash; - - use crate::core::tests::the_tracker::{complete_peer, incomplete_peer, public_tracker}; - use crate::core::{PeersWanted, ScrapeData, SwarmMetadata}; - - #[tokio::test] - async fn it_should_return_a_zeroed_swarm_metadata_for_the_requested_file_if_the_tracker_does_not_have_that_torrent( - ) { - let tracker = public_tracker(); - - let info_hashes = vec!["3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap()]; - - let scrape_data = tracker.scrape(&info_hashes).await; - - let mut expected_scrape_data = ScrapeData::empty(); - - expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[0]); - - assert_eq!(scrape_data, expected_scrape_data); - } - - #[tokio::test] - async fn it_should_return_the_swarm_metadata_for_the_requested_file_if_the_tracker_has_that_torrent() { - let tracker = public_tracker(); - - let info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(); - - // Announce a "complete" peer for the torrent - let mut complete_peer = complete_peer(); - tracker.announce( - &info_hash, - &mut complete_peer, - &IpAddr::V4(Ipv4Addr::new(126, 0, 0, 10)), - &PeersWanted::All, - ); - - // Announce an "incomplete" peer for the torrent - let mut incomplete_peer = incomplete_peer(); - tracker.announce( - &info_hash, - &mut incomplete_peer, - &IpAddr::V4(Ipv4Addr::new(126, 0, 0, 11)), - &PeersWanted::All, - ); - - // Scrape - let scrape_data = tracker.scrape(&vec![info_hash]).await; - - // The expected swarm metadata for the file - let mut expected_scrape_data = ScrapeData::empty(); - expected_scrape_data.add_file( - &info_hash, - SwarmMetadata { - complete: 0, // the "complete" peer does not count because it was not previously known - downloaded: 0, - incomplete: 1, // the "incomplete" peer we have just announced - }, - ); - - assert_eq!(scrape_data, expected_scrape_data); - } - - #[tokio::test] - async fn it_should_allow_scraping_for_multiple_torrents() { - let tracker = public_tracker(); - - let info_hashes = vec![ - "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), - "99c82bb73505a3c0b453f9fa0e881d6e5a32a0c1".parse::().unwrap(), - ]; - - let scrape_data = tracker.scrape(&info_hashes).await; - - let mut expected_scrape_data = ScrapeData::empty(); - expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[0]); - expected_scrape_data.add_file_with_zeroed_metadata(&info_hashes[1]); - - assert_eq!(scrape_data, expected_scrape_data); - } - } - } - - mod configured_as_whitelisted { - - mod handling_authorization { - use crate::core::tests::the_tracker::{sample_info_hash, whitelisted_tracker}; - - #[tokio::test] - async fn it_should_authorize_the_announce_and_scrape_actions_on_whitelisted_torrents() { - let tracker = whitelisted_tracker(); - - let info_hash = sample_info_hash(); - - let result = tracker.add_torrent_to_whitelist(&info_hash).await; - assert!(result.is_ok()); - - let result = tracker.authorize(&info_hash).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn it_should_not_authorize_the_announce_and_scrape_actions_on_not_whitelisted_torrents() { - let tracker = whitelisted_tracker(); - - let info_hash = sample_info_hash(); - - let result = tracker.authorize(&info_hash).await; - assert!(result.is_err()); - } - } - - mod handling_the_torrent_whitelist { - use crate::core::tests::the_tracker::{sample_info_hash, whitelisted_tracker}; - - #[tokio::test] - async fn it_should_add_a_torrent_to_the_whitelist() { - let tracker = whitelisted_tracker(); - - let info_hash = sample_info_hash(); - - tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - assert!(tracker.is_info_hash_whitelisted(&info_hash).await); - } - - #[tokio::test] - async fn it_should_remove_a_torrent_from_the_whitelist() { - let tracker = whitelisted_tracker(); - - let info_hash = sample_info_hash(); - - tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - tracker.remove_torrent_from_whitelist(&info_hash).await.unwrap(); - - assert!(!tracker.is_info_hash_whitelisted(&info_hash).await); - } - - mod persistence { - use crate::core::tests::the_tracker::{sample_info_hash, whitelisted_tracker}; - - #[tokio::test] - async fn it_should_load_the_whitelist_from_the_database() { - let tracker = whitelisted_tracker(); - - let info_hash = sample_info_hash(); - - tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - // Remove torrent from the in-memory whitelist - tracker.whitelist.write().await.remove(&info_hash); - assert!(!tracker.is_info_hash_whitelisted(&info_hash).await); - - tracker.load_whitelist_from_database().await.unwrap(); - - assert!(tracker.is_info_hash_whitelisted(&info_hash).await); - } - } - } - - mod handling_an_announce_request {} - - mod handling_an_scrape_request { - - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - - use crate::core::tests::the_tracker::{ - complete_peer, incomplete_peer, peer_ip, sample_info_hash, whitelisted_tracker, - }; - use crate::core::{PeersWanted, ScrapeData}; - - #[test] - fn it_should_be_able_to_build_a_zeroed_scrape_data_for_a_list_of_info_hashes() { - // Zeroed scrape data is used when the authentication for the scrape request fails. - - let sample_info_hash = sample_info_hash(); - - let mut expected_scrape_data = ScrapeData::empty(); - expected_scrape_data.add_file_with_zeroed_metadata(&sample_info_hash); - - assert_eq!(ScrapeData::zeroed(&vec![sample_info_hash]), expected_scrape_data); - } - - #[tokio::test] - async fn it_should_return_the_zeroed_swarm_metadata_for_the_requested_file_if_it_is_not_whitelisted() { - let tracker = whitelisted_tracker(); - - let info_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(); - - let mut peer = incomplete_peer(); - tracker.announce(&info_hash, &mut peer, &peer_ip(), &PeersWanted::All); - - // Announce twice to force non zeroed swarm metadata - let mut peer = complete_peer(); - tracker.announce(&info_hash, &mut peer, &peer_ip(), &PeersWanted::All); - - let scrape_data = tracker.scrape(&vec![info_hash]).await; - - // The expected zeroed swarm metadata for the file - let mut expected_scrape_data = ScrapeData::empty(); - expected_scrape_data.add_file(&info_hash, SwarmMetadata::zeroed()); - - assert_eq!(scrape_data, expected_scrape_data); - } - } - } - - mod configured_as_private { - - mod handling_authentication { - use std::str::FromStr; - use std::time::Duration; - - use crate::core::auth::{self}; - use crate::core::tests::the_tracker::private_tracker; - - #[tokio::test] - async fn it_should_fail_authenticating_a_peer_when_it_uses_an_unregistered_key() { - let tracker = private_tracker(); - - let unregistered_key = auth::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); - - let result = tracker.authenticate(&unregistered_key).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn it_should_fail_verifying_an_unregistered_authentication_key() { - let tracker = private_tracker(); - - let unregistered_key = auth::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); - - assert!(tracker.verify_auth_key(&unregistered_key).await.is_err()); - } - - #[tokio::test] - async fn it_should_remove_an_authentication_key() { - let tracker = private_tracker(); - - let expiring_key = tracker.generate_auth_key(Some(Duration::from_secs(100))).await.unwrap(); - - let result = tracker.remove_auth_key(&expiring_key.key()).await; - - assert!(result.is_ok()); - assert!(tracker.verify_auth_key(&expiring_key.key()).await.is_err()); - } - - #[tokio::test] - async fn it_should_load_authentication_keys_from_the_database() { - let tracker = private_tracker(); - - let expiring_key = tracker.generate_auth_key(Some(Duration::from_secs(100))).await.unwrap(); - - // Remove the newly generated key in memory - tracker.keys.write().await.remove(&expiring_key.key()); - - let result = tracker.load_keys_from_database().await; - - assert!(result.is_ok()); - assert!(tracker.verify_auth_key(&expiring_key.key()).await.is_ok()); - } - - mod with_expiring_and { - - mod randomly_generated_keys { - use std::time::Duration; - - use torrust_tracker_clock::clock::Time; - use torrust_tracker_configuration::v2_0_0::core::PrivateMode; - - use crate::core::auth::Key; - use crate::core::tests::the_tracker::private_tracker; - use crate::CurrentClock; - - #[tokio::test] - async fn it_should_generate_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker.generate_auth_key(Some(Duration::from_secs(100))).await.unwrap(); - - assert_eq!( - peer_key.valid_until, - Some(CurrentClock::now_add(&Duration::from_secs(100)).unwrap()) - ); - } - - #[tokio::test] - async fn it_should_authenticate_a_peer_with_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker.generate_auth_key(Some(Duration::from_secs(100))).await.unwrap(); - - let result = tracker.authenticate(&peer_key.key()).await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { - let mut tracker = private_tracker(); - - tracker.config.private_mode = Some(PrivateMode { - check_keys_expiration: false, - }); - - let past_timestamp = Duration::ZERO; - - let peer_key = tracker - .add_auth_key(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(), Some(past_timestamp)) - .await - .unwrap(); - - assert!(tracker.authenticate(&peer_key.key()).await.is_ok()); - } - } - - mod pre_generated_keys { - use std::time::Duration; - - use torrust_tracker_clock::clock::Time; - use torrust_tracker_configuration::v2_0_0::core::PrivateMode; - - use crate::core::auth::Key; - use crate::core::tests::the_tracker::private_tracker; - use crate::core::AddKeyRequest; - use crate::CurrentClock; - - #[tokio::test] - async fn it_should_add_a_pre_generated_key() { - let tracker = private_tracker(); - - let peer_key = tracker - .add_peer_key(AddKeyRequest { - opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), - opt_seconds_valid: Some(100), - }) - .await - .unwrap(); - - assert_eq!( - peer_key.valid_until, - Some(CurrentClock::now_add(&Duration::from_secs(100)).unwrap()) - ); - } - - #[tokio::test] - async fn it_should_authenticate_a_peer_with_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker - .add_peer_key(AddKeyRequest { - opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), - opt_seconds_valid: Some(100), - }) - .await - .unwrap(); - - let result = tracker.authenticate(&peer_key.key()).await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn it_should_accept_an_expired_key_when_checking_expiration_is_disabled_in_configuration() { - let mut tracker = private_tracker(); - - tracker.config.private_mode = Some(PrivateMode { - check_keys_expiration: false, - }); - - let peer_key = tracker - .add_peer_key(AddKeyRequest { - opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), - opt_seconds_valid: Some(0), - }) - .await - .unwrap(); - - assert!(tracker.authenticate(&peer_key.key()).await.is_ok()); - } - } - } - - mod with_permanent_and { - - mod randomly_generated_keys { - use crate::core::tests::the_tracker::private_tracker; - - #[tokio::test] - async fn it_should_generate_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker.generate_permanent_auth_key().await.unwrap(); - - assert_eq!(peer_key.valid_until, None); - } - - #[tokio::test] - async fn it_should_authenticate_a_peer_with_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker.generate_permanent_auth_key().await.unwrap(); - - let result = tracker.authenticate(&peer_key.key()).await; - - assert!(result.is_ok()); - } - } - - mod pre_generated_keys { - use crate::core::auth::Key; - use crate::core::tests::the_tracker::private_tracker; - use crate::core::AddKeyRequest; - - #[tokio::test] - async fn it_should_add_a_pre_generated_key() { - let tracker = private_tracker(); - - let peer_key = tracker - .add_peer_key(AddKeyRequest { - opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), - opt_seconds_valid: None, - }) - .await - .unwrap(); - - assert_eq!(peer_key.valid_until, None); - } - - #[tokio::test] - async fn it_should_authenticate_a_peer_with_the_key() { - let tracker = private_tracker(); - - let peer_key = tracker - .add_peer_key(AddKeyRequest { - opt_key: Some(Key::new("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap().to_string()), - opt_seconds_valid: None, - }) - .await - .unwrap(); - - let result = tracker.authenticate(&peer_key.key()).await; - - assert!(result.is_ok()); - } - } - } - } - - mod handling_an_announce_request {} - - mod handling_an_scrape_request {} - } - - mod configured_as_private_and_whitelisted { - - mod handling_an_announce_request {} - - mod handling_an_scrape_request {} - } - - mod handling_torrent_persistence { - - use aquatic_udp_protocol::AnnounceEvent; - use torrust_tracker_torrent_repository::entry::EntrySync; - use torrust_tracker_torrent_repository::repository::Repository; - - use crate::core::tests::the_tracker::{sample_info_hash, sample_peer, tracker_persisting_torrents_in_database}; - - #[tokio::test] - async fn it_should_persist_the_number_of_completed_peers_for_all_torrents_into_the_database() { - let tracker = tracker_persisting_torrents_in_database(); - - let info_hash = sample_info_hash(); - - let mut peer = sample_peer(); - - peer.event = AnnounceEvent::Started; - let swarm_stats = tracker.upsert_peer_and_get_stats(&info_hash, &peer); - assert_eq!(swarm_stats.downloaded, 0); - - peer.event = AnnounceEvent::Completed; - let swarm_stats = tracker.upsert_peer_and_get_stats(&info_hash, &peer); - assert_eq!(swarm_stats.downloaded, 1); - - // Remove the newly updated torrent from memory - tracker.torrents.remove(&info_hash); - - tracker.load_torrents_from_database().unwrap(); - - let torrent_entry = tracker.torrents.get(&info_hash).expect("it should be able to get entry"); - - // It persists the number of completed peers. - assert_eq!(torrent_entry.get_swarm_metadata().downloaded, 1); - - // It does not persist the peers - assert!(torrent_entry.peers_is_empty()); - } - } - } -} diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs deleted file mode 100644 index 166f40df4..000000000 --- a/src/core/services/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Tracker domain services. Core and statistics services. -//! -//! There are two types of service: -//! -//! - [Core tracker services](crate::core::services::torrent): related to the tracker main functionalities like getting info about torrents. -//! - [Services for statistics](crate::core::services::statistics): related to tracker metrics. Aggregate data about the tracker server. -pub mod statistics; -pub mod torrent; - -use std::sync::Arc; - -use torrust_tracker_configuration::Configuration; - -use crate::core::Tracker; - -/// It returns a new tracker building its dependencies. -/// -/// # Panics -/// -/// Will panic if tracker cannot be instantiated. -#[must_use] -pub fn tracker_factory(config: &Configuration) -> Tracker { - // Initialize statistics - let (stats_event_sender, stats_repository) = statistics::setup::factory(config.core.tracker_usage_statistics); - - // Initialize Torrust tracker - match Tracker::new(&Arc::new(config).core, stats_event_sender, stats_repository) { - Ok(tracker) => tracker, - Err(error) => { - panic!("{}", error) - } - } -} diff --git a/src/core/services/statistics/mod.rs b/src/core/services/statistics/mod.rs deleted file mode 100644 index ee1c0c4fa..000000000 --- a/src/core/services/statistics/mod.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Statistics services. -//! -//! It includes: -//! -//! - A [`factory`](crate::core::services::statistics::setup::factory) function to build the structs needed to collect the tracker metrics. -//! - A [`get_metrics`] service to get the [`tracker metrics`](crate::core::statistics::Metrics). -//! -//! Tracker metrics are collected using a Publisher-Subscribe pattern. -//! -//! The factory function builds two structs: -//! -//! - An statistics [`EventSender`](crate::core::statistics::EventSender) -//! - An statistics [`Repo`](crate::core::statistics::Repo) -//! -//! ```text -//! let (stats_event_sender, stats_repository) = factory(tracker_usage_statistics); -//! ``` -//! -//! The statistics repository is responsible for storing the metrics in memory. -//! The statistics event sender allows sending events related to metrics. -//! There is an event listener that is receiving all the events and processing them with an event handler. -//! Then, the event handler updates the metrics depending on the received event. -//! -//! For example, if you send the event [`Event::Udp4Connect`](crate::core::statistics::Event::Udp4Connect): -//! -//! ```text -//! let result = event_sender.send_event(Event::Udp4Connect).await; -//! ``` -//! -//! Eventually the counter for UDP connections from IPv4 peers will be increased. -//! -//! ```rust,no_run -//! pub struct Metrics { -//! // ... -//! pub udp4_connections_handled: u64, // This will be incremented -//! // ... -//! } -//! ``` -pub mod setup; - -use std::sync::Arc; - -use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; - -use crate::core::statistics::Metrics; -use crate::core::Tracker; - -/// All the metrics collected by the tracker. -#[derive(Debug, PartialEq)] -pub struct TrackerMetrics { - /// Domain level metrics. - /// - /// General metrics for all torrents (number of seeders, leechers, etcetera) - pub torrents_metrics: TorrentsMetrics, - - /// Application level metrics. Usage statistics/metrics. - /// - /// Metrics about how the tracker is been used (number of udp announce requests, number of http scrape requests, etcetera) - pub protocol_metrics: Metrics, -} - -/// It returns all the [`TrackerMetrics`] -pub async fn get_metrics(tracker: Arc) -> TrackerMetrics { - let torrents_metrics = tracker.get_torrents_metrics(); - let stats = tracker.get_stats().await; - - TrackerMetrics { - torrents_metrics, - protocol_metrics: Metrics { - tcp4_connections_handled: stats.tcp4_connections_handled, - tcp4_announces_handled: stats.tcp4_announces_handled, - tcp4_scrapes_handled: stats.tcp4_scrapes_handled, - tcp6_connections_handled: stats.tcp6_connections_handled, - tcp6_announces_handled: stats.tcp6_announces_handled, - tcp6_scrapes_handled: stats.tcp6_scrapes_handled, - udp4_connections_handled: stats.udp4_connections_handled, - udp4_announces_handled: stats.udp4_announces_handled, - udp4_scrapes_handled: stats.udp4_scrapes_handled, - udp6_connections_handled: stats.udp6_connections_handled, - udp6_announces_handled: stats.udp6_announces_handled, - udp6_scrapes_handled: stats.udp6_scrapes_handled, - }, - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use torrust_tracker_configuration::Configuration; - use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; - use torrust_tracker_test_helpers::configuration; - - use crate::core; - use crate::core::services::statistics::{get_metrics, TrackerMetrics}; - use crate::core::services::tracker_factory; - - pub fn tracker_configuration() -> Configuration { - configuration::ephemeral() - } - - #[tokio::test] - async fn the_statistics_service_should_return_the_tracker_metrics() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let tracker_metrics = get_metrics(tracker.clone()).await; - - assert_eq!( - tracker_metrics, - TrackerMetrics { - torrents_metrics: TorrentsMetrics::default(), - protocol_metrics: core::statistics::Metrics::default(), - } - ); - } -} diff --git a/src/core/services/statistics/setup.rs b/src/core/services/statistics/setup.rs deleted file mode 100644 index 37603852b..000000000 --- a/src/core/services/statistics/setup.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Setup for the tracker statistics. -//! -//! The [`factory`] function builds the structs needed for handling the tracker metrics. -use crate::core::statistics; - -/// It builds the structs needed for handling the tracker metrics. -/// -/// It returns: -/// -/// - An statistics [`EventSender`](crate::core::statistics::EventSender) that allows you to send events related to statistics. -/// - An statistics [`Repo`](crate::core::statistics::Repo) which is an in-memory repository for the tracker metrics. -/// -/// When the input argument `tracker_usage_statistics`is false the setup does not run the event listeners, consequently the statistics -/// events are sent are received but not dispatched to the handler. -#[must_use] -pub fn factory(tracker_usage_statistics: bool) -> (Option>, statistics::Repo) { - let mut stats_event_sender = None; - - let mut stats_tracker = statistics::Keeper::new(); - - if tracker_usage_statistics { - stats_event_sender = Some(stats_tracker.run_event_listener()); - } - - (stats_event_sender, stats_tracker.repository) -} - -#[cfg(test)] -mod test { - use super::factory; - - #[tokio::test] - async fn should_not_send_any_event_when_statistics_are_disabled() { - let tracker_usage_statistics = false; - - let (stats_event_sender, _stats_repository) = factory(tracker_usage_statistics); - - assert!(stats_event_sender.is_none()); - } - - #[tokio::test] - async fn should_send_events_when_statistics_are_enabled() { - let tracker_usage_statistics = true; - - let (stats_event_sender, _stats_repository) = factory(tracker_usage_statistics); - - assert!(stats_event_sender.is_some()); - } -} diff --git a/src/core/services/torrent.rs b/src/core/services/torrent.rs deleted file mode 100644 index 3b014982d..000000000 --- a/src/core/services/torrent.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Core tracker domain services. -//! -//! There are two services: -//! -//! - [`get_torrent_info`]: it returns all the data about one torrent. -//! - [`get_torrents`]: it returns data about some torrent in bulk excluding the peer list. -use std::sync::Arc; - -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::pagination::Pagination; -use torrust_tracker_primitives::peer; -use torrust_tracker_torrent_repository::entry::EntrySync; -use torrust_tracker_torrent_repository::repository::Repository; - -use crate::core::Tracker; - -/// It contains all the information the tracker has about a torrent -#[derive(Debug, PartialEq)] -pub struct Info { - /// The infohash of the torrent this data is related to - pub info_hash: InfoHash, - /// The total number of seeders for this torrent. Peer that actively serving a full copy of the torrent data - pub seeders: u64, - /// The total number of peers that have ever complete downloading this torrent - pub completed: u64, - /// The total number of leechers for this torrent. Peers that actively downloading this torrent - pub leechers: u64, - /// The swarm: the list of peers that are actively trying to download or serving this torrent - pub peers: Option>, -} - -/// It contains only part of the information the tracker has about a torrent -/// -/// It contains the same data as [Info] but without the list of peers in the swarm. -#[derive(Debug, PartialEq, Clone)] -pub struct BasicInfo { - /// The infohash of the torrent this data is related to - pub info_hash: InfoHash, - /// The total number of seeders for this torrent. Peer that actively serving a full copy of the torrent data - pub seeders: u64, - /// The total number of peers that have ever complete downloading this torrent - pub completed: u64, - /// The total number of leechers for this torrent. Peers that actively downloading this torrent - pub leechers: u64, -} - -/// It returns all the information the tracker has about one torrent in a [Info] struct. -pub async fn get_torrent_info(tracker: Arc, info_hash: &InfoHash) -> Option { - let torrent_entry_option = tracker.torrents.get(info_hash); - - let torrent_entry = torrent_entry_option?; - - let stats = torrent_entry.get_swarm_metadata(); - - let peers = torrent_entry.get_peers(None); - - let peers = Some(peers.iter().map(|peer| (**peer)).collect()); - - Some(Info { - info_hash: *info_hash, - seeders: u64::from(stats.complete), - completed: u64::from(stats.downloaded), - leechers: u64::from(stats.incomplete), - peers, - }) -} - -/// It returns all the information the tracker has about multiple torrents in a [`BasicInfo`] struct, excluding the peer list. -pub async fn get_torrents_page(tracker: Arc, pagination: Option<&Pagination>) -> Vec { - let mut basic_infos: Vec = vec![]; - - for (info_hash, torrent_entry) in tracker.torrents.get_paginated(pagination) { - let stats = torrent_entry.get_swarm_metadata(); - - basic_infos.push(BasicInfo { - info_hash, - seeders: u64::from(stats.complete), - completed: u64::from(stats.downloaded), - leechers: u64::from(stats.incomplete), - }); - } - - basic_infos -} - -/// It returns all the information the tracker has about multiple torrents in a [`BasicInfo`] struct, excluding the peer list. -pub async fn get_torrents(tracker: Arc, info_hashes: &[InfoHash]) -> Vec { - let mut basic_infos: Vec = vec![]; - - for info_hash in info_hashes { - if let Some(stats) = tracker.torrents.get(info_hash).map(|t| t.get_swarm_metadata()) { - basic_infos.push(BasicInfo { - info_hash: *info_hash, - seeders: u64::from(stats.complete), - completed: u64::from(stats.downloaded), - leechers: u64::from(stats.incomplete), - }); - } - } - - basic_infos -} - -#[cfg(test)] -mod tests { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - - use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; - use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; - - fn sample_peer() -> peer::Peer { - peer::Peer { - peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), - event: AnnounceEvent::Started, - } - } - - mod getting_a_torrent_info { - - use std::str::FromStr; - use std::sync::Arc; - - use torrust_tracker_configuration::Configuration; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::torrent::tests::sample_peer; - use crate::core::services::torrent::{get_torrent_info, Info}; - use crate::core::services::tracker_factory; - - pub fn tracker_configuration() -> Configuration { - configuration::ephemeral() - } - - #[tokio::test] - async fn should_return_none_if_the_tracker_does_not_have_the_torrent() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let torrent_info = get_torrent_info( - tracker.clone(), - &InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(), - ) - .await; - - assert!(torrent_info.is_none()); - } - - #[tokio::test] - async fn should_return_the_torrent_info_if_the_tracker_has_the_torrent() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - tracker.upsert_peer_and_get_stats(&info_hash, &sample_peer()); - - let torrent_info = get_torrent_info(tracker.clone(), &info_hash).await.unwrap(); - - assert_eq!( - torrent_info, - Info { - info_hash: InfoHash::from_str(&hash).unwrap(), - seeders: 1, - completed: 0, - leechers: 0, - peers: Some(vec![sample_peer()]), - } - ); - } - } - - mod searching_for_torrents { - - use std::str::FromStr; - use std::sync::Arc; - - use torrust_tracker_configuration::Configuration; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::torrent::tests::sample_peer; - use crate::core::services::torrent::{get_torrents_page, BasicInfo, Pagination}; - use crate::core::services::tracker_factory; - - pub fn tracker_configuration() -> Configuration { - configuration::ephemeral() - } - - #[tokio::test] - async fn should_return_an_empty_result_if_the_tracker_does_not_have_any_torrent() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let torrents = get_torrents_page(tracker.clone(), Some(&Pagination::default())).await; - - assert_eq!(torrents, vec![]); - } - - #[tokio::test] - async fn should_return_a_summarized_info_for_all_torrents() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - - tracker.upsert_peer_and_get_stats(&info_hash, &sample_peer()); - - let torrents = get_torrents_page(tracker.clone(), Some(&Pagination::default())).await; - - assert_eq!( - torrents, - vec![BasicInfo { - info_hash: InfoHash::from_str(&hash).unwrap(), - seeders: 1, - completed: 0, - leechers: 0, - }] - ); - } - - #[tokio::test] - async fn should_allow_limiting_the_number_of_torrents_in_the_result() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash1 = InfoHash::from_str(&hash1).unwrap(); - let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); - let info_hash2 = InfoHash::from_str(&hash2).unwrap(); - - tracker.upsert_peer_and_get_stats(&info_hash1, &sample_peer()); - tracker.upsert_peer_and_get_stats(&info_hash2, &sample_peer()); - - let offset = 0; - let limit = 1; - - let torrents = get_torrents_page(tracker.clone(), Some(&Pagination::new(offset, limit))).await; - - assert_eq!(torrents.len(), 1); - } - - #[tokio::test] - async fn should_allow_using_pagination_in_the_result() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash1 = InfoHash::from_str(&hash1).unwrap(); - let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); - let info_hash2 = InfoHash::from_str(&hash2).unwrap(); - - tracker.upsert_peer_and_get_stats(&info_hash1, &sample_peer()); - tracker.upsert_peer_and_get_stats(&info_hash2, &sample_peer()); - - let offset = 1; - let limit = 4000; - - let torrents = get_torrents_page(tracker.clone(), Some(&Pagination::new(offset, limit))).await; - - assert_eq!(torrents.len(), 1); - assert_eq!( - torrents, - vec![BasicInfo { - info_hash: InfoHash::from_str(&hash1).unwrap(), - seeders: 1, - completed: 0, - leechers: 0, - }] - ); - } - - #[tokio::test] - async fn should_return_torrents_ordered_by_info_hash() { - let tracker = Arc::new(tracker_factory(&tracker_configuration())); - - let hash1 = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash1 = InfoHash::from_str(&hash1).unwrap(); - tracker.upsert_peer_and_get_stats(&info_hash1, &sample_peer()); - - let hash2 = "03840548643af2a7b63a9f5cbca348bc7150ca3a".to_owned(); - let info_hash2 = InfoHash::from_str(&hash2).unwrap(); - tracker.upsert_peer_and_get_stats(&info_hash2, &sample_peer()); - - let torrents = get_torrents_page(tracker.clone(), Some(&Pagination::default())).await; - - assert_eq!( - torrents, - vec![ - BasicInfo { - info_hash: InfoHash::from_str(&hash2).unwrap(), - seeders: 1, - completed: 0, - leechers: 0, - }, - BasicInfo { - info_hash: InfoHash::from_str(&hash1).unwrap(), - seeders: 1, - completed: 0, - leechers: 0, - } - ] - ); - } - } -} diff --git a/src/core/statistics.rs b/src/core/statistics.rs deleted file mode 100644 index c9681d23c..000000000 --- a/src/core/statistics.rs +++ /dev/null @@ -1,488 +0,0 @@ -//! Structs to collect and keep tracker metrics. -//! -//! The tracker collects metrics such as: -//! -//! - Number of connections handled -//! - Number of `announce` requests handled -//! - Number of `scrape` request handled -//! -//! These metrics are collected for each connection type: UDP and HTTP and -//! also for each IP version used by the peers: IPv4 and IPv6. -//! -//! > Notice: that UDP tracker have an specific `connection` request. For the HTTP metrics the counter counts one connection for each `announce` or `scrape` request. -//! -//! The data is collected by using an `event-sender -> event listener` model. -//! -//! The tracker uses an [`statistics::EventSender`](crate::core::statistics::EventSender) instance to send an event. -//! The [`statistics::Keeper`](crate::core::statistics::Keeper) listens to new events and uses the [`statistics::Repo`](crate::core::statistics::Repo) to upgrade and store metrics. -//! -//! See the [`statistics::Event`](crate::core::statistics::Event) enum to check which events are available. -use std::sync::Arc; - -use futures::future::BoxFuture; -use futures::FutureExt; -#[cfg(test)] -use mockall::{automock, predicate::str}; -use tokio::sync::mpsc::error::SendError; -use tokio::sync::{mpsc, RwLock, RwLockReadGuard}; - -const CHANNEL_BUFFER_SIZE: usize = 65_535; - -/// An statistics event. It is used to collect tracker metrics. -/// -/// - `Tcp` prefix means the event was triggered by the HTTP tracker -/// - `Udp` prefix means the event was triggered by the UDP tracker -/// - `4` or `6` prefixes means the IP version used by the peer -/// - Finally the event suffix is the type of request: `announce`, `scrape` or `connection` -/// -/// > NOTE: HTTP trackers do not use `connection` requests. -#[derive(Debug, PartialEq, Eq)] -pub enum Event { - // code-review: consider one single event for request type with data: Event::Announce { scheme: HTTPorUDP, ip_version: V4orV6 } - // Attributes are enums too. - Tcp4Announce, - Tcp4Scrape, - Tcp6Announce, - Tcp6Scrape, - Udp4Connect, - Udp4Announce, - Udp4Scrape, - Udp6Connect, - Udp6Announce, - Udp6Scrape, -} - -/// Metrics collected by the tracker. -/// -/// - Number of connections handled -/// - Number of `announce` requests handled -/// - Number of `scrape` request handled -/// -/// These metrics are collected for each connection type: UDP and HTTP -/// and also for each IP version used by the peers: IPv4 and IPv6. -#[derive(Debug, PartialEq, Default)] -pub struct Metrics { - /// Total number of TCP (HTTP tracker) connections from IPv4 peers. - /// Since the HTTP tracker spec does not require a handshake, this metric - /// increases for every HTTP request. - pub tcp4_connections_handled: u64, - /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. - pub tcp4_announces_handled: u64, - /// Total number of TCP (HTTP tracker) `scrape` requests from IPv4 peers. - pub tcp4_scrapes_handled: u64, - /// Total number of TCP (HTTP tracker) connections from IPv6 peers. - pub tcp6_connections_handled: u64, - /// Total number of TCP (HTTP tracker) `announce` requests from IPv6 peers. - pub tcp6_announces_handled: u64, - /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. - pub tcp6_scrapes_handled: u64, - /// Total number of UDP (UDP tracker) connections from IPv4 peers. - pub udp4_connections_handled: u64, - /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. - pub udp4_announces_handled: u64, - /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. - pub udp4_scrapes_handled: u64, - /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. - pub udp6_connections_handled: u64, - /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. - pub udp6_announces_handled: u64, - /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. - pub udp6_scrapes_handled: u64, -} - -/// The service responsible for keeping tracker metrics (listening to statistics events and handle them). -/// -/// It actively listen to new statistics events. When it receives a new event -/// it accordingly increases the counters. -pub struct Keeper { - pub repository: Repo, -} - -impl Default for Keeper { - fn default() -> Self { - Self::new() - } -} - -impl Keeper { - #[must_use] - pub fn new() -> Self { - Self { repository: Repo::new() } - } - - #[must_use] - pub fn new_active_instance() -> (Box, Repo) { - let mut stats_tracker = Self::new(); - - let stats_event_sender = stats_tracker.run_event_listener(); - - (stats_event_sender, stats_tracker.repository) - } - - pub fn run_event_listener(&mut self) -> Box { - let (sender, receiver) = mpsc::channel::(CHANNEL_BUFFER_SIZE); - - let stats_repository = self.repository.clone(); - - tokio::spawn(async move { event_listener(receiver, stats_repository).await }); - - Box::new(Sender { sender }) - } -} - -async fn event_listener(mut receiver: mpsc::Receiver, stats_repository: Repo) { - while let Some(event) = receiver.recv().await { - event_handler(event, &stats_repository).await; - } -} - -async fn event_handler(event: Event, stats_repository: &Repo) { - match event { - // TCP4 - Event::Tcp4Announce => { - stats_repository.increase_tcp4_announces().await; - stats_repository.increase_tcp4_connections().await; - } - Event::Tcp4Scrape => { - stats_repository.increase_tcp4_scrapes().await; - stats_repository.increase_tcp4_connections().await; - } - - // TCP6 - Event::Tcp6Announce => { - stats_repository.increase_tcp6_announces().await; - stats_repository.increase_tcp6_connections().await; - } - Event::Tcp6Scrape => { - stats_repository.increase_tcp6_scrapes().await; - stats_repository.increase_tcp6_connections().await; - } - - // UDP4 - Event::Udp4Connect => { - stats_repository.increase_udp4_connections().await; - } - Event::Udp4Announce => { - stats_repository.increase_udp4_announces().await; - } - Event::Udp4Scrape => { - stats_repository.increase_udp4_scrapes().await; - } - - // UDP6 - Event::Udp6Connect => { - stats_repository.increase_udp6_connections().await; - } - Event::Udp6Announce => { - stats_repository.increase_udp6_announces().await; - } - Event::Udp6Scrape => { - stats_repository.increase_udp6_scrapes().await; - } - } - - tracing::debug!("stats: {:?}", stats_repository.get_stats().await); -} - -/// A trait to allow sending statistics events -#[cfg_attr(test, automock)] -pub trait EventSender: Sync + Send { - fn send_event(&self, event: Event) -> BoxFuture<'_, Option>>>; -} - -/// An [`statistics::EventSender`](crate::core::statistics::EventSender) implementation. -/// -/// It uses a channel sender to send the statistic events. The channel is created by a -/// [`statistics::Keeper`](crate::core::statistics::Keeper) -pub struct Sender { - sender: mpsc::Sender, -} - -impl EventSender for Sender { - fn send_event(&self, event: Event) -> BoxFuture<'_, Option>>> { - async move { Some(self.sender.send(event).await) }.boxed() - } -} - -/// A repository for the tracker metrics. -#[derive(Clone)] -pub struct Repo { - pub stats: Arc>, -} - -impl Default for Repo { - fn default() -> Self { - Self::new() - } -} - -impl Repo { - #[must_use] - pub fn new() -> Self { - Self { - stats: Arc::new(RwLock::new(Metrics::default())), - } - } - - pub async fn get_stats(&self) -> RwLockReadGuard<'_, Metrics> { - self.stats.read().await - } - - pub async fn increase_tcp4_announces(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp4_announces_handled += 1; - drop(stats_lock); - } - - pub async fn increase_tcp4_connections(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp4_connections_handled += 1; - drop(stats_lock); - } - - pub async fn increase_tcp4_scrapes(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp4_scrapes_handled += 1; - drop(stats_lock); - } - - pub async fn increase_tcp6_announces(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp6_announces_handled += 1; - drop(stats_lock); - } - - pub async fn increase_tcp6_connections(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp6_connections_handled += 1; - drop(stats_lock); - } - - pub async fn increase_tcp6_scrapes(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.tcp6_scrapes_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp4_connections(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp4_connections_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp4_announces(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp4_announces_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp4_scrapes(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp4_scrapes_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp6_connections(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp6_connections_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp6_announces(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp6_announces_handled += 1; - drop(stats_lock); - } - - pub async fn increase_udp6_scrapes(&self) { - let mut stats_lock = self.stats.write().await; - stats_lock.udp6_scrapes_handled += 1; - drop(stats_lock); - } -} - -#[cfg(test)] -mod tests { - - mod stats_tracker { - use crate::core::statistics::{Event, Keeper, Metrics}; - - #[tokio::test] - async fn should_contain_the_tracker_statistics() { - let stats_tracker = Keeper::new(); - - let stats = stats_tracker.repository.get_stats().await; - - assert_eq!(stats.tcp4_announces_handled, Metrics::default().tcp4_announces_handled); - } - - #[tokio::test] - async fn should_create_an_event_sender_to_send_statistical_events() { - let mut stats_tracker = Keeper::new(); - - let event_sender = stats_tracker.run_event_listener(); - - let result = event_sender.send_event(Event::Udp4Connect).await; - - assert!(result.is_some()); - } - } - - mod event_handler { - use crate::core::statistics::{event_handler, Event, Repo}; - - #[tokio::test] - async fn should_increase_the_tcp4_announces_counter_when_it_receives_a_tcp4_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp4Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp4_announces_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp4_connections_counter_when_it_receives_a_tcp4_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp4Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp4_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp4_scrapes_counter_when_it_receives_a_tcp4_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp4Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp4_scrapes_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp4_connections_counter_when_it_receives_a_tcp4_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp4Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp4_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp6_announces_counter_when_it_receives_a_tcp6_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp6Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp6_announces_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp6_connections_counter_when_it_receives_a_tcp6_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp6Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp6_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp6_scrapes_counter_when_it_receives_a_tcp6_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp6Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp6_scrapes_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_tcp6_connections_counter_when_it_receives_a_tcp6_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Tcp6Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.tcp6_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp4_connections_counter_when_it_receives_a_udp4_connect_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp4Connect, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp4_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp4_announces_counter_when_it_receives_a_udp4_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp4Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp4_announces_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp4_scrapes_counter_when_it_receives_a_udp4_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp4Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp4_scrapes_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp6_connections_counter_when_it_receives_a_udp6_connect_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp6Connect, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp6_connections_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp6_announces_counter_when_it_receives_a_udp6_announce_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp6Announce, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp6_announces_handled, 1); - } - - #[tokio::test] - async fn should_increase_the_udp6_scrapes_counter_when_it_receives_a_udp6_scrape_event() { - let stats_repository = Repo::new(); - - event_handler(Event::Udp6Scrape, &stats_repository).await; - - let stats = stats_repository.get_stats().await; - - assert_eq!(stats.udp6_scrapes_handled, 1); - } - } -} diff --git a/src/core/torrent/mod.rs b/src/core/torrent/mod.rs deleted file mode 100644 index 38311864b..000000000 --- a/src/core/torrent/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Structs to store the swarm data. -//! -//! There are to main data structures: -//! -//! - A torrent [`Entry`](torrust_tracker_torrent_repository::entry::Entry): it contains all the information stored by the tracker for one torrent. -//! - The [`SwarmMetadata`](torrust_tracker_primitives::swarm_metadata::SwarmMetadata): it contains aggregate information that can me derived from the torrent entries. -//! -//! A "swarm" is a network of peers that are trying to download the same torrent. -//! -//! The torrent entry contains the "swarm" data, which is basically the list of peers in the swarm. -//! That's the most valuable information the peer want to get from the tracker, because it allows them to -//! start downloading torrent from those peers. -//! -//! The "swarm metadata" contains aggregate data derived from the torrent entries. There two types of data: -//! -//! - For **active peers**: metrics related to the current active peers in the swarm. -//! - **Historical data**: since the tracker started running. -//! -//! The tracker collects metrics for: -//! -//! - The number of peers that have completed downloading the torrent since the tracker started collecting metrics. -//! - The number of peers that have completed downloading the torrent and are still active, that means they are actively participating in the network, -//! by announcing themselves periodically to the tracker. Since they have completed downloading they have a full copy of the torrent data. Peers with a -//! full copy of the data are called "seeders". -//! - The number of peers that have NOT completed downloading the torrent and are still active, that means they are actively participating in the network. -//! Peer that don not have a full copy of the torrent data are called "leechers". -//! -use torrust_tracker_torrent_repository::TorrentsSkipMapMutexStd; - -pub type Torrents = TorrentsSkipMapMutexStd; // Currently Used diff --git a/src/lib.rs b/src/lib.rs index d242ac80e..b26960899 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,7 @@ //! - [API](#api) //! - [HTTP Tracker](#http-tracker) //! - [UDP Tracker](#udp-tracker) -//! - [Components](#components) +//! - [Packages](#packages) //! - [Implemented BEPs](#implemented-beps) //! - [Contributing](#contributing) //! - [Documentation](#documentation) @@ -55,9 +55,9 @@ //! //! From the end-user perspective the Torrust Tracker exposes three different services. //! -//! - A REST [`API`](crate::servers::apis) -//! - One or more [`UDP`](crate::servers::udp) trackers -//! - One or more [`HTTP`](crate::servers::http) trackers +//! - A REST [`API`](torrust_axum_rest_tracker_api_server) +//! - One or more [`UDP`](torrust_udp_tracker_server) trackers +//! - One or more [`HTTP`](torrust_axum_http_tracker_server) trackers //! //! # Installation //! @@ -124,7 +124,7 @@ //! By default the tracker uses `SQLite` and the database file name `sqlite3.db`. //! //! You only need the `tls` directory in case you are setting up SSL for the HTTP tracker or the tracker API. -//! Visit [`HTTP`](crate::servers::http) or [`API`](crate::servers::apis) if you want to know how you can use HTTPS. +//! Visit [`HTTP`](torrust_axum_http_tracker_server) or [`API`](torrust_axum_rest_tracker_api_server) if you want to know how you can use HTTPS. //! //! ## Install from sources //! @@ -138,7 +138,6 @@ //! ```text //! git clone https://github.com/torrust/torrust-tracker.git \ //! && cd torrust-tracker \ -//! && cargo build --release \ //! && mkdir -p ./storage/tracker/etc \ //! && mkdir -p ./storage/tracker/lib/database \ //! && mkdir -p ./storage/tracker/lib/tls \ @@ -149,13 +148,13 @@ //! compile and after being compiled it will start running the tracker. //! //! ```text -//! cargo run +//! cargo run --release //! ``` //! //! ## Run with docker //! //! You can run the tracker with a pre-built docker image. Please refer to the -//! [tracker docker documentation](https://github.com/torrust/torrust-tracker/tree/develop/docker). +//! [tracker docker documentation](https://github.com/torrust/torrust-tracker/blob/develop/docs/containers.md). //! //! # Configuration //! @@ -214,7 +213,7 @@ //! of the `tracker.toml` file. //! //! The env var contains the same data as the `tracker.toml`. It's particularly -//! useful in you are [running the tracker with docker](https://github.com/torrust/torrust-tracker/tree/develop/docker). +//! useful in you are [running the tracker with docker](https://github.com/torrust/torrust-tracker/blob/develop/docs/containers.md). //! //! > NOTICE: The `TORRUST_TRACKER_CONFIG_TOML` env var has priority over the `tracker.toml` file. //! @@ -280,7 +279,7 @@ //! } //! ``` //! -//! Refer to the [`API`](crate::servers::apis) documentation for more information about the [`API`](crate::servers::apis) endpoints. +//! Refer to the [`API`](torrust_axum_rest_tracker_api_server) documentation for more information about the [`API`](torrust_axum_rest_tracker_api_server) endpoints. //! //! ## HTTP tracker //! @@ -301,7 +300,7 @@ //! bind_address = "0.0.0.0:7070" //! ``` //! -//! Refer to the [`HTTP`](crate::servers::http) documentation for more information about the [`HTTP`](crate::servers::http) tracker. +//! Refer to the [`HTTP`](torrust_axum_http_tracker_server) documentation for more information about the [`HTTP`](torrust_axum_http_tracker_server) tracker. //! //! ### Announce //! @@ -359,7 +358,7 @@ //! //! If the tracker is running in `private` or `private_listed` mode you will need to provide a valid authentication key. //! -//! Right now the only way to add new keys is via the REST [`API`](crate::servers::apis). The endpoint `POST /api/vi/key/:duration_in_seconds` +//! Right now the only way to add new keys is via the REST [`API`](torrust_axum_rest_tracker_api_server). The endpoint `POST /api/vi/key/:duration_in_seconds` //! will return an expiring key that will be valid for `duration_in_seconds` seconds. //! //! Using `curl` you can create a 2-minute valid auth key: @@ -379,7 +378,7 @@ //! ``` //! //! You can also use the Torrust Tracker together with the [Torrust Index](https://github.com/torrust/torrust-index). If that's the case, -//! the Index will create the keys by using the tracker [API](crate::servers::apis). +//! the Index will create the keys by using the tracker [API](torrust_axum_rest_tracker_api_server). //! //! ## UDP tracker //! @@ -395,22 +394,15 @@ //! bind_address = "0.0.0.0:6969" //! ``` //! -//! Refer to the [`UDP`](crate::servers::udp) documentation for more information about the [`UDP`](crate::servers::udp) tracker. +//! Refer to the [`UDP`](torrust_udp_tracker_server) documentation for more information about the [`UDP`](torrust_udp_tracker_server) tracker. //! //! If you want to know more about the UDP tracker protocol: //! //! - [BEP 15. UDP Tracker Protocol for `BitTorrent`](https://www.bittorrent.org/beps/bep_0015.html) //! -//! # Components +//! # Packages //! -//! Torrust Tracker has four main components: -//! -//! - The core tracker [`core`] -//! - The tracker REST [`API`](crate::servers::apis) -//! - The [`UDP`](crate::servers::udp) tracker -//! - The [`HTTP`](crate::servers::http) tracker -//! -//! ![Torrust Tracker Components](https://raw.githubusercontent.com/torrust/torrust-tracker/main/docs/media/torrust-tracker-components.png) +//! ![Torrust Tracker Layers with Main Packages](https://raw.githubusercontent.com/torrust/torrust-tracker/main/docs/media/packages/torrust-tracker-layers-with-packages.png) //! //! ## Core tracker //! @@ -434,7 +426,7 @@ //! - Torrents: to get peers for a torrent //! - Whitelist: to handle the torrent whitelist when the tracker runs on `listed` or `private_listed` mode //! -//! See [`API`](crate::servers::apis) for more details on the REST API. +//! See [`API`](torrust_axum_rest_tracker_api_server) for more details on the REST API. //! //! ## UDP tracker //! @@ -446,13 +438,13 @@ //! - [Wikipedia: UDP tracker](https://en.wikipedia.org/wiki/UDP_tracker) //! - [BEP 15: UDP Tracker Protocol for `BitTorrent`](https://www.bittorrent.org/beps/bep_0015.html) //! -//! See [`UDP`](crate::servers::udp) for more details on the UDP tracker. +//! See [`UDP`](torrust_udp_tracker_server) for more details on the UDP tracker. //! //! ## HTTP tracker //! //! HTTP tracker was the original tracker specification defined on the [BEP 3]((https://www.bittorrent.org/beps/bep_0003.html)). //! -//! See [`HTTP`](crate::servers::http) for more details on the HTTP tracker. +//! See [`HTTP`](torrust_axum_http_tracker_server) for more details on the HTTP tracker. //! //! You can find more information about UDP tracker on: //! @@ -488,17 +480,12 @@ //! In addition to the production code documentation you can find a lot of //! examples on the integration and unit tests. -use torrust_tracker_clock::{clock, time_extent}; +use torrust_tracker_clock::clock; pub mod app; pub mod bootstrap; pub mod console; -pub mod core; -pub mod servers; -pub mod shared; - -#[macro_use] -extern crate lazy_static; +pub mod container; /// This code needs to be copied into each crate. /// Working version, for production. @@ -510,13 +497,3 @@ pub(crate) type CurrentClock = clock::Working; #[cfg(test)] #[allow(dead_code)] pub(crate) type CurrentClock = clock::Stopped; - -/// Working version, for production. -#[cfg(not(test))] -#[allow(dead_code)] -pub(crate) type DefaultTimeExtentMaker = time_extent::WorkingTimeExtentMaker; - -/// Stopped version, for testing. -#[cfg(test)] -#[allow(dead_code)] -pub(crate) type DefaultTimeExtentMaker = time_extent::StoppedTimeExtentMaker; diff --git a/src/main.rs b/src/main.rs index e0b7bc4ab..7012ecaa7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,20 @@ -use torrust_tracker::{app, bootstrap}; +use std::time::Duration; + +use torrust_tracker_lib::app; #[tokio::main] async fn main() { - let (config, tracker) = bootstrap::app::setup(); - - let jobs = app::start(&config, tracker).await; + let (_app_container, jobs) = app::run().await; - // handle the signals tokio::select! { _ = tokio::signal::ctrl_c() => { - tracing::info!("Torrust shutting down ..."); + tracing::info!("Torrust tracker shutting down ..."); + + jobs.cancel(); + + jobs.wait_for_all(Duration::from_secs(10)).await; - // Await for all jobs to shutdown - futures::future::join_all(jobs).await; - tracing::info!("Torrust successfully shutdown."); + tracing::info!("Torrust tracker successfully shutdown."); } } } diff --git a/src/servers/apis/v1/context/stats/handlers.rs b/src/servers/apis/v1/context/stats/handlers.rs deleted file mode 100644 index c3be5dc7a..000000000 --- a/src/servers/apis/v1/context/stats/handlers.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! API handlers for the [`stats`](crate::servers::apis::v1::context::stats) -//! API context. -use std::sync::Arc; - -use axum::extract::State; -use axum::response::Json; - -use super::resources::Stats; -use super::responses::stats_response; -use crate::core::services::statistics::get_metrics; -use crate::core::Tracker; - -/// It handles the request to get the tracker statistics. -/// -/// It returns a `200` response with a json [`Stats`] -/// -/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::stats#get-tracker-statistics) -/// for more information about this endpoint. -pub async fn get_stats_handler(State(tracker): State>) -> Json { - stats_response(get_metrics(tracker.clone()).await) -} diff --git a/src/servers/apis/v1/context/stats/resources.rs b/src/servers/apis/v1/context/stats/resources.rs deleted file mode 100644 index 9e8ab6bab..000000000 --- a/src/servers/apis/v1/context/stats/resources.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! API resources for the [`stats`](crate::servers::apis::v1::context::stats) -//! API context. -use serde::{Deserialize, Serialize}; - -use crate::core::services::statistics::TrackerMetrics; - -/// It contains all the statistics generated by the tracker. -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct Stats { - // Torrent metrics - /// Total number of torrents. - pub torrents: u64, - /// Total number of seeders for all torrents. - pub seeders: u64, - /// Total number of peers that have ever completed downloading for all torrents. - pub completed: u64, - /// Total number of leechers for all torrents. - pub leechers: u64, - - // Protocol metrics - /// Total number of TCP (HTTP tracker) connections from IPv4 peers. - /// Since the HTTP tracker spec does not require a handshake, this metric - /// increases for every HTTP request. - pub tcp4_connections_handled: u64, - /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. - pub tcp4_announces_handled: u64, - /// Total number of TCP (HTTP tracker) `scrape` requests from IPv4 peers. - pub tcp4_scrapes_handled: u64, - /// Total number of TCP (HTTP tracker) connections from IPv6 peers. - pub tcp6_connections_handled: u64, - /// Total number of TCP (HTTP tracker) `announce` requests from IPv6 peers. - pub tcp6_announces_handled: u64, - /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. - pub tcp6_scrapes_handled: u64, - /// Total number of UDP (UDP tracker) connections from IPv4 peers. - pub udp4_connections_handled: u64, - /// Total number of UDP (UDP tracker) `announce` requests from IPv4 peers. - pub udp4_announces_handled: u64, - /// Total number of UDP (UDP tracker) `scrape` requests from IPv4 peers. - pub udp4_scrapes_handled: u64, - /// Total number of UDP (UDP tracker) `connection` requests from IPv6 peers. - pub udp6_connections_handled: u64, - /// Total number of UDP (UDP tracker) `announce` requests from IPv6 peers. - pub udp6_announces_handled: u64, - /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. - pub udp6_scrapes_handled: u64, -} - -impl From for Stats { - fn from(metrics: TrackerMetrics) -> Self { - Self { - torrents: metrics.torrents_metrics.torrents, - seeders: metrics.torrents_metrics.complete, - completed: metrics.torrents_metrics.downloaded, - leechers: metrics.torrents_metrics.incomplete, - tcp4_connections_handled: metrics.protocol_metrics.tcp4_connections_handled, - tcp4_announces_handled: metrics.protocol_metrics.tcp4_announces_handled, - tcp4_scrapes_handled: metrics.protocol_metrics.tcp4_scrapes_handled, - tcp6_connections_handled: metrics.protocol_metrics.tcp6_connections_handled, - tcp6_announces_handled: metrics.protocol_metrics.tcp6_announces_handled, - tcp6_scrapes_handled: metrics.protocol_metrics.tcp6_scrapes_handled, - udp4_connections_handled: metrics.protocol_metrics.udp4_connections_handled, - udp4_announces_handled: metrics.protocol_metrics.udp4_announces_handled, - udp4_scrapes_handled: metrics.protocol_metrics.udp4_scrapes_handled, - udp6_connections_handled: metrics.protocol_metrics.udp6_connections_handled, - udp6_announces_handled: metrics.protocol_metrics.udp6_announces_handled, - udp6_scrapes_handled: metrics.protocol_metrics.udp6_scrapes_handled, - } - } -} - -#[cfg(test)] -mod tests { - use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics; - - use super::Stats; - use crate::core::services::statistics::TrackerMetrics; - use crate::core::statistics::Metrics; - - #[test] - fn stats_resource_should_be_converted_from_tracker_metrics() { - assert_eq!( - Stats::from(TrackerMetrics { - torrents_metrics: TorrentsMetrics { - complete: 1, - downloaded: 2, - incomplete: 3, - torrents: 4 - }, - protocol_metrics: Metrics { - tcp4_connections_handled: 5, - tcp4_announces_handled: 6, - tcp4_scrapes_handled: 7, - tcp6_connections_handled: 8, - tcp6_announces_handled: 9, - tcp6_scrapes_handled: 10, - udp4_connections_handled: 11, - udp4_announces_handled: 12, - udp4_scrapes_handled: 13, - udp6_connections_handled: 14, - udp6_announces_handled: 15, - udp6_scrapes_handled: 16 - } - }), - Stats { - torrents: 4, - seeders: 1, - completed: 2, - leechers: 3, - tcp4_connections_handled: 5, - tcp4_announces_handled: 6, - tcp4_scrapes_handled: 7, - tcp6_connections_handled: 8, - tcp6_announces_handled: 9, - tcp6_scrapes_handled: 10, - udp4_connections_handled: 11, - udp4_announces_handled: 12, - udp4_scrapes_handled: 13, - udp6_connections_handled: 14, - udp6_announces_handled: 15, - udp6_scrapes_handled: 16 - } - ); - } -} diff --git a/src/servers/apis/v1/context/stats/responses.rs b/src/servers/apis/v1/context/stats/responses.rs deleted file mode 100644 index 9d03ccedf..000000000 --- a/src/servers/apis/v1/context/stats/responses.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! API responses for the [`stats`](crate::servers::apis::v1::context::stats) -//! API context. -use axum::response::Json; - -use super::resources::Stats; -use crate::core::services::statistics::TrackerMetrics; - -/// `200` response that contains the [`Stats`] resource as json. -pub fn stats_response(tracker_metrics: TrackerMetrics) -> Json { - Json(Stats::from(tracker_metrics)) -} diff --git a/src/servers/apis/v1/context/stats/routes.rs b/src/servers/apis/v1/context/stats/routes.rs deleted file mode 100644 index d8d552697..000000000 --- a/src/servers/apis/v1/context/stats/routes.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! API routes for the [`stats`](crate::servers::apis::v1::context::stats) API context. -//! -//! - `GET /stats` -//! -//! Refer to the [API endpoint documentation](crate::servers::apis::v1::context::stats). -use std::sync::Arc; - -use axum::routing::get; -use axum::Router; - -use super::handlers::get_stats_handler; -use crate::core::Tracker; - -/// It adds the routes to the router for the [`stats`](crate::servers::apis::v1::context::stats) API context. -pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { - router.route(&format!("{prefix}/stats"), get(get_stats_handler).with_state(tracker)) -} diff --git a/src/servers/apis/v1/context/torrent/resources/mod.rs b/src/servers/apis/v1/context/torrent/resources/mod.rs deleted file mode 100644 index a6dbff726..000000000 --- a/src/servers/apis/v1/context/torrent/resources/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! API resources for the [`torrent`](crate::servers::apis::v1::context::torrent) -//! API context. -pub mod peer; -pub mod torrent; diff --git a/src/servers/apis/v1/context/torrent/routes.rs b/src/servers/apis/v1/context/torrent/routes.rs deleted file mode 100644 index 6f8c28df5..000000000 --- a/src/servers/apis/v1/context/torrent/routes.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! API routes for the [`torrent`](crate::servers::apis::v1::context::torrent) API context. -//! -//! - `GET /torrent/:info_hash` -//! - `GET /torrents` -//! -//! Refer to the [API endpoint documentation](crate::servers::apis::v1::context::torrent). -use std::sync::Arc; - -use axum::routing::get; -use axum::Router; - -use super::handlers::{get_torrent_handler, get_torrents_handler}; -use crate::core::Tracker; - -/// It adds the routes to the router for the [`torrent`](crate::servers::apis::v1::context::torrent) API context. -pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { - // Torrents - router - .route( - &format!("{prefix}/torrent/:info_hash"), - get(get_torrent_handler).with_state(tracker.clone()), - ) - .route(&format!("{prefix}/torrents"), get(get_torrents_handler).with_state(tracker)) -} diff --git a/src/servers/apis/v1/context/whitelist/routes.rs b/src/servers/apis/v1/context/whitelist/routes.rs deleted file mode 100644 index e4e85181f..000000000 --- a/src/servers/apis/v1/context/whitelist/routes.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! API routes for the [`whitelist`](crate::servers::apis::v1::context::whitelist) API context. -//! -//! - `POST /whitelist/:info_hash` -//! - `DELETE /whitelist/:info_hash` -//! - `GET /whitelist/reload` -//! -//! Refer to the [API endpoint documentation](crate::servers::apis::v1::context::torrent). -use std::sync::Arc; - -use axum::routing::{delete, get, post}; -use axum::Router; - -use super::handlers::{add_torrent_to_whitelist_handler, reload_whitelist_handler, remove_torrent_from_whitelist_handler}; -use crate::core::Tracker; - -/// It adds the routes to the router for the [`whitelist`](crate::servers::apis::v1::context::whitelist) API context. -pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { - let prefix = format!("{prefix}/whitelist"); - - router - // Whitelisted torrents - .route( - &format!("{prefix}/:info_hash"), - post(add_torrent_to_whitelist_handler).with_state(tracker.clone()), - ) - .route( - &format!("{prefix}/:info_hash"), - delete(remove_torrent_from_whitelist_handler).with_state(tracker.clone()), - ) - // Whitelist commands - .route(&format!("{prefix}/reload"), get(reload_whitelist_handler).with_state(tracker)) -} diff --git a/src/servers/apis/v1/middlewares/auth.rs b/src/servers/apis/v1/middlewares/auth.rs deleted file mode 100644 index 58219c7ca..000000000 --- a/src/servers/apis/v1/middlewares/auth.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Authentication middleware for the API. -//! -//! It uses a "token" GET param to authenticate the user. URLs must be of the -//! form: -//! -//! `http://:/api/v1/?token=`. -//! -//! > **NOTICE**: the token can be at any position in the URL, not just at the -//! > beginning or at the end. -//! -//! The token must be one of the `access_tokens` in the tracker -//! [HTTP API configuration](torrust_tracker_configuration::HttpApi). -//! -//! The configuration file `tracker.toml` contains a list of tokens: -//! -//! ```toml -//! [http_api.access_tokens] -//! admin = "MyAccessToken" -//! ``` -//! -//! All the tokes have the same permissions, so it is not possible to have -//! different permissions for different tokens. The label is only used to -//! identify the token. -use std::sync::Arc; - -use axum::extract::{self}; -use axum::http::Request; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use serde::Deserialize; -use torrust_tracker_configuration::AccessTokens; - -use crate::servers::apis::v1::responses::unhandled_rejection_response; - -/// Container for the `token` extracted from the query params. -#[derive(Deserialize, Debug)] -pub struct QueryParams { - pub token: Option, -} - -#[derive(Clone, Debug)] -pub struct State { - pub access_tokens: Arc, -} - -/// Middleware for authentication using a "token" GET param. -/// The token must be one of the tokens in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi). -pub async fn auth( - extract::State(state): extract::State, - extract::Query(params): extract::Query, - request: Request, - next: Next, -) -> Response { - let Some(token) = params.token else { - return AuthError::Unauthorized.into_response(); - }; - - if !authenticate(&token, &state.access_tokens) { - return AuthError::TokenNotValid.into_response(); - } - - next.run(request).await -} - -enum AuthError { - /// Missing token for authentication. - Unauthorized, - /// Token was provided but it is not valid. - TokenNotValid, -} - -impl IntoResponse for AuthError { - fn into_response(self) -> Response { - match self { - AuthError::Unauthorized => unauthorized_response(), - AuthError::TokenNotValid => token_not_valid_response(), - } - } -} - -fn authenticate(token: &str, tokens: &AccessTokens) -> bool { - tokens.values().any(|t| t == token) -} - -/// `500` error response returned when the token is missing. -#[must_use] -pub fn unauthorized_response() -> Response { - unhandled_rejection_response("unauthorized".to_string()) -} - -/// `500` error response when the provided token is not valid. -#[must_use] -pub fn token_not_valid_response() -> Response { - unhandled_rejection_response("token not valid".to_string()) -} diff --git a/src/servers/apis/v1/routes.rs b/src/servers/apis/v1/routes.rs deleted file mode 100644 index 3786b3532..000000000 --- a/src/servers/apis/v1/routes.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Route initialization for the v1 API. -use std::sync::Arc; - -use axum::Router; - -use super::context::{auth_key, stats, torrent, whitelist}; -use crate::core::Tracker; - -/// Add the routes for the v1 API. -pub fn add(prefix: &str, router: Router, tracker: Arc) -> Router { - let v1_prefix = format!("{prefix}/v1"); - - let router = auth_key::routes::add(&v1_prefix, router, tracker.clone()); - let router = stats::routes::add(&v1_prefix, router, tracker.clone()); - let router = whitelist::routes::add(&v1_prefix, router, tracker.clone()); - - torrent::routes::add(&v1_prefix, router, tracker) -} diff --git a/src/servers/http/v1/handlers/announce.rs b/src/servers/http/v1/handlers/announce.rs deleted file mode 100644 index 1c7796fca..000000000 --- a/src/servers/http/v1/handlers/announce.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! Axum [`handlers`](axum#handlers) for the `announce` requests. -//! -//! Refer to [HTTP server](crate::servers::http) for more information about the -//! `announce` request. -//! -//! The handlers perform the authentication and authorization of the request, -//! and resolve the client IP address. -use std::net::{IpAddr, SocketAddr}; -use std::panic::Location; -use std::sync::Arc; - -use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; -use axum::extract::State; -use axum::response::{IntoResponse, Response}; -use torrust_tracker_clock::clock::Time; -use torrust_tracker_primitives::peer; - -use crate::core::auth::Key; -use crate::core::{AnnounceData, PeersWanted, Tracker}; -use crate::servers::http::v1::extractors::announce_request::ExtractRequest; -use crate::servers::http::v1::extractors::authentication_key::Extract as ExtractKey; -use crate::servers::http::v1::extractors::client_ip_sources::Extract as ExtractClientIpSources; -use crate::servers::http::v1::handlers::common::auth; -use crate::servers::http::v1::requests::announce::{Announce, Compact, Event}; -use crate::servers::http::v1::responses::{self}; -use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; -use crate::servers::http::v1::services::{self, peer_ip_resolver}; -use crate::CurrentClock; - -/// It handles the `announce` request when the HTTP tracker does not require -/// authentication (no PATH `key` parameter required). -#[allow(clippy::unused_async)] -pub async fn handle_without_key( - State(tracker): State>, - ExtractRequest(announce_request): ExtractRequest, - ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, -) -> Response { - tracing::debug!("http announce request: {:#?}", announce_request); - - handle(&tracker, &announce_request, &client_ip_sources, None).await -} - -/// It handles the `announce` request when the HTTP tracker requires -/// authentication (PATH `key` parameter required). -#[allow(clippy::unused_async)] -pub async fn handle_with_key( - State(tracker): State>, - ExtractRequest(announce_request): ExtractRequest, - ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, - ExtractKey(key): ExtractKey, -) -> Response { - tracing::debug!("http announce request: {:#?}", announce_request); - - handle(&tracker, &announce_request, &client_ip_sources, Some(key)).await -} - -/// It handles the `announce` request. -/// -/// Internal implementation that handles both the `authenticated` and -/// `unauthenticated` modes. -async fn handle( - tracker: &Arc, - announce_request: &Announce, - client_ip_sources: &ClientIpSources, - maybe_key: Option, -) -> Response { - let announce_data = match handle_announce(tracker, announce_request, client_ip_sources, maybe_key).await { - Ok(announce_data) => announce_data, - Err(error) => return error.into_response(), - }; - build_response(announce_request, announce_data) -} - -/* code-review: authentication, authorization and peer IP resolution could be moved - from the handler (Axum) layer into the app layer `services::announce::invoke`. - That would make the handler even simpler and the code more reusable and decoupled from Axum. - See https://github.com/torrust/torrust-tracker/discussions/240. -*/ - -async fn handle_announce( - tracker: &Arc, - announce_request: &Announce, - client_ip_sources: &ClientIpSources, - maybe_key: Option, -) -> Result { - // Authentication - if tracker.requires_authentication() { - match maybe_key { - Some(key) => match tracker.authenticate(&key).await { - Ok(()) => (), - Err(error) => return Err(responses::error::Error::from(error)), - }, - None => { - return Err(responses::error::Error::from(auth::Error::MissingAuthKey { - location: Location::caller(), - })) - } - } - } - - // Authorization - match tracker.authorize(&announce_request.info_hash).await { - Ok(()) => (), - Err(error) => return Err(responses::error::Error::from(error)), - } - - let peer_ip = match peer_ip_resolver::invoke(tracker.is_behind_reverse_proxy(), client_ip_sources) { - Ok(peer_ip) => peer_ip, - Err(error) => return Err(responses::error::Error::from(error)), - }; - - let mut peer = peer_from_request(announce_request, &peer_ip); - let peers_wanted = match announce_request.numwant { - Some(numwant) => PeersWanted::only(numwant), - None => PeersWanted::All, - }; - - let announce_data = services::announce::invoke(tracker.clone(), announce_request.info_hash, &mut peer, &peers_wanted).await; - - Ok(announce_data) -} - -fn build_response(announce_request: &Announce, announce_data: AnnounceData) -> Response { - if announce_request.compact.as_ref().is_some_and(|f| *f == Compact::Accepted) { - let response: responses::Announce = announce_data.into(); - response.into_response() - } else { - let response: responses::Announce = announce_data.into(); - response.into_response() - } -} - -/// It builds a `Peer` from the announce request. -/// -/// It ignores the peer address in the announce request params. -#[must_use] -fn peer_from_request(announce_request: &Announce, peer_ip: &IpAddr) -> peer::Peer { - peer::Peer { - peer_id: announce_request.peer_id, - peer_addr: SocketAddr::new(*peer_ip, announce_request.port), - updated: CurrentClock::now(), - uploaded: announce_request.uploaded.unwrap_or(NumberOfBytes::new(0)), - downloaded: announce_request.downloaded.unwrap_or(NumberOfBytes::new(0)), - left: announce_request.left.unwrap_or(NumberOfBytes::new(0)), - event: map_to_torrust_event(&announce_request.event), - } -} - -#[must_use] -pub fn map_to_aquatic_event(event: &Option) -> aquatic_udp_protocol::AnnounceEvent { - match event { - Some(event) => match &event { - Event::Started => aquatic_udp_protocol::AnnounceEvent::Started, - Event::Stopped => aquatic_udp_protocol::AnnounceEvent::Stopped, - Event::Completed => aquatic_udp_protocol::AnnounceEvent::Completed, - }, - None => aquatic_udp_protocol::AnnounceEvent::None, - } -} - -#[must_use] -pub fn map_to_torrust_event(event: &Option) -> AnnounceEvent { - match event { - Some(event) => match &event { - Event::Started => AnnounceEvent::Started, - Event::Stopped => AnnounceEvent::Stopped, - Event::Completed => AnnounceEvent::Completed, - }, - None => AnnounceEvent::None, - } -} - -#[cfg(test)] -mod tests { - - use aquatic_udp_protocol::PeerId; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::tracker_factory; - use crate::core::Tracker; - use crate::servers::http::v1::requests::announce::Announce; - use crate::servers::http::v1::responses; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - fn private_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_private()) - } - - fn whitelisted_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_listed()) - } - - fn tracker_on_reverse_proxy() -> Tracker { - tracker_factory(&configuration::ephemeral_with_reverse_proxy()) - } - - fn tracker_not_on_reverse_proxy() -> Tracker { - tracker_factory(&configuration::ephemeral_without_reverse_proxy()) - } - - fn sample_announce_request() -> Announce { - Announce { - info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), - peer_id: PeerId(*b"-qB00000000000000001"), - port: 17548, - downloaded: None, - uploaded: None, - left: None, - event: None, - compact: None, - numwant: None, - } - } - - fn sample_client_ip_sources() -> ClientIpSources { - ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - } - } - - fn assert_error_response(error: &responses::error::Error, error_message: &str) { - assert!( - error.failure_reason.contains(error_message), - "Error response does not contain message: '{error_message}'. Error: {error:?}" - ); - } - - mod with_tracker_in_private_mode { - - use std::str::FromStr; - use std::sync::Arc; - - use super::{private_tracker, sample_announce_request, sample_client_ip_sources}; - use crate::core::auth; - use crate::servers::http::v1::handlers::announce::handle_announce; - use crate::servers::http::v1::handlers::announce::tests::assert_error_response; - - #[tokio::test] - async fn it_should_fail_when_the_authentication_key_is_missing() { - let tracker = Arc::new(private_tracker()); - - let maybe_key = None; - - let response = handle_announce(&tracker, &sample_announce_request(), &sample_client_ip_sources(), maybe_key) - .await - .unwrap_err(); - - assert_error_response( - &response, - "Authentication error: Missing authentication key param for private tracker", - ); - } - - #[tokio::test] - async fn it_should_fail_when_the_authentication_key_is_invalid() { - let tracker = Arc::new(private_tracker()); - - let unregistered_key = auth::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); - - let maybe_key = Some(unregistered_key); - - let response = handle_announce(&tracker, &sample_announce_request(), &sample_client_ip_sources(), maybe_key) - .await - .unwrap_err(); - - assert_error_response(&response, "Authentication error: Failed to read key"); - } - } - - mod with_tracker_in_listed_mode { - - use std::sync::Arc; - - use super::{sample_announce_request, sample_client_ip_sources, whitelisted_tracker}; - use crate::servers::http::v1::handlers::announce::handle_announce; - use crate::servers::http::v1::handlers::announce::tests::assert_error_response; - - #[tokio::test] - async fn it_should_fail_when_the_announced_torrent_is_not_whitelisted() { - let tracker = Arc::new(whitelisted_tracker()); - - let announce_request = sample_announce_request(); - - let response = handle_announce(&tracker, &announce_request, &sample_client_ip_sources(), None) - .await - .unwrap_err(); - - assert_error_response( - &response, - &format!( - "Tracker error: The torrent: {}, is not whitelisted", - announce_request.info_hash - ), - ); - } - } - - mod with_tracker_on_reverse_proxy { - - use std::sync::Arc; - - use super::{sample_announce_request, tracker_on_reverse_proxy}; - use crate::servers::http::v1::handlers::announce::handle_announce; - use crate::servers::http::v1::handlers::announce::tests::assert_error_response; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - #[tokio::test] - async fn it_should_fail_when_the_right_most_x_forwarded_for_header_ip_is_not_available() { - let tracker = Arc::new(tracker_on_reverse_proxy()); - - let client_ip_sources = ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }; - - let response = handle_announce(&tracker, &sample_announce_request(), &client_ip_sources, None) - .await - .unwrap_err(); - - assert_error_response( - &response, - "Error resolving peer IP: missing or invalid the right most X-Forwarded-For IP", - ); - } - } - - mod with_tracker_not_on_reverse_proxy { - - use std::sync::Arc; - - use super::{sample_announce_request, tracker_not_on_reverse_proxy}; - use crate::servers::http::v1::handlers::announce::handle_announce; - use crate::servers::http::v1::handlers::announce::tests::assert_error_response; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - #[tokio::test] - async fn it_should_fail_when_the_client_ip_from_the_connection_info_is_not_available() { - let tracker = Arc::new(tracker_not_on_reverse_proxy()); - - let client_ip_sources = ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }; - - let response = handle_announce(&tracker, &sample_announce_request(), &client_ip_sources, None) - .await - .unwrap_err(); - - assert_error_response( - &response, - "Error resolving peer IP: cannot get the client IP from the connection info", - ); - } - } -} diff --git a/src/servers/http/v1/handlers/common/auth.rs b/src/servers/http/v1/handlers/common/auth.rs deleted file mode 100644 index f9a7796a4..000000000 --- a/src/servers/http/v1/handlers/common/auth.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! HTTP server authentication error and conversion to -//! [`responses::error::Error`] -//! response. -use std::panic::Location; - -use thiserror::Error; - -use crate::core::auth; -use crate::servers::http::v1::responses; - -/// Authentication error. -/// -/// When the tracker is private, the authentication key is required in the URL -/// path. These are the possible errors that can occur when extracting the key -/// from the URL path. -#[derive(Debug, Error)] -pub enum Error { - #[error("Missing authentication key param for private tracker. Error in {location}")] - MissingAuthKey { location: &'static Location<'static> }, - #[error("Invalid format for authentication key param. Error in {location}")] - InvalidKeyFormat { location: &'static Location<'static> }, - #[error("Cannot extract authentication key param from URL path. Error in {location}")] - CannotExtractKeyParam { location: &'static Location<'static> }, -} - -impl From for responses::error::Error { - fn from(err: Error) -> Self { - responses::error::Error { - failure_reason: format!("Authentication error: {err}"), - } - } -} - -impl From for responses::error::Error { - fn from(err: auth::Error) -> Self { - responses::error::Error { - failure_reason: format!("Authentication error: {err}"), - } - } -} diff --git a/src/servers/http/v1/handlers/common/mod.rs b/src/servers/http/v1/handlers/common/mod.rs deleted file mode 100644 index 30eaf37b7..000000000 --- a/src/servers/http/v1/handlers/common/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Common logic for HTTP handlers. -pub mod auth; -pub mod peer_ip; diff --git a/src/servers/http/v1/handlers/common/peer_ip.rs b/src/servers/http/v1/handlers/common/peer_ip.rs deleted file mode 100644 index 5602bd26c..000000000 --- a/src/servers/http/v1/handlers/common/peer_ip.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Logic to convert peer IP resolution errors into responses. -//! -//! The HTTP tracker may fail to resolve the peer IP address. This module -//! contains the logic to convert those -//! [`PeerIpResolutionError`] -//! errors into responses. -use crate::servers::http::v1::responses; -use crate::servers::http::v1::services::peer_ip_resolver::PeerIpResolutionError; - -impl From for responses::error::Error { - fn from(err: PeerIpResolutionError) -> Self { - responses::error::Error { - failure_reason: format!("Error resolving peer IP: {err}"), - } - } -} - -#[cfg(test)] -mod tests { - use std::panic::Location; - - use crate::servers::http::v1::responses; - use crate::servers::http::v1::services::peer_ip_resolver::PeerIpResolutionError; - - fn assert_error_response(error: &responses::error::Error, error_message: &str) { - assert!( - error.failure_reason.contains(error_message), - "Error response does not contain message: '{error_message}'. Error: {error:?}" - ); - } - - #[test] - fn it_should_map_a_peer_ip_resolution_error_into_an_error_response() { - let response = responses::error::Error::from(PeerIpResolutionError::MissingRightMostXForwardedForIp { - location: Location::caller(), - }); - - assert_error_response(&response, "Error resolving peer IP"); - } -} diff --git a/src/servers/http/v1/handlers/mod.rs b/src/servers/http/v1/handlers/mod.rs deleted file mode 100644 index 7b3a1e7c3..000000000 --- a/src/servers/http/v1/handlers/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Axum [`handlers`](axum#handlers) for the HTTP server. -//! -//! Refer to the generic [HTTP server documentation](crate::servers::http) for -//! more information about the HTTP tracker. -use super::responses; -use crate::core::error::Error; - -pub mod announce; -pub mod common; -pub mod health_check; -pub mod scrape; - -impl From for responses::error::Error { - fn from(err: Error) -> Self { - responses::error::Error { - failure_reason: format!("Tracker error: {err}"), - } - } -} diff --git a/src/servers/http/v1/handlers/scrape.rs b/src/servers/http/v1/handlers/scrape.rs deleted file mode 100644 index ca4c85207..000000000 --- a/src/servers/http/v1/handlers/scrape.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Axum [`handlers`](axum#handlers) for the `announce` requests. -//! -//! Refer to [HTTP server](crate::servers::http) for more information about the -//! `scrape` request. -//! -//! The handlers perform the authentication and authorization of the request, -//! and resolve the client IP address. -use std::sync::Arc; - -use axum::extract::State; -use axum::response::{IntoResponse, Response}; - -use crate::core::auth::Key; -use crate::core::{ScrapeData, Tracker}; -use crate::servers::http::v1::extractors::authentication_key::Extract as ExtractKey; -use crate::servers::http::v1::extractors::client_ip_sources::Extract as ExtractClientIpSources; -use crate::servers::http::v1::extractors::scrape_request::ExtractRequest; -use crate::servers::http::v1::requests::scrape::Scrape; -use crate::servers::http::v1::services::peer_ip_resolver::{self, ClientIpSources}; -use crate::servers::http::v1::{responses, services}; - -/// It handles the `scrape` request when the HTTP tracker is configured -/// to run in `public` mode. -#[allow(clippy::unused_async)] -pub async fn handle_without_key( - State(tracker): State>, - ExtractRequest(scrape_request): ExtractRequest, - ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, -) -> Response { - tracing::debug!("http scrape request: {:#?}", &scrape_request); - - handle(&tracker, &scrape_request, &client_ip_sources, None).await -} - -/// It handles the `scrape` request when the HTTP tracker is configured -/// to run in `private` or `private_listed` mode. -/// -/// In this case, the authentication `key` parameter is required. -#[allow(clippy::unused_async)] -pub async fn handle_with_key( - State(tracker): State>, - ExtractRequest(scrape_request): ExtractRequest, - ExtractClientIpSources(client_ip_sources): ExtractClientIpSources, - ExtractKey(key): ExtractKey, -) -> Response { - tracing::debug!("http scrape request: {:#?}", &scrape_request); - - handle(&tracker, &scrape_request, &client_ip_sources, Some(key)).await -} - -async fn handle( - tracker: &Arc, - scrape_request: &Scrape, - client_ip_sources: &ClientIpSources, - maybe_key: Option, -) -> Response { - let scrape_data = match handle_scrape(tracker, scrape_request, client_ip_sources, maybe_key).await { - Ok(scrape_data) => scrape_data, - Err(error) => return error.into_response(), - }; - build_response(scrape_data) -} - -/* code-review: authentication, authorization and peer IP resolution could be moved - from the handler (Axum) layer into the app layer `services::announce::invoke`. - That would make the handler even simpler and the code more reusable and decoupled from Axum. - See https://github.com/torrust/torrust-tracker/discussions/240. -*/ - -async fn handle_scrape( - tracker: &Arc, - scrape_request: &Scrape, - client_ip_sources: &ClientIpSources, - maybe_key: Option, -) -> Result { - // Authentication - let return_real_scrape_data = if tracker.requires_authentication() { - match maybe_key { - Some(key) => match tracker.authenticate(&key).await { - Ok(()) => true, - Err(_error) => false, - }, - None => false, - } - } else { - true - }; - - // Authorization for scrape requests is handled at the `Tracker` level - // for each torrent. - - let peer_ip = match peer_ip_resolver::invoke(tracker.is_behind_reverse_proxy(), client_ip_sources) { - Ok(peer_ip) => peer_ip, - Err(error) => return Err(responses::error::Error::from(error)), - }; - - if return_real_scrape_data { - Ok(services::scrape::invoke(tracker, &scrape_request.info_hashes, &peer_ip).await) - } else { - Ok(services::scrape::fake(tracker, &scrape_request.info_hashes, &peer_ip).await) - } -} - -fn build_response(scrape_data: ScrapeData) -> Response { - responses::scrape::Bencoded::from(scrape_data).into_response() -} - -#[cfg(test)] -mod tests { - use std::net::IpAddr; - use std::str::FromStr; - - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::tracker_factory; - use crate::core::Tracker; - use crate::servers::http::v1::requests::scrape::Scrape; - use crate::servers::http::v1::responses; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - fn private_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_private()) - } - - fn whitelisted_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_listed()) - } - - fn tracker_on_reverse_proxy() -> Tracker { - tracker_factory(&configuration::ephemeral_with_reverse_proxy()) - } - - fn tracker_not_on_reverse_proxy() -> Tracker { - tracker_factory(&configuration::ephemeral_without_reverse_proxy()) - } - - fn sample_scrape_request() -> Scrape { - Scrape { - info_hashes: vec!["3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap()], - } - } - - fn sample_client_ip_sources() -> ClientIpSources { - ClientIpSources { - right_most_x_forwarded_for: Some(IpAddr::from_str("203.0.113.195").unwrap()), - connection_info_ip: Some(IpAddr::from_str("203.0.113.196").unwrap()), - } - } - - fn assert_error_response(error: &responses::error::Error, error_message: &str) { - assert!( - error.failure_reason.contains(error_message), - "Error response does not contain message: '{error_message}'. Error: {error:?}" - ); - } - - mod with_tracker_in_private_mode { - use std::str::FromStr; - use std::sync::Arc; - - use super::{private_tracker, sample_client_ip_sources, sample_scrape_request}; - use crate::core::{auth, ScrapeData}; - use crate::servers::http::v1::handlers::scrape::handle_scrape; - - #[tokio::test] - async fn it_should_return_zeroed_swarm_metadata_when_the_authentication_key_is_missing() { - let tracker = Arc::new(private_tracker()); - - let scrape_request = sample_scrape_request(); - let maybe_key = None; - - let scrape_data = handle_scrape(&tracker, &scrape_request, &sample_client_ip_sources(), maybe_key) - .await - .unwrap(); - - let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); - - assert_eq!(scrape_data, expected_scrape_data); - } - - #[tokio::test] - async fn it_should_return_zeroed_swarm_metadata_when_the_authentication_key_is_invalid() { - let tracker = Arc::new(private_tracker()); - - let scrape_request = sample_scrape_request(); - let unregistered_key = auth::Key::from_str("YZSl4lMZupRuOpSRC3krIKR5BPB14nrJ").unwrap(); - let maybe_key = Some(unregistered_key); - - let scrape_data = handle_scrape(&tracker, &scrape_request, &sample_client_ip_sources(), maybe_key) - .await - .unwrap(); - - let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); - - assert_eq!(scrape_data, expected_scrape_data); - } - } - - mod with_tracker_in_listed_mode { - - use std::sync::Arc; - - use super::{sample_client_ip_sources, sample_scrape_request, whitelisted_tracker}; - use crate::core::ScrapeData; - use crate::servers::http::v1::handlers::scrape::handle_scrape; - - #[tokio::test] - async fn it_should_return_zeroed_swarm_metadata_when_the_torrent_is_not_whitelisted() { - let tracker = Arc::new(whitelisted_tracker()); - - let scrape_request = sample_scrape_request(); - - let scrape_data = handle_scrape(&tracker, &scrape_request, &sample_client_ip_sources(), None) - .await - .unwrap(); - - let expected_scrape_data = ScrapeData::zeroed(&scrape_request.info_hashes); - - assert_eq!(scrape_data, expected_scrape_data); - } - } - - mod with_tracker_on_reverse_proxy { - use std::sync::Arc; - - use super::{sample_scrape_request, tracker_on_reverse_proxy}; - use crate::servers::http::v1::handlers::scrape::handle_scrape; - use crate::servers::http::v1::handlers::scrape::tests::assert_error_response; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - #[tokio::test] - async fn it_should_fail_when_the_right_most_x_forwarded_for_header_ip_is_not_available() { - let tracker = Arc::new(tracker_on_reverse_proxy()); - - let client_ip_sources = ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }; - - let response = handle_scrape(&tracker, &sample_scrape_request(), &client_ip_sources, None) - .await - .unwrap_err(); - - assert_error_response( - &response, - "Error resolving peer IP: missing or invalid the right most X-Forwarded-For IP", - ); - } - } - - mod with_tracker_not_on_reverse_proxy { - use std::sync::Arc; - - use super::{sample_scrape_request, tracker_not_on_reverse_proxy}; - use crate::servers::http::v1::handlers::scrape::handle_scrape; - use crate::servers::http::v1::handlers::scrape::tests::assert_error_response; - use crate::servers::http::v1::services::peer_ip_resolver::ClientIpSources; - - #[tokio::test] - async fn it_should_fail_when_the_client_ip_from_the_connection_info_is_not_available() { - let tracker = Arc::new(tracker_not_on_reverse_proxy()); - - let client_ip_sources = ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }; - - let response = handle_scrape(&tracker, &sample_scrape_request(), &client_ip_sources, None) - .await - .unwrap_err(); - - assert_error_response( - &response, - "Error resolving peer IP: cannot get the client IP from the connection info", - ); - } - } -} diff --git a/src/servers/http/v1/mod.rs b/src/servers/http/v1/mod.rs deleted file mode 100644 index 9d2745692..000000000 --- a/src/servers/http/v1/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! HTTP server implementation for the `v1` API. -//! -//! Refer to the generic [HTTP server documentation](crate::servers::http) for -//! more information about the endpoints and their usage. -pub mod extractors; -pub mod handlers; -pub mod query; -pub mod requests; -pub mod responses; -pub mod routes; -pub mod services; diff --git a/src/servers/http/v1/requests/mod.rs b/src/servers/http/v1/requests/mod.rs deleted file mode 100644 index ee34ca72a..000000000 --- a/src/servers/http/v1/requests/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! HTTP requests for the HTTP tracker. -//! -//! Refer to the generic [HTTP server documentation](crate::servers::http) for -//! more information about the HTTP tracker. -pub mod announce; -pub mod scrape; diff --git a/src/servers/http/v1/responses/error.rs b/src/servers/http/v1/responses/error.rs deleted file mode 100644 index c406c797a..000000000 --- a/src/servers/http/v1/responses/error.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! `Error` response for the [`HTTP tracker`](crate::servers::http). -//! -//! Data structures and logic to build the error responses. -//! -//! From the [BEP 03. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html): -//! -//! _"Tracker responses are bencoded dictionaries. If a tracker response has a -//! key failure reason, then that maps to a human readable string which explains -//! why the query failed, and no other keys are required."_ -//! -//! > **NOTICE**: error responses are bencoded and always have a `200 OK` status -//! > code. The official `BitTorrent` specification does not specify the status -//! > code. -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use serde::Serialize; - -/// `Error` response for the [`HTTP tracker`](crate::servers::http). -#[derive(Serialize, Debug, PartialEq)] -pub struct Error { - /// Human readable string which explains why the request failed. - #[serde(rename = "failure reason")] - pub failure_reason: String, -} - -impl Error { - /// Returns the bencoded representation of the `Error` struct. - /// - /// ```rust - /// use torrust_tracker::servers::http::v1::responses::error::Error; - /// - /// let err = Error { - /// failure_reason: "error message".to_owned(), - /// }; - /// - /// // cspell:disable-next-line - /// assert_eq!(err.write(), "d14:failure reason13:error messagee"); - /// ``` - /// - /// # Panics - /// - /// It would panic if the `Error` struct contained an inappropriate field - /// type. - #[must_use] - pub fn write(&self) -> String { - serde_bencode::to_string(&self).unwrap() - } -} - -impl IntoResponse for Error { - fn into_response(self) -> Response { - (StatusCode::OK, self.write()).into_response() - } -} - -#[cfg(test)] -mod tests { - - use super::Error; - - #[test] - fn http_tracker_errors_can_be_bencoded() { - let err = Error { - failure_reason: "error message".to_owned(), - }; - - assert_eq!(err.write(), "d14:failure reason13:error messagee"); // cspell:disable-line - } -} diff --git a/src/servers/http/v1/responses/mod.rs b/src/servers/http/v1/responses/mod.rs deleted file mode 100644 index e22879c6d..000000000 --- a/src/servers/http/v1/responses/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! HTTP responses for the HTTP tracker. -//! -//! Refer to the generic [HTTP server documentation](crate::servers::http) for -//! more information about the HTTP tracker. -pub mod announce; -pub mod error; -pub mod scrape; - -pub use announce::{Announce, Compact, Normal}; - -/// Trait that defines the Announce Response Format -pub trait Response: axum::response::IntoResponse { - /// Returns the Body of the Announce Response - /// - /// # Errors - /// - /// If unable to generate the response, it will return an error. - fn body(self) -> Result, error::Error>; -} diff --git a/src/servers/http/v1/services/announce.rs b/src/servers/http/v1/services/announce.rs deleted file mode 100644 index 9c5dfdad2..000000000 --- a/src/servers/http/v1/services/announce.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! The `announce` service. -//! -//! The service is responsible for handling the `announce` requests. -//! -//! It delegates the `announce` logic to the [`Tracker`](crate::core::Tracker::announce) -//! and it returns the [`AnnounceData`] returned -//! by the [`Tracker`]. -//! -//! It also sends an [`statistics::Event`] -//! because events are specific for the HTTP tracker. -use std::net::IpAddr; -use std::sync::Arc; - -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::peer; - -use crate::core::{statistics, AnnounceData, PeersWanted, Tracker}; - -/// The HTTP tracker `announce` service. -/// -/// The service sends an statistics event that increments: -/// -/// - The number of TCP connections handled by the HTTP tracker. -/// - The number of TCP `announce` requests handled by the HTTP tracker. -/// -/// > **NOTICE**: as the HTTP tracker does not requires a connection request -/// > like the UDP tracker, the number of TCP connections is incremented for -/// > each `announce` request. -pub async fn invoke( - tracker: Arc, - info_hash: InfoHash, - peer: &mut peer::Peer, - peers_wanted: &PeersWanted, -) -> AnnounceData { - let original_peer_ip = peer.peer_addr.ip(); - - // The tracker could change the original peer ip - let announce_data = tracker.announce(&info_hash, peer, &original_peer_ip, peers_wanted); - - match original_peer_ip { - IpAddr::V4(_) => { - tracker.send_stats_event(statistics::Event::Tcp4Announce).await; - } - IpAddr::V6(_) => { - tracker.send_stats_event(statistics::Event::Tcp6Announce).await; - } - } - - announce_data -} - -#[cfg(test)] -mod tests { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - - use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::tracker_factory; - use crate::core::Tracker; - - fn public_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_public()) - } - - fn sample_info_hash() -> InfoHash { - "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap() - } - - fn sample_peer_using_ipv4() -> peer::Peer { - sample_peer() - } - - fn sample_peer_using_ipv6() -> peer::Peer { - let mut peer = sample_peer(); - peer.peer_addr = SocketAddr::new( - IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), - 8080, - ); - peer - } - - fn sample_peer() -> peer::Peer { - peer::Peer { - peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), - event: AnnounceEvent::Started, - } - } - - mod with_tracker_in_any_mode { - use std::future; - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - use std::sync::Arc; - - use mockall::predicate::eq; - use torrust_tracker_primitives::peer; - use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - use torrust_tracker_test_helpers::configuration; - - use super::{sample_peer_using_ipv4, sample_peer_using_ipv6}; - use crate::core::{statistics, AnnounceData, PeersWanted, Tracker}; - use crate::servers::http::v1::services::announce::invoke; - use crate::servers::http::v1::services::announce::tests::{public_tracker, sample_info_hash, sample_peer}; - - #[tokio::test] - async fn it_should_return_the_announce_data() { - let tracker = Arc::new(public_tracker()); - - let mut peer = sample_peer(); - - let announce_data = invoke(tracker.clone(), sample_info_hash(), &mut peer, &PeersWanted::All).await; - - let expected_announce_data = AnnounceData { - peers: vec![], - stats: SwarmMetadata { - downloaded: 0, - complete: 1, - incomplete: 0, - }, - policy: tracker.get_announce_policy(), - }; - - assert_eq!(announce_data, expected_announce_data); - } - - #[tokio::test] - async fn it_should_send_the_tcp_4_announce_event_when_the_peer_uses_ipv4() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp4Announce)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let mut peer = sample_peer_using_ipv4(); - - let _announce_data = invoke(tracker, sample_info_hash(), &mut peer, &PeersWanted::All).await; - } - - fn tracker_with_an_ipv6_external_ip(stats_event_sender: Box) -> Tracker { - let mut configuration = configuration::ephemeral(); - configuration.core.net.external_ip = Some(IpAddr::V6(Ipv6Addr::new( - 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, - ))); - - Tracker::new(&configuration.core, Some(stats_event_sender), statistics::Repo::new()).unwrap() - } - - fn peer_with_the_ipv4_loopback_ip() -> peer::Peer { - let loopback_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); - let mut peer = sample_peer(); - peer.peer_addr = SocketAddr::new(loopback_ip, 8080); - peer - } - - #[tokio::test] - async fn it_should_send_the_tcp_4_announce_event_when_the_peer_uses_ipv4_even_if_the_tracker_changes_the_peer_ip_to_ipv6() - { - // Tracker changes the peer IP to the tracker external IP when the peer is using the loopback IP. - - // Assert that the event sent is a TCP4 event - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp4Announce)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let mut peer = peer_with_the_ipv4_loopback_ip(); - - let _announce_data = invoke( - tracker_with_an_ipv6_external_ip(stats_event_sender).into(), - sample_info_hash(), - &mut peer, - &PeersWanted::All, - ) - .await; - } - - #[tokio::test] - async fn it_should_send_the_tcp_6_announce_event_when_the_peer_uses_ipv6_even_if_the_tracker_changes_the_peer_ip_to_ipv4() - { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp6Announce)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let mut peer = sample_peer_using_ipv6(); - - let _announce_data = invoke(tracker, sample_info_hash(), &mut peer, &PeersWanted::All).await; - } - } -} diff --git a/src/servers/http/v1/services/peer_ip_resolver.rs b/src/servers/http/v1/services/peer_ip_resolver.rs deleted file mode 100644 index b8987bb4d..000000000 --- a/src/servers/http/v1/services/peer_ip_resolver.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! This service resolves the peer IP from the request. -//! -//! The peer IP is used to identify the peer in the tracker. It's the peer IP -//! that is used in the `announce` responses (peer list). And it's also used to -//! send statistics events. -//! -//! Given this request chain: -//! -//! ```text -//! client <-> http proxy 1 <-> http proxy 2 <-> server -//! ip: 126.0.0.1 ip: 126.0.0.2 ip: 126.0.0.3 ip: 126.0.0.4 -//! X-Forwarded-For: 126.0.0.1 X-Forwarded-For: 126.0.0.1,126.0.0.2 -//! ``` -//! -//! This service returns two options for the peer IP: -//! -//! ```text -//! right_most_x_forwarded_for = 126.0.0.2 -//! connection_info_ip = 126.0.0.3 -//! ``` -//! -//! Depending on the tracker configuration. -use std::net::IpAddr; -use std::panic::Location; - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -/// This struct contains the sources from which the peer IP can be obtained. -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] -pub struct ClientIpSources { - /// The right most IP from the `X-Forwarded-For` HTTP header. - pub right_most_x_forwarded_for: Option, - /// The IP from the connection info. - pub connection_info_ip: Option, -} - -/// The error that can occur when resolving the peer IP. -#[derive(Error, Debug)] -pub enum PeerIpResolutionError { - /// The peer IP cannot be obtained because the tracker is configured as a - /// reverse proxy but the `X-Forwarded-For` HTTP header is missing or - /// invalid. - #[error( - "missing or invalid the right most X-Forwarded-For IP (mandatory on reverse proxy tracker configuration) in {location}" - )] - MissingRightMostXForwardedForIp { location: &'static Location<'static> }, - /// The peer IP cannot be obtained because the tracker is not configured as - /// a reverse proxy but the connection info was not provided to the Axum - /// framework via a route extension. - #[error("cannot get the client IP from the connection info in {location}")] - MissingClientIp { location: &'static Location<'static> }, -} - -/// Resolves the peer IP from the request. -/// -/// Given the sources from which the peer IP can be obtained, this function -/// resolves the peer IP according to the tracker configuration. -/// -/// With the tracker running on reverse proxy mode: -/// -/// ```rust -/// use std::net::IpAddr; -/// use std::str::FromStr; -/// -/// use torrust_tracker::servers::http::v1::services::peer_ip_resolver::{invoke, ClientIpSources, PeerIpResolutionError}; -/// -/// let on_reverse_proxy = true; -/// -/// let ip = invoke( -/// on_reverse_proxy, -/// &ClientIpSources { -/// right_most_x_forwarded_for: Some(IpAddr::from_str("203.0.113.195").unwrap()), -/// connection_info_ip: None, -/// }, -/// ) -/// .unwrap(); -/// -/// assert_eq!(ip, IpAddr::from_str("203.0.113.195").unwrap()); -/// ``` -/// -/// With the tracker non running on reverse proxy mode: -/// -/// ```rust -/// use std::net::IpAddr; -/// use std::str::FromStr; -/// -/// use torrust_tracker::servers::http::v1::services::peer_ip_resolver::{invoke, ClientIpSources, PeerIpResolutionError}; -/// -/// let on_reverse_proxy = false; -/// -/// let ip = invoke( -/// on_reverse_proxy, -/// &ClientIpSources { -/// right_most_x_forwarded_for: None, -/// connection_info_ip: Some(IpAddr::from_str("203.0.113.195").unwrap()), -/// }, -/// ) -/// .unwrap(); -/// -/// assert_eq!(ip, IpAddr::from_str("203.0.113.195").unwrap()); -/// ``` -/// -/// # Errors -/// -/// Will return an error if the peer IP cannot be obtained according to the configuration. -/// For example, if the IP is extracted from an HTTP header which is missing in the request. -pub fn invoke(on_reverse_proxy: bool, client_ip_sources: &ClientIpSources) -> Result { - if on_reverse_proxy { - resolve_peer_ip_on_reverse_proxy(client_ip_sources) - } else { - resolve_peer_ip_without_reverse_proxy(client_ip_sources) - } -} - -fn resolve_peer_ip_without_reverse_proxy(remote_client_ip: &ClientIpSources) -> Result { - if let Some(ip) = remote_client_ip.connection_info_ip { - Ok(ip) - } else { - Err(PeerIpResolutionError::MissingClientIp { - location: Location::caller(), - }) - } -} - -fn resolve_peer_ip_on_reverse_proxy(remote_client_ip: &ClientIpSources) -> Result { - if let Some(ip) = remote_client_ip.right_most_x_forwarded_for { - Ok(ip) - } else { - Err(PeerIpResolutionError::MissingRightMostXForwardedForIp { - location: Location::caller(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::invoke; - - mod working_without_reverse_proxy { - use std::net::IpAddr; - use std::str::FromStr; - - use super::invoke; - use crate::servers::http::v1::services::peer_ip_resolver::{ClientIpSources, PeerIpResolutionError}; - - #[test] - fn it_should_get_the_peer_ip_from_the_connection_info() { - let on_reverse_proxy = false; - - let ip = invoke( - on_reverse_proxy, - &ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: Some(IpAddr::from_str("203.0.113.195").unwrap()), - }, - ) - .unwrap(); - - assert_eq!(ip, IpAddr::from_str("203.0.113.195").unwrap()); - } - - #[test] - fn it_should_return_an_error_if_it_cannot_get_the_peer_ip_from_the_connection_info() { - let on_reverse_proxy = false; - - let error = invoke( - on_reverse_proxy, - &ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }, - ) - .unwrap_err(); - - assert!(matches!(error, PeerIpResolutionError::MissingClientIp { .. })); - } - } - - mod working_on_reverse_proxy { - use std::net::IpAddr; - use std::str::FromStr; - - use crate::servers::http::v1::services::peer_ip_resolver::{invoke, ClientIpSources, PeerIpResolutionError}; - - #[test] - fn it_should_get_the_peer_ip_from_the_right_most_ip_in_the_x_forwarded_for_header() { - let on_reverse_proxy = true; - - let ip = invoke( - on_reverse_proxy, - &ClientIpSources { - right_most_x_forwarded_for: Some(IpAddr::from_str("203.0.113.195").unwrap()), - connection_info_ip: None, - }, - ) - .unwrap(); - - assert_eq!(ip, IpAddr::from_str("203.0.113.195").unwrap()); - } - - #[test] - fn it_should_return_an_error_if_it_cannot_get_the_right_most_ip_from_the_x_forwarded_for_header() { - let on_reverse_proxy = true; - - let error = invoke( - on_reverse_proxy, - &ClientIpSources { - right_most_x_forwarded_for: None, - connection_info_ip: None, - }, - ) - .unwrap_err(); - - assert!(matches!(error, PeerIpResolutionError::MissingRightMostXForwardedForIp { .. })); - } - } -} diff --git a/src/servers/http/v1/services/scrape.rs b/src/servers/http/v1/services/scrape.rs deleted file mode 100644 index 0d561c7bc..000000000 --- a/src/servers/http/v1/services/scrape.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! The `scrape` service. -//! -//! The service is responsible for handling the `scrape` requests. -//! -//! It delegates the `scrape` logic to the [`Tracker`](crate::core::Tracker::scrape) -//! and it returns the [`ScrapeData`] returned -//! by the [`Tracker`]. -//! -//! It also sends an [`statistics::Event`] -//! because events are specific for the HTTP tracker. -use std::net::IpAddr; -use std::sync::Arc; - -use torrust_tracker_primitives::info_hash::InfoHash; - -use crate::core::{statistics, ScrapeData, Tracker}; - -/// The HTTP tracker `scrape` service. -/// -/// The service sends an statistics event that increments: -/// -/// - The number of TCP connections handled by the HTTP tracker. -/// - The number of TCP `scrape` requests handled by the HTTP tracker. -/// -/// > **NOTICE**: as the HTTP tracker does not requires a connection request -/// > like the UDP tracker, the number of TCP connections is incremented for -/// > each `scrape` request. -pub async fn invoke(tracker: &Arc, info_hashes: &Vec, original_peer_ip: &IpAddr) -> ScrapeData { - let scrape_data = tracker.scrape(info_hashes).await; - - send_scrape_event(original_peer_ip, tracker).await; - - scrape_data -} - -/// The HTTP tracker fake `scrape` service. It returns zeroed stats. -/// -/// When the peer is not authenticated and the tracker is running in `private` mode, -/// the tracker returns empty stats for all the torrents. -/// -/// > **NOTICE**: tracker statistics are not updated in this case. -pub async fn fake(tracker: &Arc, info_hashes: &Vec, original_peer_ip: &IpAddr) -> ScrapeData { - send_scrape_event(original_peer_ip, tracker).await; - - ScrapeData::zeroed(info_hashes) -} - -async fn send_scrape_event(original_peer_ip: &IpAddr, tracker: &Arc) { - match original_peer_ip { - IpAddr::V4(_) => { - tracker.send_stats_event(statistics::Event::Tcp4Scrape).await; - } - IpAddr::V6(_) => { - tracker.send_stats_event(statistics::Event::Tcp6Scrape).await; - } - } -} - -#[cfg(test)] -mod tests { - - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - - use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes, PeerId}; - use torrust_tracker_primitives::info_hash::InfoHash; - use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch}; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::tracker_factory; - use crate::core::Tracker; - - fn public_tracker() -> Tracker { - tracker_factory(&configuration::ephemeral_public()) - } - - fn sample_info_hashes() -> Vec { - vec![sample_info_hash()] - } - - fn sample_info_hash() -> InfoHash { - "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap() - } - - fn sample_peer() -> peer::Peer { - peer::Peer { - peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), - updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), - uploaded: NumberOfBytes::new(0), - downloaded: NumberOfBytes::new(0), - left: NumberOfBytes::new(0), - event: AnnounceEvent::Started, - } - } - - mod with_real_data { - - use std::future; - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use std::sync::Arc; - - use mockall::predicate::eq; - use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - use torrust_tracker_test_helpers::configuration; - - use crate::core::{statistics, PeersWanted, ScrapeData, Tracker}; - use crate::servers::http::v1::services::scrape::invoke; - use crate::servers::http::v1::services::scrape::tests::{ - public_tracker, sample_info_hash, sample_info_hashes, sample_peer, - }; - - #[tokio::test] - async fn it_should_return_the_scrape_data_for_a_torrent() { - let tracker = Arc::new(public_tracker()); - - let info_hash = sample_info_hash(); - let info_hashes = vec![info_hash]; - - // Announce a new peer to force scrape data to contain not zeroed data - let mut peer = sample_peer(); - let original_peer_ip = peer.ip(); - tracker.announce(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::All); - - let scrape_data = invoke(&tracker, &info_hashes, &original_peer_ip).await; - - let mut expected_scrape_data = ScrapeData::empty(); - expected_scrape_data.add_file( - &info_hash, - SwarmMetadata { - complete: 1, - downloaded: 0, - incomplete: 0, - }, - ); - - assert_eq!(scrape_data, expected_scrape_data); - } - - #[tokio::test] - async fn it_should_send_the_tcp_4_scrape_event_when_the_peer_uses_ipv4() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp4Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let peer_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); - - invoke(&tracker, &sample_info_hashes(), &peer_ip).await; - } - - #[tokio::test] - async fn it_should_send_the_tcp_6_scrape_event_when_the_peer_uses_ipv6() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp6Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let peer_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); - - invoke(&tracker, &sample_info_hashes(), &peer_ip).await; - } - } - - mod with_zeroed_data { - - use std::future; - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use std::sync::Arc; - - use mockall::predicate::eq; - use torrust_tracker_test_helpers::configuration; - - use crate::core::{statistics, PeersWanted, ScrapeData, Tracker}; - use crate::servers::http::v1::services::scrape::fake; - use crate::servers::http::v1::services::scrape::tests::{ - public_tracker, sample_info_hash, sample_info_hashes, sample_peer, - }; - - #[tokio::test] - async fn it_should_always_return_the_zeroed_scrape_data_for_a_torrent() { - let tracker = Arc::new(public_tracker()); - - let info_hash = sample_info_hash(); - let info_hashes = vec![info_hash]; - - // Announce a new peer to force scrape data to contain not zeroed data - let mut peer = sample_peer(); - let original_peer_ip = peer.ip(); - tracker.announce(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::All); - - let scrape_data = fake(&tracker, &info_hashes, &original_peer_ip).await; - - let expected_scrape_data = ScrapeData::zeroed(&info_hashes); - - assert_eq!(scrape_data, expected_scrape_data); - } - - #[tokio::test] - async fn it_should_send_the_tcp_4_scrape_event_when_the_peer_uses_ipv4() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp4Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let peer_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); - - fake(&tracker, &sample_info_hashes(), &peer_ip).await; - } - - #[tokio::test] - async fn it_should_send_the_tcp_6_scrape_event_when_the_peer_uses_ipv6() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Tcp6Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - Tracker::new( - &configuration::ephemeral().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let peer_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); - - fake(&tracker, &sample_info_hashes(), &peer_ip).await; - } - } -} diff --git a/src/servers/logging.rs b/src/servers/logging.rs deleted file mode 100644 index ad9ccbbcc..000000000 --- a/src/servers/logging.rs +++ /dev/null @@ -1,29 +0,0 @@ -/// This is the prefix used in logs to identify a started service. -/// -/// For example: -/// -/// ```text -/// 2024-06-25T12:36:25.025312Z INFO UDP TRACKER: Started on: udp://0.0.0.0:6969 -/// 2024-06-25T12:36:25.025445Z INFO HTTP TRACKER: Started on: http://0.0.0.0:7070 -/// 2024-06-25T12:36:25.025527Z INFO API: Started on http://0.0.0.0:1212 -/// 2024-06-25T12:36:25.025580Z INFO HEALTH CHECK API: Started on: http://127.0.0.1:1313 -/// ``` -pub const STARTED_ON: &str = "Started on"; - -/* - -todo: we should use a field fot the URL. - -For example, instead of: - -``` -2024-06-25T12:36:25.025312Z INFO UDP TRACKER: Started on: udp://0.0.0.0:6969 -``` - -We should use something like: - -``` -2024-06-25T12:36:25.025312Z INFO UDP TRACKER started_at_url=udp://0.0.0.0:6969 -``` - -*/ diff --git a/src/servers/mod.rs b/src/servers/mod.rs deleted file mode 100644 index 705a4728e..000000000 --- a/src/servers/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Servers. Services that can be started and stopped. -pub mod apis; -pub mod custom_axum_server; -pub mod health_check_api; -pub mod http; -pub mod logging; -pub mod registar; -pub mod signals; -pub mod udp; diff --git a/src/servers/udp/connection_cookie.rs b/src/servers/udp/connection_cookie.rs deleted file mode 100644 index 36bf98304..000000000 --- a/src/servers/udp/connection_cookie.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Logic for generating and verifying connection IDs. -//! -//! The UDP tracker requires the client to connect to the server before it can -//! send any data. The server responds with a random 64-bit integer that the -//! client must use to identify itself. -//! -//! This connection ID is used to avoid spoofing attacks. The client must send -//! the connection ID in all requests to the server. The server will ignore any -//! requests that do not contain the correct connection ID. -//! -//! The simplest way to implement this would be to generate a random number when -//! the client connects and store it in a hash table. However, this would -//! require the server to store a large number of connection IDs, which would be -//! a waste of memory. Instead, the server generates a connection ID based on -//! the client's IP address and the current time. This allows the server to -//! verify the connection ID without storing it. -//! -//! This module implements this method of generating connection IDs. It's the -//! most common way to generate connection IDs. The connection ID is generated -//! using a time based algorithm and it is valid for a certain amount of time -//! (usually two minutes). The connection ID is generated using the following: -//! -//! ```text -//! connection ID = hash(client IP + current time slot + secret seed) -//! ``` -//! -//! Time slots are two minute intervals since the Unix epoch. The secret seed is -//! a random number that is generated when the server starts. And the client IP -//! is used in order generate a unique connection ID for each client. -//! -//! The BEP-15 recommends a two-minute time slot. -//! -//! ```text -//! Timestamp (seconds from Unix epoch): -//! |------------|------------|------------|------------| -//! 0 120 240 360 480 -//! Time slots (two-minutes time extents from Unix epoch): -//! |------------|------------|------------|------------| -//! 0 1 2 3 4 -//! Peer connections: -//! Peer A |-------------------------| -//! Peer B |-------------------------| -//! Peer C |------------------| -//! Peer A connects at timestamp 120 slot 1 -> connection ID will be valid from timestamp 120 to 360 -//! Peer B connects at timestamp 240 slot 2 -> connection ID will be valid from timestamp 240 to 480 -//! Peer C connects at timestamp 180 slot 1 -> connection ID will be valid from timestamp 180 to 360 -//! ``` -//! > **NOTICE**: connection ID is always the same for a given peer -//! > (socket address) and time slot. -//! -//! > **NOTICE**: connection ID will be valid for two time extents, **not two -//! > minutes**. It'll be valid for the the current time extent and the next one. -//! -//! Refer to [`Connect`](crate::servers::udp#connect) for more information about -//! the connection process. -//! -//! ## Advantages -//! -//! - It consumes less memory than storing a hash table of connection IDs. -//! - It's easy to implement. -//! - It's fast. -//! -//! ## Disadvantages -//! -//! - It's not very flexible. The connection ID is only valid for a certain amount of time. -//! - It's not very accurate. The connection ID is valid for more than two minutes. -use std::net::SocketAddr; -use std::panic::Location; - -use aquatic_udp_protocol::ConnectionId; -use torrust_tracker_clock::time_extent::{Extent, TimeExtent}; -use zerocopy::network_endian::I64; -use zerocopy::AsBytes; - -use super::error::Error; - -pub type Cookie = [u8; 8]; - -pub type SinceUnixEpochTimeExtent = TimeExtent; - -pub const COOKIE_LIFETIME: TimeExtent = TimeExtent::from_sec(2, &60); - -/// Converts a connection ID into a connection cookie. -#[must_use] -pub fn from_connection_id(connection_id: &ConnectionId) -> Cookie { - let mut cookie = [0u8; 8]; - connection_id.write_to(&mut cookie); - cookie -} - -/// Converts a connection cookie into a connection ID. -#[must_use] -pub fn into_connection_id(connection_cookie: &Cookie) -> ConnectionId { - ConnectionId(I64::new(i64::from_be_bytes(*connection_cookie))) -} - -/// Generates a new connection cookie. -#[must_use] -pub fn make(remote_address: &SocketAddr) -> Cookie { - let time_extent = cookie_builder::get_last_time_extent(); - - //println!("remote_address: {remote_address:?}, time_extent: {time_extent:?}, cookie: {cookie:?}"); - cookie_builder::build(remote_address, &time_extent) -} - -/// Checks if the supplied `connection_cookie` is valid. -/// -/// # Panics -/// -/// It would panic if the `COOKIE_LIFETIME` constant would be an unreasonably large number. -/// -/// # Errors -/// -/// Will return a `ServerError::InvalidConnectionId` if the supplied `connection_cookie` fails to verify. -pub fn check(remote_address: &SocketAddr, connection_cookie: &Cookie) -> Result { - // we loop backwards testing each time_extent until we find one that matches. - // (or the lifetime of time_extents is exhausted) - for offset in 0..=COOKIE_LIFETIME.amount { - let checking_time_extent = cookie_builder::get_last_time_extent().decrease(offset).unwrap(); - - let checking_cookie = cookie_builder::build(remote_address, &checking_time_extent); - //println!("remote_address: {remote_address:?}, time_extent: {checking_time_extent:?}, cookie: {checking_cookie:?}"); - - if *connection_cookie == checking_cookie { - return Ok(checking_time_extent); - } - } - Err(Error::InvalidConnectionId { - location: Location::caller(), - }) -} - -mod cookie_builder { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - use std::net::SocketAddr; - - use torrust_tracker_clock::time_extent::{Extent, Make, TimeExtent}; - - use super::{Cookie, SinceUnixEpochTimeExtent, COOKIE_LIFETIME}; - use crate::shared::crypto::keys::seeds::{Current, Keeper}; - use crate::DefaultTimeExtentMaker; - - pub(super) fn get_last_time_extent() -> SinceUnixEpochTimeExtent { - DefaultTimeExtentMaker::now(&COOKIE_LIFETIME.increment) - .unwrap() - .unwrap() - .increase(COOKIE_LIFETIME.amount) - .unwrap() - } - - pub(super) fn build(remote_address: &SocketAddr, time_extent: &TimeExtent) -> Cookie { - let seed = Current::get_seed(); - - let mut hasher = DefaultHasher::new(); - - remote_address.hash(&mut hasher); - time_extent.hash(&mut hasher); - seed.hash(&mut hasher); - - hasher.finish().to_le_bytes() - } -} - -#[cfg(test)] -mod tests { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - - use torrust_tracker_clock::clock::stopped::Stopped as _; - use torrust_tracker_clock::clock::{self}; - use torrust_tracker_clock::time_extent::{self, Extent}; - - use super::cookie_builder::{self}; - use crate::servers::udp::connection_cookie::{check, make, Cookie, COOKIE_LIFETIME}; - - // #![feature(const_socketaddr)] - // const REMOTE_ADDRESS_IPV4_ZERO: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - #[test] - fn it_should_make_a_connection_cookie() { - // Note: This constant may need to be updated in the future as the hash - // is not guaranteed to to be stable between versions. - const ID_COOKIE_OLD_HASHER: Cookie = [41, 166, 45, 246, 249, 24, 108, 203]; - const ID_COOKIE_NEW_HASHER: Cookie = [185, 122, 191, 238, 6, 43, 2, 198]; - - clock::Stopped::local_set_to_unix_epoch(); - - let cookie = make(&SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)); - - assert!(cookie == ID_COOKIE_OLD_HASHER || cookie == ID_COOKIE_NEW_HASHER); - } - - #[test] - fn it_should_make_the_same_connection_cookie_for_the_same_input_data() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - let time_extent_zero = time_extent::ZERO; - - let cookie = cookie_builder::build(&remote_address, &time_extent_zero); - let cookie_2 = cookie_builder::build(&remote_address, &time_extent_zero); - - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie:?}"); - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie_2:?}"); - - //remote_address: 127.0.0.1:8080, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [212, 9, 204, 223, 176, 190, 150, 153] - //remote_address: 127.0.0.1:8080, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [212, 9, 204, 223, 176, 190, 150, 153] - - assert_eq!(cookie, cookie_2); - } - - #[test] - fn it_should_make_the_different_connection_cookie_for_different_ip() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - let remote_address_2 = SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), 0); - let time_extent_zero = time_extent::ZERO; - - let cookie = cookie_builder::build(&remote_address, &time_extent_zero); - let cookie_2 = cookie_builder::build(&remote_address_2, &time_extent_zero); - - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie:?}"); - println!("remote_address: {remote_address_2:?}, time_extent: {time_extent_zero:?}, cookie: {cookie_2:?}"); - - //remote_address: 0.0.0.0:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [151, 130, 30, 157, 190, 41, 179, 135] - //remote_address: 255.255.255.255:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [217, 87, 239, 178, 182, 126, 66, 166] - - assert_ne!(cookie, cookie_2); - } - - #[test] - fn it_should_make_the_different_connection_cookie_for_different_ip_version() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - let remote_address_2 = SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0); - let time_extent_zero = time_extent::ZERO; - - let cookie = cookie_builder::build(&remote_address, &time_extent_zero); - let cookie_2 = cookie_builder::build(&remote_address_2, &time_extent_zero); - - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie:?}"); - println!("remote_address: {remote_address_2:?}, time_extent: {time_extent_zero:?}, cookie: {cookie_2:?}"); - - //remote_address: 0.0.0.0:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [151, 130, 30, 157, 190, 41, 179, 135] - //remote_address: [::]:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [99, 119, 230, 177, 20, 220, 163, 187] - - assert_ne!(cookie, cookie_2); - } - - #[test] - fn it_should_make_the_different_connection_cookie_for_different_socket() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - let remote_address_2 = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 1); - let time_extent_zero = time_extent::ZERO; - - let cookie = cookie_builder::build(&remote_address, &time_extent_zero); - let cookie_2 = cookie_builder::build(&remote_address_2, &time_extent_zero); - - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie:?}"); - println!("remote_address: {remote_address_2:?}, time_extent: {time_extent_zero:?}, cookie: {cookie_2:?}"); - - //remote_address: 0.0.0.0:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [151, 130, 30, 157, 190, 41, 179, 135] - //remote_address: 0.0.0.0:1, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [38, 8, 0, 102, 92, 170, 220, 11] - - assert_ne!(cookie, cookie_2); - } - - #[test] - fn it_should_make_the_different_connection_cookie_for_different_time_extents() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - let time_extent_zero = time_extent::ZERO; - let time_extent_max = time_extent::MAX; - - let cookie = cookie_builder::build(&remote_address, &time_extent_zero); - let cookie_2 = cookie_builder::build(&remote_address, &time_extent_max); - - println!("remote_address: {remote_address:?}, time_extent: {time_extent_zero:?}, cookie: {cookie:?}"); - println!("remote_address: {remote_address:?}, time_extent: {time_extent_max:?}, cookie: {cookie_2:?}"); - - //remote_address: 0.0.0.0:0, time_extent: TimeExtent { increment: 0ns, amount: 0 }, cookie: [151, 130, 30, 157, 190, 41, 179, 135] - //remote_address: 0.0.0.0:0, time_extent: TimeExtent { increment: 18446744073709551615.999999999s, amount: 18446744073709551615 }, cookie: [87, 111, 109, 125, 182, 206, 3, 201] - - assert_ne!(cookie, cookie_2); - } - - #[test] - fn it_should_make_different_cookies_for_the_next_time_extent() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - let cookie = make(&remote_address); - - clock::Stopped::local_add(&COOKIE_LIFETIME.increment).unwrap(); - - let cookie_next = make(&remote_address); - - assert_ne!(cookie, cookie_next); - } - - #[test] - fn it_should_be_valid_for_this_time_extent() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - let cookie = make(&remote_address); - - check(&remote_address, &cookie).unwrap(); - } - - #[test] - fn it_should_be_valid_for_the_next_time_extent() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - let cookie = make(&remote_address); - - clock::Stopped::local_add(&COOKIE_LIFETIME.increment).unwrap(); - - check(&remote_address, &cookie).unwrap(); - } - - #[test] - fn it_should_be_valid_for_the_last_time_extent() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - clock::Stopped::local_set_to_unix_epoch(); - - let cookie = make(&remote_address); - - clock::Stopped::local_set(&COOKIE_LIFETIME.total().unwrap().unwrap()); - - check(&remote_address, &cookie).unwrap(); - } - - #[test] - #[should_panic = "InvalidConnectionId"] - fn it_should_be_not_valid_after_their_last_time_extent() { - let remote_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); - - let cookie = make(&remote_address); - - clock::Stopped::local_set(&COOKIE_LIFETIME.total_next().unwrap().unwrap()); - - check(&remote_address, &cookie).unwrap(); - } -} diff --git a/src/servers/udp/error.rs b/src/servers/udp/error.rs deleted file mode 100644 index 315c9d1cf..000000000 --- a/src/servers/udp/error.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Error types for the UDP server. -use std::panic::Location; - -use thiserror::Error; -use torrust_tracker_located_error::LocatedError; - -/// Error returned by the UDP server. -#[derive(Error, Debug)] -pub enum Error { - /// Error returned when the domain tracker returns an error. - #[error("tracker server error: {source}")] - TrackerError { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - - /// Error returned from a third-party library (`aquatic_udp_protocol`). - #[error("internal server error: {message}, {location}")] - InternalServer { - location: &'static Location<'static>, - message: String, - }, - - /// Error returned when the connection id could not be verified. - #[error("connection id could not be verified")] - InvalidConnectionId { location: &'static Location<'static> }, - - /// Error returned when the request is invalid. - #[error("bad request: {source}")] - BadRequest { - source: LocatedError<'static, dyn std::error::Error + Send + Sync>, - }, - - /// Error returned when tracker requires authentication. - #[error("domain tracker requires authentication but is not supported in current UDP implementation. Location: {location}")] - TrackerAuthenticationRequired { location: &'static Location<'static> }, -} diff --git a/src/servers/udp/handlers.rs b/src/servers/udp/handlers.rs deleted file mode 100644 index 69a427e0e..000000000 --- a/src/servers/udp/handlers.rs +++ /dev/null @@ -1,1368 +0,0 @@ -//! Handlers for the UDP server. -use std::fmt; -use std::net::{IpAddr, SocketAddr}; -use std::panic::Location; -use std::sync::Arc; -use std::time::Instant; - -use aquatic_udp_protocol::{ - AnnounceInterval, AnnounceRequest, AnnounceResponse, AnnounceResponseFixedData, ConnectRequest, ConnectResponse, - ErrorResponse, Ipv4AddrBytes, Ipv6AddrBytes, NumberOfDownloads, NumberOfPeers, Port, Request, Response, ResponsePeer, - ScrapeRequest, ScrapeResponse, TorrentScrapeStatistics, TransactionId, -}; -use torrust_tracker_located_error::DynError; -use torrust_tracker_primitives::info_hash::InfoHash; -use tracing::{instrument, Level}; -use uuid::Uuid; -use zerocopy::network_endian::I32; - -use super::connection_cookie::{check, from_connection_id, into_connection_id, make}; -use super::RawRequest; -use crate::core::{statistics, PeersWanted, ScrapeData, Tracker}; -use crate::servers::udp::error::Error; -use crate::servers::udp::logging::{log_bad_request, log_error_response, log_request, log_response}; -use crate::servers::udp::peer_builder; -use crate::shared::bit_torrent::common::MAX_SCRAPE_TORRENTS; - -/// It handles the incoming UDP packets. -/// -/// It's responsible for: -/// -/// - Parsing the incoming packet. -/// - Delegating the request to the correct handler depending on the request type. -/// -/// It will return an `Error` response if the request is invalid. -#[instrument(skip(udp_request, tracker, local_addr), ret(level = Level::TRACE))] -pub(crate) async fn handle_packet(udp_request: RawRequest, tracker: &Tracker, local_addr: SocketAddr) -> Response { - tracing::debug!("Handling Packets: {udp_request:?}"); - - let start_time = Instant::now(); - - let request_id = RequestId::make(&udp_request); - - match Request::parse_bytes(&udp_request.payload[..udp_request.payload.len()], MAX_SCRAPE_TORRENTS).map_err(|e| { - Error::InternalServer { - message: format!("{e:?}"), - location: Location::caller(), - } - }) { - Ok(request) => { - log_request(&request, &request_id, &local_addr); - - let transaction_id = match &request { - Request::Connect(connect_request) => connect_request.transaction_id, - Request::Announce(announce_request) => announce_request.transaction_id, - Request::Scrape(scrape_request) => scrape_request.transaction_id, - }; - - let response = match handle_request(request, udp_request.from, tracker).await { - Ok(response) => response, - Err(e) => handle_error(&e, transaction_id), - }; - - let latency = start_time.elapsed(); - - log_response(&response, &transaction_id, &request_id, &local_addr, latency); - - response - } - Err(e) => { - log_bad_request(&request_id); - - let response = handle_error( - &Error::BadRequest { - source: (Arc::new(e) as DynError).into(), - }, - TransactionId(I32::new(0)), - ); - - log_error_response(&request_id); - - response - } - } -} - -/// It dispatches the request to the correct handler. -/// -/// # Errors -/// -/// If a error happens in the `handle_request` function, it will just return the `ServerError`. -#[instrument(skip(request, remote_addr, tracker))] -pub async fn handle_request(request: Request, remote_addr: SocketAddr, tracker: &Tracker) -> Result { - tracing::trace!("handle request"); - - match request { - Request::Connect(connect_request) => handle_connect(remote_addr, &connect_request, tracker).await, - Request::Announce(announce_request) => handle_announce(remote_addr, &announce_request, tracker).await, - Request::Scrape(scrape_request) => handle_scrape(remote_addr, &scrape_request, tracker).await, - } -} - -/// It handles the `Connect` request. Refer to [`Connect`](crate::servers::udp#connect) -/// request for more information. -/// -/// # Errors -/// -/// This function does not ever return an error. -#[instrument(skip(tracker), err, ret(level = Level::TRACE))] -pub async fn handle_connect(remote_addr: SocketAddr, request: &ConnectRequest, tracker: &Tracker) -> Result { - tracing::trace!("handle connect"); - - let connection_cookie = make(&remote_addr); - let connection_id = into_connection_id(&connection_cookie); - - let response = ConnectResponse { - transaction_id: request.transaction_id, - connection_id, - }; - - // send stats event - match remote_addr { - SocketAddr::V4(_) => { - tracker.send_stats_event(statistics::Event::Udp4Connect).await; - } - SocketAddr::V6(_) => { - tracker.send_stats_event(statistics::Event::Udp6Connect).await; - } - } - - Ok(Response::from(response)) -} - -/// It handles the `Announce` request. Refer to [`Announce`](crate::servers::udp#announce) -/// request for more information. -/// -/// # Errors -/// -/// If a error happens in the `handle_announce` function, it will just return the `ServerError`. -#[instrument(skip(tracker), err, ret(level = Level::TRACE))] -pub async fn handle_announce( - remote_addr: SocketAddr, - announce_request: &AnnounceRequest, - tracker: &Tracker, -) -> Result { - tracing::trace!("handle announce"); - - // Authentication - if tracker.requires_authentication() { - return Err(Error::TrackerAuthenticationRequired { - location: Location::caller(), - }); - } - - check(&remote_addr, &from_connection_id(&announce_request.connection_id))?; - - let info_hash = announce_request.info_hash.into(); - let remote_client_ip = remote_addr.ip(); - - // Authorization - tracker.authorize(&info_hash).await.map_err(|e| Error::TrackerError { - source: (Arc::new(e) as Arc).into(), - })?; - - let mut peer = peer_builder::from_request(announce_request, &remote_client_ip); - let peers_wanted: PeersWanted = i32::from(announce_request.peers_wanted.0).into(); - - let response = tracker.announce(&info_hash, &mut peer, &remote_client_ip, &peers_wanted); - - match remote_client_ip { - IpAddr::V4(_) => { - tracker.send_stats_event(statistics::Event::Udp4Announce).await; - } - IpAddr::V6(_) => { - tracker.send_stats_event(statistics::Event::Udp6Announce).await; - } - } - - #[allow(clippy::cast_possible_truncation)] - if remote_addr.is_ipv4() { - let announce_response = AnnounceResponse { - fixed: AnnounceResponseFixedData { - transaction_id: announce_request.transaction_id, - announce_interval: AnnounceInterval(I32::new(i64::from(tracker.get_announce_policy().interval) as i32)), - leechers: NumberOfPeers(I32::new(i64::from(response.stats.incomplete) as i32)), - seeders: NumberOfPeers(I32::new(i64::from(response.stats.complete) as i32)), - }, - peers: response - .peers - .iter() - .filter_map(|peer| { - if let IpAddr::V4(ip) = peer.peer_addr.ip() { - Some(ResponsePeer:: { - ip_address: ip.into(), - port: Port(peer.peer_addr.port().into()), - }) - } else { - None - } - }) - .collect(), - }; - - Ok(Response::from(announce_response)) - } else { - let announce_response = AnnounceResponse { - fixed: AnnounceResponseFixedData { - transaction_id: announce_request.transaction_id, - announce_interval: AnnounceInterval(I32::new(i64::from(tracker.get_announce_policy().interval) as i32)), - leechers: NumberOfPeers(I32::new(i64::from(response.stats.incomplete) as i32)), - seeders: NumberOfPeers(I32::new(i64::from(response.stats.complete) as i32)), - }, - peers: response - .peers - .iter() - .filter_map(|peer| { - if let IpAddr::V6(ip) = peer.peer_addr.ip() { - Some(ResponsePeer:: { - ip_address: ip.into(), - port: Port(peer.peer_addr.port().into()), - }) - } else { - None - } - }) - .collect(), - }; - - Ok(Response::from(announce_response)) - } -} - -/// It handles the `Scrape` request. Refer to [`Scrape`](crate::servers::udp#scrape) -/// request for more information. -/// -/// # Errors -/// -/// This function does not ever return an error. -#[instrument(skip(tracker), err, ret(level = Level::TRACE))] -pub async fn handle_scrape(remote_addr: SocketAddr, request: &ScrapeRequest, tracker: &Tracker) -> Result { - tracing::trace!("handle scrape"); - - // Convert from aquatic infohashes - let mut info_hashes: Vec = vec![]; - for info_hash in &request.info_hashes { - info_hashes.push((*info_hash).into()); - } - - let scrape_data = if tracker.requires_authentication() { - ScrapeData::zeroed(&info_hashes) - } else { - tracker.scrape(&info_hashes).await - }; - - let mut torrent_stats: Vec = Vec::new(); - - for file in &scrape_data.files { - let swarm_metadata = file.1; - - #[allow(clippy::cast_possible_truncation)] - let scrape_entry = { - TorrentScrapeStatistics { - seeders: NumberOfPeers(I32::new(i64::from(swarm_metadata.complete) as i32)), - completed: NumberOfDownloads(I32::new(i64::from(swarm_metadata.downloaded) as i32)), - leechers: NumberOfPeers(I32::new(i64::from(swarm_metadata.incomplete) as i32)), - } - }; - - torrent_stats.push(scrape_entry); - } - - // send stats event - match remote_addr { - SocketAddr::V4(_) => { - tracker.send_stats_event(statistics::Event::Udp4Scrape).await; - } - SocketAddr::V6(_) => { - tracker.send_stats_event(statistics::Event::Udp6Scrape).await; - } - } - - let response = ScrapeResponse { - transaction_id: request.transaction_id, - torrent_stats, - }; - - Ok(Response::from(response)) -} - -fn handle_error(e: &Error, transaction_id: TransactionId) -> Response { - let message = e.to_string(); - Response::from(ErrorResponse { - transaction_id, - message: message.into(), - }) -} - -/// An identifier for a request. -#[derive(Debug, Clone)] -pub struct RequestId(Uuid); - -impl RequestId { - fn make(_request: &RawRequest) -> RequestId { - RequestId(Uuid::new_v4()) - } -} - -impl fmt::Display for RequestId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[cfg(test)] -mod tests { - - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - use std::sync::Arc; - - use aquatic_udp_protocol::{NumberOfBytes, PeerId}; - use torrust_tracker_clock::clock::Time; - use torrust_tracker_configuration::Configuration; - use torrust_tracker_primitives::peer; - use torrust_tracker_test_helpers::configuration; - - use crate::core::services::tracker_factory; - use crate::core::Tracker; - use crate::CurrentClock; - - fn tracker_configuration() -> Configuration { - default_testing_tracker_configuration() - } - - fn default_testing_tracker_configuration() -> Configuration { - configuration::ephemeral() - } - - fn public_tracker() -> Arc { - initialized_tracker(&configuration::ephemeral_public()) - } - - fn private_tracker() -> Arc { - initialized_tracker(&configuration::ephemeral_private()) - } - - fn whitelisted_tracker() -> Arc { - initialized_tracker(&configuration::ephemeral_listed()) - } - - fn initialized_tracker(configuration: &Configuration) -> Arc { - tracker_factory(configuration).into() - } - - fn sample_ipv4_remote_addr() -> SocketAddr { - sample_ipv4_socket_address() - } - - fn sample_ipv6_remote_addr() -> SocketAddr { - sample_ipv6_socket_address() - } - - fn sample_ipv4_socket_address() -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080) - } - - fn sample_ipv6_socket_address() -> SocketAddr { - SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8080) - } - - #[derive(Debug, Default)] - pub struct TorrentPeerBuilder { - peer: peer::Peer, - } - - impl TorrentPeerBuilder { - #[must_use] - pub fn new() -> Self { - Self { - peer: peer::Peer { - updated: CurrentClock::now(), - ..Default::default() - }, - } - } - - #[must_use] - pub fn with_peer_address(mut self, peer_addr: SocketAddr) -> Self { - self.peer.peer_addr = peer_addr; - self - } - - #[must_use] - pub fn with_peer_id(mut self, peer_id: PeerId) -> Self { - self.peer.peer_id = peer_id; - self - } - - #[must_use] - pub fn with_number_of_bytes_left(mut self, left: i64) -> Self { - self.peer.left = NumberOfBytes::new(left); - self - } - - #[must_use] - pub fn into(self) -> peer::Peer { - self.peer - } - } - - struct TrackerConfigurationBuilder { - configuration: Configuration, - } - - impl TrackerConfigurationBuilder { - pub fn default() -> TrackerConfigurationBuilder { - let default_configuration = default_testing_tracker_configuration(); - TrackerConfigurationBuilder { - configuration: default_configuration, - } - } - - pub fn with_external_ip(mut self, external_ip: &str) -> Self { - self.configuration.core.net.external_ip = Some(external_ip.to_owned().parse().expect("valid IP address")); - self - } - - pub fn into(self) -> Configuration { - self.configuration - } - } - - mod connect_request { - - use std::future; - use std::sync::Arc; - - use aquatic_udp_protocol::{ConnectRequest, ConnectResponse, Response, TransactionId}; - use mockall::predicate::eq; - - use super::{sample_ipv4_socket_address, sample_ipv6_remote_addr, tracker_configuration}; - use crate::core::{self, statistics}; - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::handle_connect; - use crate::servers::udp::handlers::tests::{public_tracker, sample_ipv4_remote_addr}; - - fn sample_connect_request() -> ConnectRequest { - ConnectRequest { - transaction_id: TransactionId(0i32.into()), - } - } - - #[tokio::test] - async fn a_connect_response_should_contain_the_same_transaction_id_as_the_connect_request() { - let request = ConnectRequest { - transaction_id: TransactionId(0i32.into()), - }; - - let response = handle_connect(sample_ipv4_remote_addr(), &request, &public_tracker()) - .await - .unwrap(); - - assert_eq!( - response, - Response::Connect(ConnectResponse { - connection_id: into_connection_id(&make(&sample_ipv4_remote_addr())), - transaction_id: request.transaction_id - }) - ); - } - - #[tokio::test] - async fn a_connect_response_should_contain_a_new_connection_id() { - let request = ConnectRequest { - transaction_id: TransactionId(0i32.into()), - }; - - let response = handle_connect(sample_ipv4_remote_addr(), &request, &public_tracker()) - .await - .unwrap(); - - assert_eq!( - response, - Response::Connect(ConnectResponse { - connection_id: into_connection_id(&make(&sample_ipv4_remote_addr())), - transaction_id: request.transaction_id - }) - ); - } - - #[tokio::test] - async fn it_should_send_the_upd4_connect_event_when_a_client_tries_to_connect_using_a_ip4_socket_address() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp4Connect)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let client_socket_address = sample_ipv4_socket_address(); - - let torrent_tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - handle_connect(client_socket_address, &sample_connect_request(), &torrent_tracker) - .await - .unwrap(); - } - - #[tokio::test] - async fn it_should_send_the_upd6_connect_event_when_a_client_tries_to_connect_using_a_ip6_socket_address() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp6Connect)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let torrent_tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - handle_connect(sample_ipv6_remote_addr(), &sample_connect_request(), &torrent_tracker) - .await - .unwrap(); - } - } - - mod announce_request { - - use std::net::Ipv4Addr; - use std::num::NonZeroU16; - - use aquatic_udp_protocol::{ - AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectionId, NumberOfBytes, NumberOfPeers, - PeerId as AquaticPeerId, PeerKey, Port, TransactionId, - }; - - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::tests::sample_ipv4_remote_addr; - - struct AnnounceRequestBuilder { - request: AnnounceRequest, - } - - impl AnnounceRequestBuilder { - pub fn default() -> AnnounceRequestBuilder { - let client_ip = Ipv4Addr::new(126, 0, 0, 1); - let client_port = 8080; - let info_hash_aquatic = aquatic_udp_protocol::InfoHash([0u8; 20]); - - let default_request = AnnounceRequest { - connection_id: into_connection_id(&make(&sample_ipv4_remote_addr())), - action_placeholder: AnnounceActionPlaceholder::default(), - transaction_id: TransactionId(0i32.into()), - info_hash: info_hash_aquatic, - peer_id: AquaticPeerId([255u8; 20]), - bytes_downloaded: NumberOfBytes(0i64.into()), - bytes_uploaded: NumberOfBytes(0i64.into()), - bytes_left: NumberOfBytes(0i64.into()), - event: AnnounceEvent::Started.into(), - ip_address: client_ip.into(), - key: PeerKey::new(0i32), - peers_wanted: NumberOfPeers::new(1i32), - port: Port::new(NonZeroU16::new(client_port).expect("a non-zero client port")), - }; - AnnounceRequestBuilder { - request: default_request, - } - } - - pub fn with_connection_id(mut self, connection_id: ConnectionId) -> Self { - self.request.connection_id = connection_id; - self - } - - pub fn with_info_hash(mut self, info_hash: aquatic_udp_protocol::InfoHash) -> Self { - self.request.info_hash = info_hash; - self - } - - pub fn with_peer_id(mut self, peer_id: AquaticPeerId) -> Self { - self.request.peer_id = peer_id; - self - } - - pub fn with_ip_address(mut self, ip_address: Ipv4Addr) -> Self { - self.request.ip_address = ip_address.into(); - self - } - - pub fn with_port(mut self, port: u16) -> Self { - self.request.port = Port(port.into()); - self - } - - pub fn into(self) -> AnnounceRequest { - self.request - } - } - - mod using_ipv4 { - - use std::future; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::sync::Arc; - - use aquatic_udp_protocol::{ - AnnounceInterval, AnnounceResponse, InfoHash as AquaticInfoHash, Ipv4AddrBytes, Ipv6AddrBytes, NumberOfPeers, - PeerId as AquaticPeerId, Response, ResponsePeer, - }; - use mockall::predicate::eq; - - use crate::core::{self, statistics}; - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::tests::announce_request::AnnounceRequestBuilder; - use crate::servers::udp::handlers::tests::{ - public_tracker, sample_ipv4_socket_address, tracker_configuration, TorrentPeerBuilder, - }; - use crate::servers::udp::handlers::{handle_announce, AnnounceResponseFixedData}; - - #[tokio::test] - async fn an_announced_peer_should_be_added_to_the_tracker() { - let tracker = public_tracker(); - - let client_ip = Ipv4Addr::new(126, 0, 0, 1); - let client_port = 8080; - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - - let remote_addr = SocketAddr::new(IpAddr::V4(client_ip), client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(client_ip) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - let expected_peer = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(SocketAddr::new(IpAddr::V4(client_ip), client_port)) - .into(); - - assert_eq!(peers[0], Arc::new(expected_peer)); - } - - #[tokio::test] - async fn the_announced_peer_should_not_be_included_in_the_response() { - let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .into(); - - let response = handle_announce(remote_addr, &request, &public_tracker()).await.unwrap(); - - let empty_peer_vector: Vec> = vec![]; - assert_eq!( - response, - Response::from(AnnounceResponse { - fixed: AnnounceResponseFixedData { - transaction_id: request.transaction_id, - announce_interval: AnnounceInterval(120i32.into()), - leechers: NumberOfPeers(0i32.into()), - seeders: NumberOfPeers(1i32.into()), - }, - peers: empty_peer_vector - }) - ); - } - - #[tokio::test] - async fn the_tracker_should_always_use_the_remote_client_ip_but_not_the_port_in_the_udp_request_header_instead_of_the_peer_address_in_the_announce_request( - ) { - // From the BEP 15 (https://www.bittorrent.org/beps/bep_0015.html): - // "Do note that most trackers will only honor the IP address field under limited circumstances." - - let tracker = public_tracker(); - - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - let client_port = 8080; - - let remote_client_ip = Ipv4Addr::new(126, 0, 0, 1); - let remote_client_port = 8081; - let peer_address = Ipv4Addr::new(126, 0, 0, 2); - - let remote_addr = SocketAddr::new(IpAddr::V4(remote_client_ip), remote_client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(peer_address) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V4(remote_client_ip), client_port)); - } - - fn add_a_torrent_peer_using_ipv6(tracker: &Arc) { - let info_hash = AquaticInfoHash([0u8; 20]); - - let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); - let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); - let client_port = 8080; - let peer_id = AquaticPeerId([255u8; 20]); - - let peer_using_ipv6 = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(SocketAddr::new(IpAddr::V6(client_ip_v6), client_port)) - .into(); - - tracker.upsert_peer_and_get_stats(&info_hash.0.into(), &peer_using_ipv6); - } - - async fn announce_a_new_peer_using_ipv4(tracker: Arc) -> Response { - let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap() - } - - #[tokio::test] - async fn when_the_announce_request_comes_from_a_client_using_ipv4_the_response_should_not_include_peers_using_ipv6() { - let tracker = public_tracker(); - - add_a_torrent_peer_using_ipv6(&tracker); - - let response = announce_a_new_peer_using_ipv4(tracker.clone()).await; - - // The response should not contain the peer using IPV6 - let peers: Option>> = match response { - Response::AnnounceIpv6(announce_response) => Some(announce_response.peers), - _ => None, - }; - let no_ipv6_peers = peers.is_none(); - assert!(no_ipv6_peers); - } - - #[tokio::test] - async fn should_send_the_upd4_announce_event() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp4Announce)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - handle_announce( - sample_ipv4_socket_address(), - &AnnounceRequestBuilder::default().into(), - &tracker, - ) - .await - .unwrap(); - } - - mod from_a_loopback_ip { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::sync::Arc; - - use aquatic_udp_protocol::{InfoHash as AquaticInfoHash, PeerId as AquaticPeerId}; - - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::handle_announce; - use crate::servers::udp::handlers::tests::announce_request::AnnounceRequestBuilder; - use crate::servers::udp::handlers::tests::{public_tracker, TorrentPeerBuilder}; - - #[tokio::test] - async fn the_peer_ip_should_be_changed_to_the_external_ip_in_the_tracker_configuration_if_defined() { - let tracker = public_tracker(); - - let client_ip = Ipv4Addr::new(127, 0, 0, 1); - let client_port = 8080; - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - - let remote_addr = SocketAddr::new(IpAddr::V4(client_ip), client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(client_ip) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - let external_ip_in_tracker_configuration = tracker.get_maybe_external_ip().unwrap(); - - let expected_peer = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(SocketAddr::new(external_ip_in_tracker_configuration, client_port)) - .into(); - - assert_eq!(peers[0], Arc::new(expected_peer)); - } - } - } - - mod using_ipv6 { - - use std::future; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - use std::sync::Arc; - - use aquatic_udp_protocol::{ - AnnounceInterval, AnnounceResponse, InfoHash as AquaticInfoHash, Ipv4AddrBytes, Ipv6AddrBytes, NumberOfPeers, - PeerId as AquaticPeerId, Response, ResponsePeer, - }; - use mockall::predicate::eq; - - use crate::core::{self, statistics}; - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::tests::announce_request::AnnounceRequestBuilder; - use crate::servers::udp::handlers::tests::{ - public_tracker, sample_ipv6_remote_addr, tracker_configuration, TorrentPeerBuilder, - }; - use crate::servers::udp::handlers::{handle_announce, AnnounceResponseFixedData}; - - #[tokio::test] - async fn an_announced_peer_should_be_added_to_the_tracker() { - let tracker = public_tracker(); - - let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); - let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); - let client_port = 8080; - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - - let remote_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(client_ip_v4) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - let expected_peer = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(SocketAddr::new(IpAddr::V6(client_ip_v6), client_port)) - .into(); - - assert_eq!(peers[0], Arc::new(expected_peer)); - } - - #[tokio::test] - async fn the_announced_peer_should_not_be_included_in_the_response() { - let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); - let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); - - let remote_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), 8080); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .into(); - - let response = handle_announce(remote_addr, &request, &public_tracker()).await.unwrap(); - - let empty_peer_vector: Vec> = vec![]; - assert_eq!( - response, - Response::from(AnnounceResponse { - fixed: AnnounceResponseFixedData { - transaction_id: request.transaction_id, - announce_interval: AnnounceInterval(120i32.into()), - leechers: NumberOfPeers(0i32.into()), - seeders: NumberOfPeers(1i32.into()), - }, - peers: empty_peer_vector - }) - ); - } - - #[tokio::test] - async fn the_tracker_should_always_use_the_remote_client_ip_but_not_the_port_in_the_udp_request_header_instead_of_the_peer_address_in_the_announce_request( - ) { - // From the BEP 15 (https://www.bittorrent.org/beps/bep_0015.html): - // "Do note that most trackers will only honor the IP address field under limited circumstances." - - let tracker = public_tracker(); - - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - let client_port = 8080; - - let remote_client_ip = "::100".parse().unwrap(); // IPV4 ::0.0.1.0 -> IPV6 = ::100 = ::ffff:0:100 = 0:0:0:0:0:ffff:0:0100 - let remote_client_port = 8081; - let peer_address = "126.0.0.1".parse().unwrap(); - - let remote_addr = SocketAddr::new(IpAddr::V6(remote_client_ip), remote_client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(peer_address) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - // When using IPv6 the tracker converts the remote client ip into a IPv4 address - assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V6(remote_client_ip), client_port)); - } - - fn add_a_torrent_peer_using_ipv4(tracker: &Arc) { - let info_hash = AquaticInfoHash([0u8; 20]); - - let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); - let client_port = 8080; - let peer_id = AquaticPeerId([255u8; 20]); - - let peer_using_ipv4 = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(SocketAddr::new(IpAddr::V4(client_ip_v4), client_port)) - .into(); - - tracker.upsert_peer_and_get_stats(&info_hash.0.into(), &peer_using_ipv4); - } - - async fn announce_a_new_peer_using_ipv6(tracker: Arc) -> Response { - let client_ip_v4 = Ipv4Addr::new(126, 0, 0, 1); - let client_ip_v6 = client_ip_v4.to_ipv6_compatible(); - let client_port = 8080; - let remote_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap() - } - - #[tokio::test] - async fn when_the_announce_request_comes_from_a_client_using_ipv6_the_response_should_not_include_peers_using_ipv4() { - let tracker = public_tracker(); - - add_a_torrent_peer_using_ipv4(&tracker); - - let response = announce_a_new_peer_using_ipv6(tracker.clone()).await; - - // The response should not contain the peer using IPV4 - let peers: Option>> = match response { - Response::AnnounceIpv4(announce_response) => Some(announce_response.peers), - _ => None, - }; - let no_ipv4_peers = peers.is_none(); - assert!(no_ipv4_peers); - } - - #[tokio::test] - async fn should_send_the_upd6_announce_event() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp6Announce)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - let remote_addr = sample_ipv6_remote_addr(); - - let announce_request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .into(); - - handle_announce(remote_addr, &announce_request, &tracker).await.unwrap(); - } - - mod from_a_loopback_ip { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - use std::sync::Arc; - - use aquatic_udp_protocol::{InfoHash as AquaticInfoHash, PeerId as AquaticPeerId}; - - use crate::core; - use crate::core::statistics::Keeper; - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::handle_announce; - use crate::servers::udp::handlers::tests::announce_request::AnnounceRequestBuilder; - use crate::servers::udp::handlers::tests::TrackerConfigurationBuilder; - - #[tokio::test] - async fn the_peer_ip_should_be_changed_to_the_external_ip_in_the_tracker_configuration() { - let configuration = Arc::new(TrackerConfigurationBuilder::default().with_external_ip("::126.0.0.1").into()); - let (stats_event_sender, stats_repository) = Keeper::new_active_instance(); - let tracker = - Arc::new(core::Tracker::new(&configuration.core, Some(stats_event_sender), stats_repository).unwrap()); - - let loopback_ipv4 = Ipv4Addr::new(127, 0, 0, 1); - let loopback_ipv6 = Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1); - - let client_ip_v4 = loopback_ipv4; - let client_ip_v6 = loopback_ipv6; - let client_port = 8080; - - let info_hash = AquaticInfoHash([0u8; 20]); - let peer_id = AquaticPeerId([255u8; 20]); - - let remote_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); - - let request = AnnounceRequestBuilder::default() - .with_connection_id(into_connection_id(&make(&remote_addr))) - .with_info_hash(info_hash) - .with_peer_id(peer_id) - .with_ip_address(client_ip_v4) - .with_port(client_port) - .into(); - - handle_announce(remote_addr, &request, &tracker).await.unwrap(); - - let peers = tracker.get_torrent_peers(&info_hash.0.into()); - - let external_ip_in_tracker_configuration = tracker.get_maybe_external_ip().unwrap(); - - assert!(external_ip_in_tracker_configuration.is_ipv6()); - - // There's a special type of IPv6 addresses that provide compatibility with IPv4. - // The last 32 bits of these addresses represent an IPv4, and are represented like this: - // 1111:2222:3333:4444:5555:6666:1.2.3.4 - // - // ::127.0.0.1 is the IPV6 representation for the IPV4 address 127.0.0.1. - assert_eq!(Ok(peers[0].peer_addr.ip()), "::126.0.0.1".parse()); - } - } - } - } - - mod scrape_request { - use std::net::SocketAddr; - use std::sync::Arc; - - use aquatic_udp_protocol::{ - InfoHash, NumberOfDownloads, NumberOfPeers, PeerId, Response, ScrapeRequest, ScrapeResponse, TorrentScrapeStatistics, - TransactionId, - }; - - use super::TorrentPeerBuilder; - use crate::core::{self}; - use crate::servers::udp::connection_cookie::{into_connection_id, make}; - use crate::servers::udp::handlers::handle_scrape; - use crate::servers::udp::handlers::tests::{public_tracker, sample_ipv4_remote_addr}; - - fn zeroed_torrent_statistics() -> TorrentScrapeStatistics { - TorrentScrapeStatistics { - seeders: NumberOfPeers(0.into()), - completed: NumberOfDownloads(0.into()), - leechers: NumberOfPeers(0.into()), - } - } - - #[tokio::test] - async fn should_return_no_stats_when_the_tracker_does_not_have_any_torrent() { - let remote_addr = sample_ipv4_remote_addr(); - - let info_hash = InfoHash([0u8; 20]); - let info_hashes = vec![info_hash]; - - let request = ScrapeRequest { - connection_id: into_connection_id(&make(&remote_addr)), - transaction_id: TransactionId(0i32.into()), - info_hashes, - }; - - let response = handle_scrape(remote_addr, &request, &public_tracker()).await.unwrap(); - - let expected_torrent_stats = vec![zeroed_torrent_statistics()]; - - assert_eq!( - response, - Response::from(ScrapeResponse { - transaction_id: request.transaction_id, - torrent_stats: expected_torrent_stats - }) - ); - } - - async fn add_a_seeder(tracker: Arc, remote_addr: &SocketAddr, info_hash: &InfoHash) { - let peer_id = PeerId([255u8; 20]); - - let peer = TorrentPeerBuilder::new() - .with_peer_id(peer_id) - .with_peer_address(*remote_addr) - .with_number_of_bytes_left(0) - .into(); - - tracker.upsert_peer_and_get_stats(&info_hash.0.into(), &peer); - } - - fn build_scrape_request(remote_addr: &SocketAddr, info_hash: &InfoHash) -> ScrapeRequest { - let info_hashes = vec![*info_hash]; - - ScrapeRequest { - connection_id: into_connection_id(&make(remote_addr)), - transaction_id: TransactionId::new(0i32), - info_hashes, - } - } - - async fn add_a_sample_seeder_and_scrape(tracker: Arc) -> Response { - let remote_addr = sample_ipv4_remote_addr(); - let info_hash = InfoHash([0u8; 20]); - - add_a_seeder(tracker.clone(), &remote_addr, &info_hash).await; - - let request = build_scrape_request(&remote_addr, &info_hash); - - handle_scrape(remote_addr, &request, &tracker).await.unwrap() - } - - fn match_scrape_response(response: Response) -> Option { - match response { - Response::Scrape(scrape_response) => Some(scrape_response), - _ => None, - } - } - - mod with_a_public_tracker { - use aquatic_udp_protocol::{NumberOfDownloads, NumberOfPeers, TorrentScrapeStatistics}; - - use crate::servers::udp::handlers::tests::public_tracker; - use crate::servers::udp::handlers::tests::scrape_request::{add_a_sample_seeder_and_scrape, match_scrape_response}; - - #[tokio::test] - async fn should_return_torrent_statistics_when_the_tracker_has_the_requested_torrent() { - let tracker = public_tracker(); - - let torrent_stats = match_scrape_response(add_a_sample_seeder_and_scrape(tracker.clone()).await); - - let expected_torrent_stats = vec![TorrentScrapeStatistics { - seeders: NumberOfPeers(1.into()), - completed: NumberOfDownloads(0.into()), - leechers: NumberOfPeers(0.into()), - }]; - - assert_eq!(torrent_stats.unwrap().torrent_stats, expected_torrent_stats); - } - } - - mod with_a_private_tracker { - - use aquatic_udp_protocol::InfoHash; - - use crate::servers::udp::handlers::handle_scrape; - use crate::servers::udp::handlers::tests::scrape_request::{ - add_a_sample_seeder_and_scrape, build_scrape_request, match_scrape_response, zeroed_torrent_statistics, - }; - use crate::servers::udp::handlers::tests::{private_tracker, sample_ipv4_remote_addr}; - - #[tokio::test] - async fn should_return_zeroed_statistics_when_the_tracker_does_not_have_the_requested_torrent() { - let tracker = private_tracker(); - - let remote_addr = sample_ipv4_remote_addr(); - let non_existing_info_hash = InfoHash([0u8; 20]); - - let request = build_scrape_request(&remote_addr, &non_existing_info_hash); - - let torrent_stats = match_scrape_response(handle_scrape(remote_addr, &request, &tracker).await.unwrap()).unwrap(); - - let expected_torrent_stats = vec![zeroed_torrent_statistics()]; - - assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); - } - - #[tokio::test] - async fn should_return_zeroed_statistics_when_the_tracker_has_the_requested_torrent_because_authenticated_requests_are_not_supported_in_udp_tracker( - ) { - let tracker = private_tracker(); - - let torrent_stats = match_scrape_response(add_a_sample_seeder_and_scrape(tracker.clone()).await).unwrap(); - - let expected_torrent_stats = vec![zeroed_torrent_statistics()]; - - assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); - } - } - - mod with_a_whitelisted_tracker { - use aquatic_udp_protocol::{InfoHash, NumberOfDownloads, NumberOfPeers, TorrentScrapeStatistics}; - - use crate::servers::udp::handlers::handle_scrape; - use crate::servers::udp::handlers::tests::scrape_request::{ - add_a_seeder, build_scrape_request, match_scrape_response, zeroed_torrent_statistics, - }; - use crate::servers::udp::handlers::tests::{sample_ipv4_remote_addr, whitelisted_tracker}; - - #[tokio::test] - async fn should_return_the_torrent_statistics_when_the_requested_torrent_is_whitelisted() { - let tracker = whitelisted_tracker(); - - let remote_addr = sample_ipv4_remote_addr(); - let info_hash = InfoHash([0u8; 20]); - - add_a_seeder(tracker.clone(), &remote_addr, &info_hash).await; - - tracker.add_torrent_to_memory_whitelist(&info_hash.0.into()).await; - - let request = build_scrape_request(&remote_addr, &info_hash); - - let torrent_stats = match_scrape_response(handle_scrape(remote_addr, &request, &tracker).await.unwrap()).unwrap(); - - let expected_torrent_stats = vec![TorrentScrapeStatistics { - seeders: NumberOfPeers(1.into()), - completed: NumberOfDownloads(0.into()), - leechers: NumberOfPeers(0.into()), - }]; - - assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); - } - - #[tokio::test] - async fn should_return_zeroed_statistics_when_the_requested_torrent_is_not_whitelisted() { - let tracker = whitelisted_tracker(); - - let remote_addr = sample_ipv4_remote_addr(); - let info_hash = InfoHash([0u8; 20]); - - add_a_seeder(tracker.clone(), &remote_addr, &info_hash).await; - - let request = build_scrape_request(&remote_addr, &info_hash); - - let torrent_stats = match_scrape_response(handle_scrape(remote_addr, &request, &tracker).await.unwrap()).unwrap(); - - let expected_torrent_stats = vec![zeroed_torrent_statistics()]; - - assert_eq!(torrent_stats.torrent_stats, expected_torrent_stats); - } - } - - fn sample_scrape_request(remote_addr: &SocketAddr) -> ScrapeRequest { - let info_hash = InfoHash([0u8; 20]); - let info_hashes = vec![info_hash]; - - ScrapeRequest { - connection_id: into_connection_id(&make(remote_addr)), - transaction_id: TransactionId(0i32.into()), - info_hashes, - } - } - - mod using_ipv4 { - use std::future; - use std::sync::Arc; - - use mockall::predicate::eq; - - use super::sample_scrape_request; - use crate::core::{self, statistics}; - use crate::servers::udp::handlers::handle_scrape; - use crate::servers::udp::handlers::tests::{sample_ipv4_remote_addr, tracker_configuration}; - - #[tokio::test] - async fn should_send_the_upd4_scrape_event() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp4Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let remote_addr = sample_ipv4_remote_addr(); - let tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - handle_scrape(remote_addr, &sample_scrape_request(&remote_addr), &tracker) - .await - .unwrap(); - } - } - - mod using_ipv6 { - use std::future; - use std::sync::Arc; - - use mockall::predicate::eq; - - use super::sample_scrape_request; - use crate::core::{self, statistics}; - use crate::servers::udp::handlers::handle_scrape; - use crate::servers::udp::handlers::tests::{sample_ipv6_remote_addr, tracker_configuration}; - - #[tokio::test] - async fn should_send_the_upd6_scrape_event() { - let mut stats_event_sender_mock = statistics::MockEventSender::new(); - stats_event_sender_mock - .expect_send_event() - .with(eq(statistics::Event::Udp6Scrape)) - .times(1) - .returning(|_| Box::pin(future::ready(Some(Ok(()))))); - let stats_event_sender = Box::new(stats_event_sender_mock); - - let remote_addr = sample_ipv6_remote_addr(); - let tracker = Arc::new( - core::Tracker::new( - &tracker_configuration().core, - Some(stats_event_sender), - statistics::Repo::new(), - ) - .unwrap(), - ); - - handle_scrape(remote_addr, &sample_scrape_request(&remote_addr), &tracker) - .await - .unwrap(); - } - } - } -} diff --git a/src/servers/udp/logging.rs b/src/servers/udp/logging.rs deleted file mode 100644 index 3891278d7..000000000 --- a/src/servers/udp/logging.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Logging for UDP Tracker requests and responses. - -use std::net::SocketAddr; -use std::time::Duration; - -use aquatic_udp_protocol::{Request, Response, TransactionId}; -use torrust_tracker_primitives::info_hash::InfoHash; - -use super::handlers::RequestId; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; - -pub fn log_request(request: &Request, request_id: &RequestId, server_socket_addr: &SocketAddr) { - let action = map_action_name(request); - - match &request { - Request::Connect(connect_request) => { - let transaction_id = connect_request.transaction_id; - let transaction_id_str = transaction_id.0.to_string(); - - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "request", server_socket_addr = %server_socket_addr, action = %action, transaction_id = %transaction_id_str, request_id = %request_id); - } - Request::Announce(announce_request) => { - let transaction_id = announce_request.transaction_id; - let transaction_id_str = transaction_id.0.to_string(); - let connection_id_str = announce_request.connection_id.0.to_string(); - let info_hash_str = InfoHash::from_bytes(&announce_request.info_hash.0).to_hex_string(); - - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "request", server_socket_addr = %server_socket_addr, action = %action, transaction_id = %transaction_id_str, request_id = %request_id, connection_id = %connection_id_str, info_hash = %info_hash_str); - } - Request::Scrape(scrape_request) => { - let transaction_id = scrape_request.transaction_id; - let transaction_id_str = transaction_id.0.to_string(); - let connection_id_str = scrape_request.connection_id.0.to_string(); - - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, - "request", - server_socket_addr = %server_socket_addr, - action = %action, - transaction_id = %transaction_id_str, - request_id = %request_id, - connection_id = %connection_id_str); - } - }; -} - -fn map_action_name(udp_request: &Request) -> String { - match udp_request { - Request::Connect(_connect_request) => "CONNECT".to_owned(), - Request::Announce(_announce_request) => "ANNOUNCE".to_owned(), - Request::Scrape(_scrape_request) => "SCRAPE".to_owned(), - } -} - -pub fn log_response( - _response: &Response, - transaction_id: &TransactionId, - request_id: &RequestId, - server_socket_addr: &SocketAddr, - latency: Duration, -) { - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, - "response", - server_socket_addr = %server_socket_addr, - transaction_id = %transaction_id.0.to_string(), - request_id = %request_id, - latency_ms = %latency.as_millis()); -} - -pub fn log_bad_request(request_id: &RequestId) { - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "bad request", request_id = %request_id); -} - -pub fn log_error_response(request_id: &RequestId) { - tracing::span!( - target: UDP_TRACKER_LOG_TARGET, - tracing::Level::INFO, "response", request_id = %request_id); -} diff --git a/src/servers/udp/server/launcher.rs b/src/servers/udp/server/launcher.rs deleted file mode 100644 index c9ad213f6..000000000 --- a/src/servers/udp/server/launcher.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use derive_more::Constructor; -use futures_util::StreamExt; -use tokio::select; -use tokio::sync::oneshot; -use tracing::instrument; - -use super::request_buffer::ActiveRequests; -use crate::bootstrap::jobs::Started; -use crate::core::Tracker; -use crate::servers::logging::STARTED_ON; -use crate::servers::registar::ServiceHealthCheckJob; -use crate::servers::signals::{shutdown_signal_with_message, Halted}; -use crate::servers::udp::server::bound_socket::BoundSocket; -use crate::servers::udp::server::processor::Processor; -use crate::servers::udp::server::receiver::Receiver; -use crate::servers::udp::UDP_TRACKER_LOG_TARGET; -use crate::shared::bit_torrent::tracker::udp::client::check; - -/// A UDP server instance launcher. -#[derive(Constructor)] -pub struct Launcher; - -impl Launcher { - /// It starts the UDP server instance with graceful shutdown. - /// - /// # Panics - /// - /// It panics if unable to bind to udp socket, and get the address from the udp socket. - /// It also panics if unable to send address of socket. - #[instrument(skip(tracker, bind_to, tx_start, rx_halt))] - pub async fn run_with_graceful_shutdown( - tracker: Arc, - bind_to: SocketAddr, - tx_start: oneshot::Sender, - rx_halt: oneshot::Receiver, - ) { - tracing::info!(target: UDP_TRACKER_LOG_TARGET, "Starting on: {bind_to}"); - - let socket = tokio::time::timeout(Duration::from_millis(5000), BoundSocket::new(bind_to)) - .await - .expect("it should bind to the socket within five seconds"); - - let bound_socket = match socket { - Ok(socket) => socket, - Err(e) => { - tracing::error!(target: UDP_TRACKER_LOG_TARGET, addr = %bind_to, err = %e, "Udp::run_with_graceful_shutdown panic! (error when building socket)" ); - panic!("could not bind to socket!"); - } - }; - - let address = bound_socket.address(); - let local_udp_url = bound_socket.url().to_string(); - - tracing::info!(target: UDP_TRACKER_LOG_TARGET, "{STARTED_ON}: {local_udp_url}"); - - let receiver = Receiver::new(bound_socket.into()); - - tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (spawning main loop)"); - - let running = { - let local_addr = local_udp_url.clone(); - tokio::task::spawn(async move { - tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_with_graceful_shutdown::task (listening...)"); - let () = Self::run_udp_server_main(receiver, tracker.clone()).await; - }) - }; - - tx_start - .send(Started { address }) - .expect("the UDP Tracker service should not be dropped"); - - tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (started)"); - - let stop = running.abort_handle(); - - let halt_task = tokio::task::spawn(shutdown_signal_with_message( - rx_halt, - format!("Halting UDP Service Bound to Socket: {address}"), - )); - - select! { - _ = running => { tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (stopped)"); }, - _ = halt_task => { tracing::debug!(target: UDP_TRACKER_LOG_TARGET, local_udp_url, "Udp::run_with_graceful_shutdown (halting)"); } - } - stop.abort(); - - tokio::task::yield_now().await; // lets allow the other threads to complete. - } - - #[must_use] - #[instrument(skip(binding))] - pub fn check(binding: &SocketAddr) -> ServiceHealthCheckJob { - let binding = *binding; - let info = format!("checking the udp tracker health check at: {binding}"); - - let job = tokio::spawn(async move { check(&binding).await }); - - ServiceHealthCheckJob::new(binding, info, job) - } - - #[instrument(skip(receiver, tracker))] - async fn run_udp_server_main(mut receiver: Receiver, tracker: Arc) { - let active_requests = &mut ActiveRequests::default(); - - let addr = receiver.bound_socket_address(); - let local_addr = format!("udp://{addr}"); - - loop { - let processor = Processor::new(receiver.socket.clone(), tracker.clone()); - - if let Some(req) = { - tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server (wait for request)"); - receiver.next().await - } { - tracing::trace!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server::loop (in)"); - - let req = match req { - Ok(req) => req, - Err(e) => { - if e.kind() == std::io::ErrorKind::Interrupted { - tracing::warn!(target: UDP_TRACKER_LOG_TARGET, local_addr, err = %e, "Udp::run_udp_server::loop (interrupted)"); - return; - } - tracing::error!(target: UDP_TRACKER_LOG_TARGET, local_addr, err = %e, "Udp::run_udp_server::loop break: (got error)"); - break; - } - }; - - // We spawn the new task even if there active requests buffer is - // full. This could seem counterintuitive because we are accepting - // more request and consuming more memory even if the server is - // already busy. However, we "force_push" the new tasks in the - // buffer. That means, in the worst scenario we will abort a - // running task to make place for the new task. - // - // Once concern could be to reach an starvation point were we - // are only adding and removing tasks without given them the - // chance to finish. However, the buffer is yielding before - // aborting one tasks, giving it the chance to finish. - let abort_handle: tokio::task::AbortHandle = tokio::task::spawn(processor.process_request(req)).abort_handle(); - - if abort_handle.is_finished() { - continue; - } - - active_requests.force_push(abort_handle, &local_addr).await; - } else { - tokio::task::yield_now().await; - - // the request iterator returned `None`. - tracing::error!(target: UDP_TRACKER_LOG_TARGET, local_addr, "Udp::run_udp_server breaking: (ran dry, should not happen in production!)"); - break; - } - } - } -} diff --git a/src/servers/udp/server/processor.rs b/src/servers/udp/server/processor.rs deleted file mode 100644 index 9fa28a44d..000000000 --- a/src/servers/udp/server/processor.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::io::Cursor; -use std::net::SocketAddr; -use std::sync::Arc; - -use aquatic_udp_protocol::Response; -use tracing::{instrument, Level}; - -use super::bound_socket::BoundSocket; -use crate::core::Tracker; -use crate::servers::udp::{handlers, RawRequest}; - -pub struct Processor { - socket: Arc, - tracker: Arc, -} - -impl Processor { - pub fn new(socket: Arc, tracker: Arc) -> Self { - Self { socket, tracker } - } - - #[instrument(skip(self, request))] - pub async fn process_request(self, request: RawRequest) { - let from = request.from; - let response = handlers::handle_packet(request, &self.tracker, self.socket.address()).await; - self.send_response(from, response).await; - } - - #[instrument(skip(self))] - async fn send_response(self, target: SocketAddr, response: Response) { - tracing::debug!("send response"); - - let response_type = match &response { - Response::Connect(_) => "Connect".to_string(), - Response::AnnounceIpv4(_) => "AnnounceIpv4".to_string(), - Response::AnnounceIpv6(_) => "AnnounceIpv6".to_string(), - Response::Scrape(_) => "Scrape".to_string(), - Response::Error(e) => format!("Error: {e:?}"), - }; - - let mut writer = Cursor::new(Vec::with_capacity(200)); - - match response.write_bytes(&mut writer) { - Ok(()) => { - let bytes_count = writer.get_ref().len(); - let payload = writer.get_ref(); - - let () = match self.send_packet(&target, payload).await { - Ok(sent_bytes) => { - if tracing::event_enabled!(Level::TRACE) { - tracing::debug!(%bytes_count, %sent_bytes, ?payload, "sent {response_type}"); - } else { - tracing::debug!(%bytes_count, %sent_bytes, "sent {response_type}"); - } - } - Err(error) => tracing::warn!(%bytes_count, %error, ?payload, "failed to send"), - }; - } - Err(e) => { - tracing::error!(%e, "error"); - } - } - } - - #[instrument(skip(self))] - async fn send_packet(&self, target: &SocketAddr, payload: &[u8]) -> std::io::Result { - tracing::trace!("send packet"); - - // doesn't matter if it reaches or not - self.socket.send_to(payload, target).await - } -} diff --git a/src/shared/bit_torrent/common.rs b/src/shared/bit_torrent/common.rs deleted file mode 100644 index 46026ac47..000000000 --- a/src/shared/bit_torrent/common.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `BitTorrent` protocol primitive types -//! -//! [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) - -/// The maximum number of torrents that can be returned in an `scrape` response. -/// -/// The [BEP 15. UDP Tracker Protocol for `BitTorrent`](https://www.bittorrent.org/beps/bep_0015.html) -/// defines this limit: -/// -/// "Up to about 74 torrents can be scraped at once. A full scrape can't be done -/// with this protocol." -/// -/// The [BEP 48. Tracker Protocol Extension: Scrape](https://www.bittorrent.org/beps/bep_0048.html) -/// does not specifically mention this limit, but the limit is being used for -/// both the UDP and HTTP trackers since it's applied at the domain level. -pub const MAX_SCRAPE_TORRENTS: u8 = 74; - -/// HTTP tracker authentication key length. -/// -/// For more information see function [`generate_key`](crate::core::auth::generate_key) to generate the -/// [`PeerKey`](crate::core::auth::PeerKey). -pub const AUTH_KEY_LENGTH: usize = 32; diff --git a/src/shared/bit_torrent/info_hash.rs b/src/shared/bit_torrent/info_hash.rs deleted file mode 100644 index 506c37758..000000000 --- a/src/shared/bit_torrent/info_hash.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! A `BitTorrent` `InfoHash`. It's a unique identifier for a `BitTorrent` torrent. -//! -//! "The 20-byte sha1 hash of the bencoded form of the info value -//! from the metainfo file." -//! -//! See [BEP 3. The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) -//! for the official specification. -//! -//! This modules provides a type that can be used to represent infohashes. -//! -//! > **NOTICE**: It only supports Info Hash v1. -//! -//! Typically infohashes are represented as hex strings, but internally they are -//! a 20-byte array. -//! -//! # Calculating the info-hash of a torrent file -//! -//! A sample torrent: -//! -//! - Torrent file: `mandelbrot_2048x2048_infohash_v1.png.torrent` -//! - File: `mandelbrot_2048x2048.png` -//! - Info Hash v1: `5452869be36f9f3350ccee6b4544e7e76caaadab` -//! - Sha1 hash of the info dictionary: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` -//! -//! A torrent file is a binary file encoded with [Bencode encoding](https://en.wikipedia.org/wiki/Bencode): -//! -//! ```text -//! 0000000: 6431 303a 6372 6561 7465 6420 6279 3138 d10:created by18 -//! 0000010: 3a71 4269 7474 6f72 7265 6e74 2076 342e :qBittorrent v4. -//! 0000020: 342e 3131 333a 6372 6561 7469 6f6e 2064 4.113:creation d -//! 0000030: 6174 6569 3136 3739 3637 3436 3238 6534 atei1679674628e4 -//! 0000040: 3a69 6e66 6f64 363a 6c65 6e67 7468 6931 :infod6:lengthi1 -//! 0000050: 3732 3230 3465 343a 6e61 6d65 3234 3a6d 72204e4:name24:m -//! 0000060: 616e 6465 6c62 726f 745f 3230 3438 7832 andelbrot_2048x2 -//! 0000070: 3034 382e 706e 6731 323a 7069 6563 6520 048.png12:piece -//! 0000080: 6c65 6e67 7468 6931 3633 3834 6536 3a70 lengthi16384e6:p -//! 0000090: 6965 6365 7332 3230 3a7d 9171 0d9d 4dba ieces220:}.q..M. -//! 00000a0: 889b 5420 54d5 2672 8d5a 863f e121 df77 ..T T.&r.Z.?.!.w -//! 00000b0: c7f7 bb6c 7796 2166 2538 c5d9 cdab 8b08 ...lw.!f%8...... -//! 00000c0: ef8c 249b b2f5 c4cd 2adf 0bc0 0cf0 addf ..$.....*....... -//! 00000d0: 7290 e5b6 414c 236c 479b 8e9f 46aa 0c0d r...AL#lG...F... -//! 00000e0: 8ed1 97ff ee68 8b5f 34a3 87d7 71c5 a6f9 .....h._4...q... -//! 00000f0: 8e2e a631 7cbd f0f9 e223 f9cc 80af 5400 ...1|....#....T. -//! 0000100: 04f9 8569 1c77 89c1 764e d6aa bf61 a6c2 ...i.w..vN...a.. -//! 0000110: 8099 abb6 5f60 2f40 a825 be32 a33d 9d07 ...._`/@.%.2.=.. -//! 0000120: 0c79 6898 d49d 6349 af20 5866 266f 986b .yh...cI. Xf&o.k -//! 0000130: 6d32 34cd 7d08 155e 1ad0 0009 57ab 303b m24.}..^....W.0; -//! 0000140: 2060 c1dc 1287 d6f3 e745 4f70 6709 3631 `.......EOpg.61 -//! 0000150: 55f2 20f6 6ca5 156f 2c89 9569 1653 817d U. .l..o,..i.S.} -//! 0000160: 31f1 b6bd 3742 cc11 0bb2 fc2b 49a5 85b6 1...7B.....+I... -//! 0000170: fc76 7444 9365 65 .vtD.ee -//! ``` -//! -//! You can generate that output with the command: -//! -//! ```text -//! xxd mandelbrot_2048x2048_infohash_v1.png.torrent -//! ``` -//! -//! And you can show only the bytes (hexadecimal): -//! -//! ```text -//! 6431303a6372656174656420627931383a71426974746f7272656e742076 -//! 342e342e3131333a6372656174696f6e2064617465693136373936373436 -//! 323865343a696e666f64363a6c656e6774686931373232303465343a6e61 -//! 6d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 -//! 323a7069656365206c656e67746869313633383465363a70696563657332 -//! 32303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c -//! 779621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 -//! e5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 -//! 8e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 -//! a6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 -//! 266f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 -//! 4f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 -//! 0bb2fc2b49a585b6fc767444936565 -//! ``` -//! -//! You can generate that output with the command: -//! -//! ```text -//! `xxd -ps mandelbrot_2048x2048_infohash_v1.png.torrent`. -//! ``` -//! -//! The same data can be represented in a JSON format: -//! -//! ```json -//! { -//! "created by": "qBittorrent v4.4.1", -//! "creation date": 1679674628, -//! "info": { -//! "length": 172204, -//! "name": "mandelbrot_2048x2048.png", -//! "piece length": 16384, -//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" -//! } -//! } -//! ``` -//! -//! The JSON object was generated with: -//! -//! As you can see, there is a `info` attribute: -//! -//! ```json -//! { -//! "length": 172204, -//! "name": "mandelbrot_2048x2048.png", -//! "piece length": 16384, -//! "pieces": "7D 91 71 0D 9D 4D BA 88 9B 54 20 54 D5 26 72 8D 5A 86 3F E1 21 DF 77 C7 F7 BB 6C 77 96 21 66 25 38 C5 D9 CD AB 8B 08 EF 8C 24 9B B2 F5 C4 CD 2A DF 0B C0 0C F0 AD DF 72 90 E5 B6 41 4C 23 6C 47 9B 8E 9F 46 AA 0C 0D 8E D1 97 FF EE 68 8B 5F 34 A3 87 D7 71 C5 A6 F9 8E 2E A6 31 7C BD F0 F9 E2 23 F9 CC 80 AF 54 00 04 F9 85 69 1C 77 89 C1 76 4E D6 AA BF 61 A6 C2 80 99 AB B6 5F 60 2F 40 A8 25 BE 32 A3 3D 9D 07 0C 79 68 98 D4 9D 63 49 AF 20 58 66 26 6F 98 6B 6D 32 34 CD 7D 08 15 5E 1A D0 00 09 57 AB 30 3B 20 60 C1 DC 12 87 D6 F3 E7 45 4F 70 67 09 36 31 55 F2 20 F6 6C A5 15 6F 2C 89 95 69 16 53 81 7D 31 F1 B6 BD 37 42 CC 11 0B B2 FC 2B 49 A5 85 B6 FC 76 74 44 93" -//! } -//! ``` -//! -//! The infohash is the [SHA1](https://en.wikipedia.org/wiki/SHA-1) hash -//! of the `info` attribute. That is, the SHA1 hash of: -//! -//! ```text -//! 64363a6c656e6774686931373232303465343a6e61 -//! d6532343a6d616e64656c62726f745f3230343878323034382e706e6731 -//! 23a7069656365206c656e67746869313633383465363a70696563657332 -//! 2303a7d91710d9d4dba889b542054d526728d5a863fe121df77c7f7bb6c -//! 79621662538c5d9cdab8b08ef8c249bb2f5c4cd2adf0bc00cf0addf7290 -//! 5b6414c236c479b8e9f46aa0c0d8ed197ffee688b5f34a387d771c5a6f9 -//! e2ea6317cbdf0f9e223f9cc80af540004f985691c7789c1764ed6aabf61 -//! 6c28099abb65f602f40a825be32a33d9d070c796898d49d6349af205866 -//! 66f986b6d3234cd7d08155e1ad0000957ab303b2060c1dc1287d6f3e745 -//! f706709363155f220f66ca5156f2c8995691653817d31f1b6bd3742cc11 -//! bb2fc2b49a585b6fc7674449365 -//! ``` -//! -//! You can hash that byte string with -//! -//! The result is a 20-char string: `5452869BE36F9F3350CCEE6B4544E7E76CAAADAB` - -use torrust_tracker_primitives::info_hash::InfoHash; - -pub mod fixture { - use std::hash::{DefaultHasher, Hash, Hasher}; - - use super::InfoHash; - - /// Generate as semi-stable pseudo-random infohash - /// - /// Note: If the [`DefaultHasher`] implementation changes - /// so will the resulting info-hashes. - /// - /// The results should not be relied upon between versions. - #[must_use] - pub fn gen_seeded_infohash(seed: &u64) -> InfoHash { - let mut buf_a: [[u8; 8]; 4] = Default::default(); - let mut buf_b = InfoHash::default(); - - let mut hasher = DefaultHasher::new(); - seed.hash(&mut hasher); - - for u in &mut buf_a { - seed.hash(&mut hasher); - *u = hasher.finish().to_le_bytes(); - } - - for (a, b) in buf_a.iter().flat_map(|a| a.iter()).zip(buf_b.0.iter_mut()) { - *b = *a; - } - - buf_b - } -} - -#[cfg(test)] -mod tests { - - use std::str::FromStr; - - use serde::{Deserialize, Serialize}; - use serde_json::json; - - use super::InfoHash; - - #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] - struct ContainingInfoHash { - pub info_hash: InfoHash, - } - - #[test] - fn an_info_hash_can_be_created_from_a_valid_40_utf8_char_string_representing_an_hexadecimal_value() { - let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); - assert!(info_hash.is_ok()); - } - - #[test] - fn an_info_hash_can_not_be_created_from_a_utf8_string_representing_a_not_valid_hexadecimal_value() { - let info_hash = InfoHash::from_str("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"); - assert!(info_hash.is_err()); - } - - #[test] - fn an_info_hash_can_only_be_created_from_a_40_utf8_char_string() { - let info_hash = InfoHash::from_str(&"F".repeat(39)); - assert!(info_hash.is_err()); - - let info_hash = InfoHash::from_str(&"F".repeat(41)); - assert!(info_hash.is_err()); - } - - #[test] - fn an_info_hash_should_by_displayed_like_a_40_utf8_lowercased_char_hex_string() { - let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); - - let output = format!("{info_hash}"); - - assert_eq!(output, "ffffffffffffffffffffffffffffffffffffffff"); - } - - #[test] - fn an_info_hash_should_return_its_a_40_utf8_lowercased_char_hex_representations_as_string() { - let info_hash = InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(); - - assert_eq!(info_hash.to_hex_string(), "ffffffffffffffffffffffffffffffffffffffff"); - } - - #[test] - fn an_info_hash_can_be_created_from_a_valid_20_byte_array_slice() { - let info_hash: InfoHash = [255u8; 20].as_slice().into(); - - assert_eq!( - info_hash, - InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() - ); - } - - #[test] - fn an_info_hash_can_be_created_from_a_valid_20_byte_array() { - let info_hash: InfoHash = [255u8; 20].into(); - - assert_eq!( - info_hash, - InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() - ); - } - - #[test] - fn an_info_hash_can_be_created_from_a_byte_vector() { - let info_hash: InfoHash = [255u8; 20].to_vec().try_into().unwrap(); - - assert_eq!( - info_hash, - InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() - ); - } - - #[test] - fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_less_than_20_bytes() { - assert!(InfoHash::try_from([255u8; 19].to_vec()).is_err()); - } - - #[test] - fn it_should_fail_trying_to_create_an_info_hash_from_a_byte_vector_with_more_than_20_bytes() { - assert!(InfoHash::try_from([255u8; 21].to_vec()).is_err()); - } - - #[test] - fn an_info_hash_can_be_serialized() { - let s = ContainingInfoHash { - info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap(), - }; - - let json_serialized_value = serde_json::to_string(&s).unwrap(); - - assert_eq!( - json_serialized_value, - r#"{"info_hash":"ffffffffffffffffffffffffffffffffffffffff"}"# - ); - } - - #[test] - fn an_info_hash_can_be_deserialized() { - let json = json!({ - "info_hash": "ffffffffffffffffffffffffffffffffffffffff", - }); - - let s: ContainingInfoHash = serde_json::from_value(json).unwrap(); - - assert_eq!( - s, - ContainingInfoHash { - info_hash: InfoHash::from_str("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF").unwrap() - } - ); - } -} diff --git a/src/shared/bit_torrent/mod.rs b/src/shared/bit_torrent/mod.rs deleted file mode 100644 index 8074661be..000000000 --- a/src/shared/bit_torrent/mod.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Common code for the `BitTorrent` protocol. -//! -//! # Glossary -//! -//! - [Announce](#announce) -//! - [Info Hash](#info-hash) -//! - [Leecher](#leechers) -//! - [Peer ID](#peer-id) -//! - [Peer List](#peer-list) -//! - [Peer](#peer) -//! - [Scrape](#scrape) -//! - [Seeders](#seeders) -//! - [Swarm](#swarm) -//! - [Tracker](#tracker) -//! -//! Glossary of `BitTorrent` terms. -//! -//! # Announce -//! -//! A request to the tracker to announce the presence of a peer. -//! -//! ## Info Hash -//! -//! A unique identifier for a torrent. -//! -//! ## Leecher -//! -//! Peers that are only downloading data. -//! -//! ## Peer ID -//! -//! A unique identifier for a peer. -//! -//! ## Peer List -//! -//! A list of peers that are downloading a torrent. -//! -//! ## Peer -//! -//! A client that is downloading or uploading a torrent. -//! -//! ## Scrape -//! -//! A request to the tracker to get information about a torrent. -//! -//! ## Seeder -//! -//! Peers that are only uploading data. -//! -//! ## Swarm -//! -//! A group of peers that are downloading the same torrent. -//! -//! ## Tracker -//! -//! A server that keeps track of peers that are downloading a torrent. -//! -//! # Links -//! -//! Description | Link -//! ---|--- -//! `BitTorrent.org`. A forum for developers to exchange ideas about the direction of the `BitTorrent` protocol | -//! Wikipedia entry for Glossary of `BitTorrent` term | -//! `BitTorrent` Specification Wiki | -//! Vuze Wiki. A `BitTorrent` client implementation | -//! `libtorrent`. Complete C++ bittorrent implementation| -//! UDP Tracker Protocol docs by `libtorrent` | -//! Percent Encoding spec | -//!Bencode & bdecode in your browser | -pub mod common; -pub mod info_hash; -pub mod tracker; diff --git a/src/shared/crypto/ephemeral_instance_keys.rs b/src/shared/crypto/ephemeral_instance_keys.rs deleted file mode 100644 index 44283365a..000000000 --- a/src/shared/crypto/ephemeral_instance_keys.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! This module contains the ephemeral instance keys used by the application. -//! -//! They are ephemeral because they are generated at runtime when the -//! application starts and are not persisted anywhere. -use rand::rngs::ThreadRng; -use rand::Rng; - -pub type Seed = [u8; 32]; - -lazy_static! { - /// The random static seed. - pub static ref RANDOM_SEED: Seed = Rng::gen(&mut ThreadRng::default()); -} diff --git a/src/shared/crypto/keys.rs b/src/shared/crypto/keys.rs deleted file mode 100644 index deb70574f..000000000 --- a/src/shared/crypto/keys.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! This module contains logic related to cryptographic keys. -pub mod seeds { - //! This module contains logic related to cryptographic seeds. - //! - //! Specifically, it contains the logic for storing the seed and providing - //! it to other modules. - //! - //! A **seed** is a pseudo-random number that is used as a secret key for - //! cryptographic operations. - use self::detail::CURRENT_SEED; - use crate::shared::crypto::ephemeral_instance_keys::{Seed, RANDOM_SEED}; - - /// This trait is for structures that can keep and provide a seed. - pub trait Keeper { - type Seed: Sized + Default + AsMut<[u8]>; - - /// It returns a reference to the seed that is keeping. - fn get_seed() -> &'static Self::Seed; - } - - /// The seed keeper for the instance. When the application is running - /// in production, this will be the seed keeper that is used. - pub struct Instance; - - /// The seed keeper for the current execution. It's a facade at compilation - /// time that will either be the instance seed keeper (with a randomly - /// generated key for production) or the zeroed seed keeper. - pub struct Current; - - impl Keeper for Instance { - type Seed = Seed; - - fn get_seed() -> &'static Self::Seed { - &RANDOM_SEED - } - } - - impl Keeper for Current { - type Seed = Seed; - - #[allow(clippy::needless_borrow)] - fn get_seed() -> &'static Self::Seed { - &CURRENT_SEED - } - } - - #[cfg(test)] - mod tests { - use super::detail::ZEROED_TEST_SEED; - use super::{Current, Instance, Keeper}; - use crate::shared::crypto::ephemeral_instance_keys::Seed; - - pub struct ZeroedTestSeed; - - impl Keeper for ZeroedTestSeed { - type Seed = Seed; - - #[allow(clippy::needless_borrow)] - fn get_seed() -> &'static Self::Seed { - &ZEROED_TEST_SEED - } - } - - #[test] - fn the_default_seed_and_the_zeroed_seed_should_be_the_same_when_testing() { - assert_eq!(Current::get_seed(), ZeroedTestSeed::get_seed()); - } - - #[test] - fn the_default_seed_and_the_instance_seed_should_be_different_when_testing() { - assert_ne!(Current::get_seed(), Instance::get_seed()); - } - } - - mod detail { - use crate::shared::crypto::ephemeral_instance_keys::Seed; - - #[allow(dead_code)] - pub const ZEROED_TEST_SEED: &Seed = &[0u8; 32]; - - #[cfg(test)] - pub use ZEROED_TEST_SEED as CURRENT_SEED; - - #[cfg(not(test))] - pub use crate::shared::crypto::ephemeral_instance_keys::RANDOM_SEED as CURRENT_SEED; - - #[cfg(test)] - mod tests { - use crate::shared::crypto::ephemeral_instance_keys::RANDOM_SEED; - use crate::shared::crypto::keys::seeds::detail::ZEROED_TEST_SEED; - use crate::shared::crypto::keys::seeds::CURRENT_SEED; - - #[test] - fn it_should_have_a_zero_test_seed() { - assert_eq!(*ZEROED_TEST_SEED, [0u8; 32]); - } - - #[test] - fn it_should_default_to_zeroed_seed_when_testing() { - assert_eq!(*CURRENT_SEED, *ZEROED_TEST_SEED); - } - - #[test] - fn it_should_have_a_large_random_seed() { - assert!(u128::from_ne_bytes((*RANDOM_SEED)[..16].try_into().unwrap()) > u128::from(u64::MAX)); - assert!(u128::from_ne_bytes((*RANDOM_SEED)[16..].try_into().unwrap()) > u128::from(u64::MAX)); - } - } - } -} diff --git a/src/shared/mod.rs b/src/shared/mod.rs deleted file mode 100644 index 8c95effe1..000000000 --- a/src/shared/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Modules with generic logic used by several modules. -//! -//! - [`bit_torrent`]: `BitTorrent` protocol related logic. -//! - [`crypto`]: Encryption related logic. -pub mod bit_torrent; -pub mod crypto; diff --git a/tests/common/clock.rs b/tests/common/clock.rs deleted file mode 100644 index de3cc7c65..000000000 --- a/tests/common/clock.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::time::Duration; - -use torrust_tracker_clock::clock::Time; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::CurrentClock; - -#[test] -fn it_should_use_stopped_time_for_testing() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - assert_eq!(CurrentClock::dbg_clock_type(), "Stopped".to_owned()); - - let time = CurrentClock::now(); - std::thread::sleep(Duration::from_millis(50)); - let time_2 = CurrentClock::now(); - - assert_eq!(time, time_2); -} diff --git a/tests/common/logging.rs b/tests/common/logging.rs deleted file mode 100644 index 71be2ece7..000000000 --- a/tests/common/logging.rs +++ /dev/null @@ -1,30 +0,0 @@ -#![allow(clippy::doc_markdown)] -//! Logging for the Integration Tests -//! -//! Tests should start their own logging. -//! -//! To find tests that do not start their own logging: -//! -//! ´´´ sh -//! awk 'BEGIN{RS=""; FS="\n"} /#\[tokio::test\]\s*async\s+fn\s+\w+\s*\(\s*\)\s*\{[^}]*\}/ && !/#\[tokio::test\]\s*async\s+fn\s+\w+\s*\(\s*\)\s*\{[^}]*INIT\.call_once/' $(find . -name "*.rs") -//! ´´´ -//! - -use std::sync::Once; - -use tracing::level_filters::LevelFilter; - -#[allow(dead_code)] -pub static INIT: Once = Once::new(); - -#[allow(dead_code)] -pub fn tracing_stderr_init(filter: LevelFilter) { - let builder = tracing_subscriber::fmt() - .with_max_level(filter) - .with_ansi(true) - .with_writer(std::io::stderr); - - builder.pretty().with_file(true).init(); - - tracing::info!("Logging initialized"); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs deleted file mode 100644 index 9589ccb1e..000000000 --- a/tests/common/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod clock; -pub mod fixtures; -pub mod http; -pub mod logging; -pub mod udp; diff --git a/tests/integration.rs b/tests/integration.rs index 8e3d46826..92289c415 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,12 +1,15 @@ -//! Integration tests. +//! Scaffolding for integration tests. +//! +//! Integration tests are used to test the interaction between multiple modules, +//! multiple running trackers, etc. Tests for one specific module should be in +//! the corresponding package. //! //! ```text //! cargo test --test integration //! ``` +mod servers; use torrust_tracker_clock::clock; -mod common; -mod servers; /// This code needs to be copied into each crate. /// Working version, for production. diff --git a/tests/servers/api/connection_info.rs b/tests/servers/api/connection_info.rs deleted file mode 100644 index 35314a2fd..000000000 --- a/tests/servers/api/connection_info.rs +++ /dev/null @@ -1,29 +0,0 @@ -pub fn connection_with_invalid_token(bind_address: &str) -> ConnectionInfo { - ConnectionInfo::authenticated(bind_address, "invalid token") -} - -pub fn connection_with_no_token(bind_address: &str) -> ConnectionInfo { - ConnectionInfo::anonymous(bind_address) -} - -#[derive(Clone)] -pub struct ConnectionInfo { - pub bind_address: String, - pub api_token: Option, -} - -impl ConnectionInfo { - pub fn authenticated(bind_address: &str, api_token: &str) -> Self { - Self { - bind_address: bind_address.to_string(), - api_token: Some(api_token.to_string()), - } - } - - pub fn anonymous(bind_address: &str) -> Self { - Self { - bind_address: bind_address.to_string(), - api_token: None, - } - } -} diff --git a/tests/servers/api/contract/mod.rs b/tests/servers/api/contract/mod.rs new file mode 100644 index 000000000..9d34677fc --- /dev/null +++ b/tests/servers/api/contract/mod.rs @@ -0,0 +1 @@ +pub mod stats; diff --git a/tests/servers/api/contract/stats/mod.rs b/tests/servers/api/contract/stats/mod.rs new file mode 100644 index 000000000..d50bc58a5 --- /dev/null +++ b/tests/servers/api/contract/stats/mod.rs @@ -0,0 +1,94 @@ +use std::env; +use std::str::FromStr as _; + +use bittorrent_primitives::info_hash::InfoHash; +use bittorrent_tracker_client::http::client::requests::announce::QueryBuilder; +use bittorrent_tracker_client::http::client::Client as HttpTrackerClient; +use reqwest::Url; +use serde::Deserialize; +use tokio::time::Duration; +use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin}; +use torrust_rest_tracker_api_client::v1::client::Client as TrackerApiClient; +use torrust_tracker_lib::app; + +#[tokio::test] +async fn the_stats_api_endpoint_should_return_the_global_stats() { + // Logging must be OFF otherwise your will get the following error: + // `Unable to install global subscriber: SetGlobalDefaultError("a global default trace dispatcher has already been set")` + // That's because we can't initialize the logger twice. + // You can enable it if you run only this test. + let config_with_two_http_trackers = r#" + [metadata] + app = "torrust-tracker" + purpose = "configuration" + schema_version = "2.0.0" + + [logging] + threshold = "off" + + [core] + listed = false + private = false + + [core.database] + driver = "sqlite3" + path = "./integration_tests_sqlite3.db" + + [[http_trackers]] + bind_address = "0.0.0.0:7272" + tracker_usage_statistics = true + + [[http_trackers]] + bind_address = "0.0.0.0:7373" + tracker_usage_statistics = true + + [http_api] + bind_address = "0.0.0.0:1414" + + [http_api.access_tokens] + admin = "MyAccessToken" + "#; + + env::set_var("TORRUST_TRACKER_CONFIG_TOML", config_with_two_http_trackers); + + let (_app_container, _jobs) = app::run().await; + + announce_to_tracker("http://127.0.0.1:7272").await; + announce_to_tracker("http://127.0.0.1:7373").await; + + let global_stats = get_tracker_statistics("http://127.0.0.1:1414", "MyAccessToken").await; + + assert_eq!(global_stats.tcp4_announces_handled, 2); +} + +/// Make a sample announce request to the tracker. +async fn announce_to_tracker(tracker_url: &str) { + let response = HttpTrackerClient::new(Url::parse(tracker_url).unwrap(), Duration::from_secs(1)) + .unwrap() + .announce( + &QueryBuilder::with_default_values() + .with_info_hash(&InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap()) // DevSkim: ignore DS173237 + .query(), + ) + .await; + + assert!(response.is_ok()); +} + +/// Global statistics with only metrics relevant to the test. +#[derive(Deserialize)] +struct PartialGlobalStatistics { + tcp4_announces_handled: u64, +} + +async fn get_tracker_statistics(aip_url: &str, token: &str) -> PartialGlobalStatistics { + let response = TrackerApiClient::new(ConnectionInfo::authenticated(Origin::new(aip_url).unwrap(), token)) + .unwrap() + .get_tracker_statistics(None) + .await; + + response + .json::() + .await + .expect("Failed to parse JSON response") +} diff --git a/tests/servers/api/environment.rs b/tests/servers/api/environment.rs deleted file mode 100644 index 2f4606be7..000000000 --- a/tests/servers/api/environment.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use futures::executor::block_on; -use torrust_tracker::bootstrap::app::initialize_with_configuration; -use torrust_tracker::bootstrap::jobs::make_rust_tls; -use torrust_tracker::core::Tracker; -use torrust_tracker::servers::apis::server::{ApiServer, Launcher, Running, Stopped}; -use torrust_tracker::servers::registar::Registar; -use torrust_tracker_configuration::{Configuration, HttpApi}; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::peer; - -use super::connection_info::ConnectionInfo; - -pub struct Environment -where - S: std::fmt::Debug + std::fmt::Display, -{ - pub config: Arc, - pub tracker: Arc, - pub registar: Registar, - pub server: ApiServer, -} - -impl Environment -where - S: std::fmt::Debug + std::fmt::Display, -{ - /// Add a torrent to the tracker - pub fn add_torrent_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { - self.tracker.upsert_peer_and_get_stats(info_hash, peer); - } -} - -impl Environment { - pub fn new(configuration: &Arc) -> Self { - let tracker = initialize_with_configuration(configuration); - - let config = Arc::new(configuration.http_api.clone().expect("missing API configuration")); - - let bind_to = config.bind_address; - - let tls = block_on(make_rust_tls(&config.tsl_config)).map(|tls| tls.expect("tls config failed")); - - let server = ApiServer::new(Launcher::new(bind_to, tls)); - - Self { - config, - tracker, - registar: Registar::default(), - server, - } - } - - pub async fn start(self) -> Environment { - let access_tokens = Arc::new(self.config.access_tokens.clone()); - - Environment { - config: self.config, - tracker: self.tracker.clone(), - registar: self.registar.clone(), - server: self - .server - .start(self.tracker, self.registar.give_form(), access_tokens) - .await - .unwrap(), - } - } -} - -impl Environment { - pub async fn new(configuration: &Arc) -> Self { - Environment::::new(configuration).start().await - } - - pub async fn stop(self) -> Environment { - Environment { - config: self.config, - tracker: self.tracker, - registar: Registar::default(), - server: self.server.stop().await.unwrap(), - } - } - - pub fn get_connection_info(&self) -> ConnectionInfo { - ConnectionInfo { - bind_address: self.server.state.local_addr.to_string(), - api_token: self.config.access_tokens.get("admin").cloned(), - } - } - - pub fn bind_address(&self) -> SocketAddr { - self.server.state.local_addr - } -} diff --git a/tests/servers/api/mod.rs b/tests/servers/api/mod.rs index 38df46e9b..2943dbb50 100644 --- a/tests/servers/api/mod.rs +++ b/tests/servers/api/mod.rs @@ -1,20 +1 @@ -use std::sync::Arc; - -use torrust_tracker::core::Tracker; -use torrust_tracker::servers::apis::server; - -pub mod connection_info; -pub mod environment; -pub mod v1; - -pub type Started = environment::Environment; - -/// It forces a database error by dropping all tables. -/// That makes any query fail. -/// code-review: -/// Alternatively we could: -/// - Inject a database mock in the future. -/// - Inject directly the database reference passed to the Tracker type. -pub fn force_database_error(tracker: &Arc) { - tracker.drop_database_tables().unwrap(); -} +pub mod contract; diff --git a/tests/servers/api/v1/client.rs b/tests/servers/api/v1/client.rs deleted file mode 100644 index 3d95c10ca..000000000 --- a/tests/servers/api/v1/client.rs +++ /dev/null @@ -1,138 +0,0 @@ -use reqwest::Response; -use serde::Serialize; - -use crate::common::http::{Query, QueryParam, ReqwestQuery}; -use crate::servers::api::connection_info::ConnectionInfo; - -/// API Client -pub struct Client { - connection_info: ConnectionInfo, - base_path: String, -} - -impl Client { - pub fn new(connection_info: ConnectionInfo) -> Self { - Self { - connection_info, - base_path: "/api/v1/".to_string(), - } - } - - pub async fn generate_auth_key(&self, seconds_valid: i32) -> Response { - self.post_empty(&format!("key/{}", &seconds_valid)).await - } - - pub async fn add_auth_key(&self, add_key_form: AddKeyForm) -> Response { - self.post_form("keys", &add_key_form).await - } - - pub async fn delete_auth_key(&self, key: &str) -> Response { - self.delete(&format!("key/{}", &key)).await - } - - pub async fn reload_keys(&self) -> Response { - self.get("keys/reload", Query::default()).await - } - - pub async fn whitelist_a_torrent(&self, info_hash: &str) -> Response { - self.post_empty(&format!("whitelist/{}", &info_hash)).await - } - - pub async fn remove_torrent_from_whitelist(&self, info_hash: &str) -> Response { - self.delete(&format!("whitelist/{}", &info_hash)).await - } - - pub async fn reload_whitelist(&self) -> Response { - self.get("whitelist/reload", Query::default()).await - } - - pub async fn get_torrent(&self, info_hash: &str) -> Response { - self.get(&format!("torrent/{}", &info_hash), Query::default()).await - } - - pub async fn get_torrents(&self, params: Query) -> Response { - self.get("torrents", params).await - } - - pub async fn get_tracker_statistics(&self) -> Response { - self.get("stats", Query::default()).await - } - - pub async fn get(&self, path: &str, params: Query) -> Response { - let mut query: Query = params; - - if let Some(token) = &self.connection_info.api_token { - query.add_param(QueryParam::new("token", token)); - }; - - self.get_request_with_query(path, query).await - } - - pub async fn post_empty(&self, path: &str) -> Response { - reqwest::Client::new() - .post(self.base_url(path).clone()) - .query(&ReqwestQuery::from(self.query_with_token())) - .send() - .await - .unwrap() - } - - pub async fn post_form(&self, path: &str, form: &T) -> Response { - reqwest::Client::new() - .post(self.base_url(path).clone()) - .query(&ReqwestQuery::from(self.query_with_token())) - .json(&form) - .send() - .await - .unwrap() - } - - async fn delete(&self, path: &str) -> Response { - reqwest::Client::new() - .delete(self.base_url(path).clone()) - .query(&ReqwestQuery::from(self.query_with_token())) - .send() - .await - .unwrap() - } - - pub async fn get_request_with_query(&self, path: &str, params: Query) -> Response { - get(&self.base_url(path), Some(params)).await - } - - pub async fn get_request(&self, path: &str) -> Response { - get(&self.base_url(path), None).await - } - - fn query_with_token(&self) -> Query { - match &self.connection_info.api_token { - Some(token) => Query::params([QueryParam::new("token", token)].to_vec()), - None => Query::default(), - } - } - - fn base_url(&self, path: &str) -> String { - format!("http://{}{}{path}", &self.connection_info.bind_address, &self.base_path) - } -} - -pub async fn get(path: &str, query: Option) -> Response { - match query { - Some(params) => reqwest::Client::builder() - .build() - .unwrap() - .get(path) - .query(&ReqwestQuery::from(params)) - .send() - .await - .unwrap(), - None => reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap(), - } -} - -#[derive(Serialize, Debug)] -pub struct AddKeyForm { - #[serde(rename = "key")] - pub opt_key: Option, - pub seconds_valid: Option, -} diff --git a/tests/servers/api/v1/contract/authentication.rs b/tests/servers/api/v1/contract/authentication.rs deleted file mode 100644 index 5c5cd3ae0..000000000 --- a/tests/servers/api/v1/contract/authentication.rs +++ /dev/null @@ -1,105 +0,0 @@ -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::http::{Query, QueryParam}; -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::v1::asserts::{assert_token_not_valid, assert_unauthorized}; -use crate::servers::api::v1::client::Client; -use crate::servers::api::Started; - -#[tokio::test] -async fn should_authenticate_requests_by_using_a_token_query_param() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let token = env.get_connection_info().api_token.unwrap(); - - let response = Client::new(env.get_connection_info()) - .get_request_with_query("stats", Query::params([QueryParam::new("token", &token)].to_vec())) - .await; - - assert_eq!(response.status(), 200); - - env.stop().await; -} - -#[tokio::test] -async fn should_not_authenticate_requests_when_the_token_is_missing() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let response = Client::new(env.get_connection_info()) - .get_request_with_query("stats", Query::default()) - .await; - - assert_unauthorized(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_not_authenticate_requests_when_the_token_is_empty() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let response = Client::new(env.get_connection_info()) - .get_request_with_query("stats", Query::params([QueryParam::new("token", "")].to_vec())) - .await; - - assert_token_not_valid(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_not_authenticate_requests_when_the_token_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let response = Client::new(env.get_connection_info()) - .get_request_with_query("stats", Query::params([QueryParam::new("token", "INVALID TOKEN")].to_vec())) - .await; - - assert_token_not_valid(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let token = env.get_connection_info().api_token.unwrap(); - - // At the beginning of the query component - let response = Client::new(env.get_connection_info()) - .get_request(&format!("torrents?token={token}&limit=1")) - .await; - - assert_eq!(response.status(), 200); - - // At the end of the query component - let response = Client::new(env.get_connection_info()) - .get_request(&format!("torrents?limit=1&token={token}")) - .await; - - assert_eq!(response.status(), 200); - - env.stop().await; -} diff --git a/tests/servers/api/v1/contract/configuration.rs b/tests/servers/api/v1/contract/configuration.rs deleted file mode 100644 index be42f16ad..000000000 --- a/tests/servers/api/v1/contract/configuration.rs +++ /dev/null @@ -1,41 +0,0 @@ -// use std::sync::Arc; - -// use axum_server::tls_rustls::RustlsConfig; -// use futures::executor::block_on; -// use torrust_tracker_test_helpers::configuration; - -// use crate::common::app::setup_with_configuration; -// use crate::servers::api::environment::stopped_environment; - -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; - -#[tokio::test] -#[ignore] -#[should_panic = "Could not receive bind_address."] -async fn should_fail_with_ssl_enabled_and_bad_ssl_config() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - // let tracker = setup_with_configuration(&Arc::new(configuration::ephemeral())); - - // let config = tracker.config.http_api.clone(); - - // let bind_to = config - // .bind_address - // .parse::() - // .expect("Tracker API bind_address invalid."); - - // let tls = - // if let (true, Some(cert), Some(key)) = (&true, &Some("bad cert path".to_string()), &Some("bad cert path".to_string())) { - // Some(block_on(RustlsConfig::from_pem_file(cert, key)).expect("Could not read tls cert.")) - // } else { - // None - // }; - - // let env = new_stopped(tracker, bind_to, tls); - - // env.start().await; -} diff --git a/tests/servers/api/v1/contract/context/health_check.rs b/tests/servers/api/v1/contract/context/health_check.rs deleted file mode 100644 index af46a5abe..000000000 --- a/tests/servers/api/v1/contract/context/health_check.rs +++ /dev/null @@ -1,26 +0,0 @@ -use torrust_tracker::servers::apis::v1::context::health_check::resources::{Report, Status}; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::v1::client::get; -use crate::servers::api::Started; - -#[tokio::test] -async fn health_check_endpoint_should_return_status_ok_if_api_is_running() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let url = format!("http://{}/api/health_check", env.get_connection_info().bind_address); - - let response = get(&url, None).await; - - assert_eq!(response.status(), 200); - assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); - assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); - - env.stop().await; -} diff --git a/tests/servers/api/v1/contract/context/stats.rs b/tests/servers/api/v1/contract/context/stats.rs deleted file mode 100644 index a034a7778..000000000 --- a/tests/servers/api/v1/contract/context/stats.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::str::FromStr; - -use torrust_tracker::servers::apis::v1::context::stats::resources::Stats; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::peer::fixture::PeerBuilder; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::connection_info::{connection_with_invalid_token, connection_with_no_token}; -use crate::servers::api::v1::asserts::{assert_stats, assert_token_not_valid, assert_unauthorized}; -use crate::servers::api::v1::client::Client; -use crate::servers::api::Started; - -#[tokio::test] -async fn should_allow_getting_tracker_statistics() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - env.add_torrent_peer( - &InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), - &PeerBuilder::default().into(), - ); - - let response = Client::new(env.get_connection_info()).get_tracker_statistics().await; - - assert_stats( - response, - Stats { - torrents: 1, - seeders: 1, - completed: 0, - leechers: 0, - tcp4_connections_handled: 0, - tcp4_announces_handled: 0, - tcp4_scrapes_handled: 0, - tcp6_connections_handled: 0, - tcp6_announces_handled: 0, - tcp6_scrapes_handled: 0, - udp4_connections_handled: 0, - udp4_announces_handled: 0, - udp4_scrapes_handled: 0, - udp6_connections_handled: 0, - udp6_announces_handled: 0, - udp6_scrapes_handled: 0, - }, - ) - .await; - - env.stop().await; -} - -#[tokio::test] -async fn should_not_allow_getting_tracker_statistics_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .get_tracker_statistics() - .await; - - assert_token_not_valid(response).await; - - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .get_tracker_statistics() - .await; - - assert_unauthorized(response).await; - - env.stop().await; -} diff --git a/tests/servers/api/v1/contract/context/whitelist.rs b/tests/servers/api/v1/contract/context/whitelist.rs deleted file mode 100644 index b30a7dbf8..000000000 --- a/tests/servers/api/v1/contract/context/whitelist.rs +++ /dev/null @@ -1,300 +0,0 @@ -use std::str::FromStr; - -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::api::connection_info::{connection_with_invalid_token, connection_with_no_token}; -use crate::servers::api::v1::asserts::{ - assert_failed_to_reload_whitelist, assert_failed_to_remove_torrent_from_whitelist, assert_failed_to_whitelist_torrent, - assert_invalid_infohash_param, assert_not_found, assert_ok, assert_token_not_valid, assert_unauthorized, -}; -use crate::servers::api::v1::client::Client; -use crate::servers::api::v1::contract::fixtures::{ - invalid_infohashes_returning_bad_request, invalid_infohashes_returning_not_found, -}; -use crate::servers::api::{force_database_error, Started}; - -#[tokio::test] -async fn should_allow_whitelisting_a_torrent() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let response = Client::new(env.get_connection_info()).whitelist_a_torrent(&info_hash).await; - - assert_ok(response).await; - assert!( - env.tracker - .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) - .await - ); - - env.stop().await; -} - -#[tokio::test] -async fn should_allow_whitelisting_a_torrent_that_has_been_already_whitelisted() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let api_client = Client::new(env.get_connection_info()); - - let response = api_client.whitelist_a_torrent(&info_hash).await; - assert_ok(response).await; - - let response = api_client.whitelist_a_torrent(&info_hash).await; - assert_ok(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_not_allow_whitelisting_a_torrent_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .whitelist_a_torrent(&info_hash) - .await; - - assert_token_not_valid(response).await; - - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .whitelist_a_torrent(&info_hash) - .await; - - assert_unauthorized(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_fail_when_the_torrent_cannot_be_whitelisted() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - force_database_error(&env.tracker); - - let response = Client::new(env.get_connection_info()).whitelist_a_torrent(&info_hash).await; - - assert_failed_to_whitelist_torrent(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_fail_whitelisting_a_torrent_when_the_provided_infohash_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - for invalid_infohash in &invalid_infohashes_returning_bad_request() { - let response = Client::new(env.get_connection_info()) - .whitelist_a_torrent(invalid_infohash) - .await; - - assert_invalid_infohash_param(response, invalid_infohash).await; - } - - for invalid_infohash in &invalid_infohashes_returning_not_found() { - let response = Client::new(env.get_connection_info()) - .whitelist_a_torrent(invalid_infohash) - .await; - - assert_not_found(response).await; - } - - env.stop().await; -} - -#[tokio::test] -async fn should_allow_removing_a_torrent_from_the_whitelist() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - let response = Client::new(env.get_connection_info()) - .remove_torrent_from_whitelist(&hash) - .await; - - assert_ok(response).await; - assert!(!env.tracker.is_info_hash_whitelisted(&info_hash).await); - - env.stop().await; -} - -#[tokio::test] -async fn should_not_fail_trying_to_remove_a_non_whitelisted_torrent_from_the_whitelist() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let non_whitelisted_torrent_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - - let response = Client::new(env.get_connection_info()) - .remove_torrent_from_whitelist(&non_whitelisted_torrent_hash) - .await; - - assert_ok(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_fail_removing_a_torrent_from_the_whitelist_when_the_provided_infohash_is_invalid() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - for invalid_infohash in &invalid_infohashes_returning_bad_request() { - let response = Client::new(env.get_connection_info()) - .remove_torrent_from_whitelist(invalid_infohash) - .await; - - assert_invalid_infohash_param(response, invalid_infohash).await; - } - - for invalid_infohash in &invalid_infohashes_returning_not_found() { - let response = Client::new(env.get_connection_info()) - .remove_torrent_from_whitelist(invalid_infohash) - .await; - - assert_not_found(response).await; - } - - env.stop().await; -} - -#[tokio::test] -async fn should_fail_when_the_torrent_cannot_be_removed_from_the_whitelist() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - force_database_error(&env.tracker); - - let response = Client::new(env.get_connection_info()) - .remove_torrent_from_whitelist(&hash) - .await; - - assert_failed_to_remove_torrent_from_whitelist(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_not_allow_removing_a_torrent_from_the_whitelist_for_unauthenticated_users() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - let response = Client::new(connection_with_invalid_token(env.get_connection_info().bind_address.as_str())) - .remove_torrent_from_whitelist(&hash) - .await; - - assert_token_not_valid(response).await; - - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - let response = Client::new(connection_with_no_token(env.get_connection_info().bind_address.as_str())) - .remove_torrent_from_whitelist(&hash) - .await; - - assert_unauthorized(response).await; - - env.stop().await; -} - -#[tokio::test] -async fn should_allow_reload_the_whitelist_from_the_database() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - let response = Client::new(env.get_connection_info()).reload_whitelist().await; - - assert_ok(response).await; - /* todo: this assert fails because the whitelist has not been reloaded yet. - We could add a new endpoint GET /api/whitelist/:info_hash to check if a torrent - is whitelisted and use that endpoint to check if the torrent is still there after reloading. - assert!( - !(env - .tracker - .is_info_hash_whitelisted(&InfoHash::from_str(&info_hash).unwrap()) - .await) - ); - */ - - env.stop().await; -} - -#[tokio::test] -async fn should_fail_when_the_whitelist_cannot_be_reloaded_from_the_database() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let info_hash = InfoHash::from_str(&hash).unwrap(); - env.tracker.add_torrent_to_whitelist(&info_hash).await.unwrap(); - - force_database_error(&env.tracker); - - let response = Client::new(env.get_connection_info()).reload_whitelist().await; - - assert_failed_to_reload_whitelist(response).await; - - env.stop().await; -} diff --git a/tests/servers/health_check_api/mod.rs b/tests/servers/health_check_api/mod.rs deleted file mode 100644 index 9e15c5f62..000000000 --- a/tests/servers/health_check_api/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod client; -pub mod contract; -pub mod environment; - -pub type Started = environment::Environment; diff --git a/tests/servers/http/connection_info.rs b/tests/servers/http/connection_info.rs deleted file mode 100644 index f4081d60e..000000000 --- a/tests/servers/http/connection_info.rs +++ /dev/null @@ -1,16 +0,0 @@ -use torrust_tracker::core::auth::Key; - -#[derive(Clone, Debug)] -pub struct ConnectionInfo { - pub bind_address: String, - pub key: Option, -} - -impl ConnectionInfo { - pub fn anonymous(bind_address: &str) -> Self { - Self { - bind_address: bind_address.to_string(), - key: None, - } - } -} diff --git a/tests/servers/http/environment.rs b/tests/servers/http/environment.rs deleted file mode 100644 index b6bb21c16..000000000 --- a/tests/servers/http/environment.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::sync::Arc; - -use futures::executor::block_on; -use torrust_tracker::bootstrap::app::initialize_with_configuration; -use torrust_tracker::bootstrap::jobs::make_rust_tls; -use torrust_tracker::core::Tracker; -use torrust_tracker::servers::http::server::{HttpServer, Launcher, Running, Stopped}; -use torrust_tracker::servers::registar::Registar; -use torrust_tracker_configuration::{Configuration, HttpTracker}; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::peer; - -pub struct Environment { - pub config: Arc, - pub tracker: Arc, - pub registar: Registar, - pub server: HttpServer, -} - -impl Environment { - /// Add a torrent to the tracker - pub fn add_torrent_peer(&self, info_hash: &InfoHash, peer: &peer::Peer) { - self.tracker.upsert_peer_and_get_stats(info_hash, peer); - } -} - -impl Environment { - #[allow(dead_code)] - pub fn new(configuration: &Arc) -> Self { - let tracker = initialize_with_configuration(configuration); - - let http_tracker = configuration - .http_trackers - .clone() - .expect("missing HTTP tracker configuration"); - - let config = Arc::new(http_tracker[0].clone()); - - let bind_to = config.bind_address; - - let tls = block_on(make_rust_tls(&config.tsl_config)).map(|tls| tls.expect("tls config failed")); - - let server = HttpServer::new(Launcher::new(bind_to, tls)); - - Self { - config, - tracker, - registar: Registar::default(), - server, - } - } - - #[allow(dead_code)] - pub async fn start(self) -> Environment { - Environment { - config: self.config, - tracker: self.tracker.clone(), - registar: self.registar.clone(), - server: self.server.start(self.tracker, self.registar.give_form()).await.unwrap(), - } - } -} - -impl Environment { - pub async fn new(configuration: &Arc) -> Self { - Environment::::new(configuration).start().await - } - - pub async fn stop(self) -> Environment { - Environment { - config: self.config, - tracker: self.tracker, - registar: Registar::default(), - - server: self.server.stop().await.unwrap(), - } - } - - pub fn bind_address(&self) -> &std::net::SocketAddr { - &self.server.state.binding - } -} diff --git a/tests/servers/mod.rs b/tests/servers/mod.rs index 65e9a665b..e5fdf85ee 100644 --- a/tests/servers/mod.rs +++ b/tests/servers/mod.rs @@ -1,4 +1 @@ -mod api; -pub mod health_check_api; -mod http; -mod udp; +pub mod api; diff --git a/tests/servers/udp/contract.rs b/tests/servers/udp/contract.rs deleted file mode 100644 index 1f9b71b62..000000000 --- a/tests/servers/udp/contract.rs +++ /dev/null @@ -1,269 +0,0 @@ -// UDP tracker documentation: -// -// BEP 15. UDP Tracker Protocol for BitTorrent -// https://www.bittorrent.org/beps/bep_0015.html - -use core::panic; - -use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; -use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; -use torrust_tracker::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; -use torrust_tracker_configuration::DEFAULT_TIMEOUT; -use torrust_tracker_test_helpers::configuration; -use tracing::level_filters::LevelFilter; - -use crate::common::logging::{tracing_stderr_init, INIT}; -use crate::servers::udp::asserts::is_error_response; -use crate::servers::udp::Started; - -fn empty_udp_request() -> [u8; MAX_PACKET_SIZE] { - [0; MAX_PACKET_SIZE] -} - -async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrackerClient) -> ConnectionId { - let connect_request = ConnectRequest { transaction_id }; - - match client.send(connect_request.into()).await { - Ok(_) => (), - Err(err) => panic!("{err}"), - }; - - let response = match client.receive().await { - Ok(response) => response, - Err(err) => panic!("{err}"), - }; - - match response { - Response::Connect(connect_response) => connect_response.connection_id, - _ => panic!("error connecting to udp server {:?}", response), - } -} - -#[tokio::test] -async fn should_return_a_bad_request_response_when_the_client_sends_an_empty_request() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { - Ok(udp_client) => udp_client, - Err(err) => panic!("{err}"), - }; - - match client.client.send(&empty_udp_request()).await { - Ok(_) => (), - Err(err) => panic!("{err}"), - }; - - let response = match client.client.receive().await { - Ok(response) => response, - Err(err) => panic!("{err}"), - }; - - let response = Response::parse_bytes(&response, true).unwrap(); - - assert!(is_error_response(&response, "bad request")); - - env.stop().await; -} - -mod receiving_a_connection_request { - use aquatic_udp_protocol::{ConnectRequest, TransactionId}; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; - use torrust_tracker_configuration::DEFAULT_TIMEOUT; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; - - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::udp::asserts::is_connect_response; - use crate::servers::udp::Started; - - #[tokio::test] - async fn should_return_a_connect_response() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { - Ok(udp_tracker_client) => udp_tracker_client, - Err(err) => panic!("{err}"), - }; - - let connect_request = ConnectRequest { - transaction_id: TransactionId::new(123), - }; - - match client.send(connect_request.into()).await { - Ok(_) => (), - Err(err) => panic!("{err}"), - }; - - let response = match client.receive().await { - Ok(response) => response, - Err(err) => panic!("{err}"), - }; - - assert!(is_connect_response(&response, TransactionId::new(123))); - - env.stop().await; - } -} - -mod receiving_an_announce_request { - use std::net::Ipv4Addr; - - use aquatic_udp_protocol::{ - AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, - PeerKey, Port, TransactionId, - }; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; - use torrust_tracker_configuration::DEFAULT_TIMEOUT; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; - - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::udp::asserts::is_ipv4_announce_response; - use crate::servers::udp::contract::send_connection_request; - use crate::servers::udp::Started; - - pub async fn send_and_get_announce(tx_id: TransactionId, c_id: ConnectionId, client: &UdpTrackerClient) { - // Send announce request - - let announce_request = AnnounceRequest { - connection_id: ConnectionId(c_id.0), - action_placeholder: AnnounceActionPlaceholder::default(), - transaction_id: tx_id, - info_hash: InfoHash([0u8; 20]), - peer_id: PeerId([255u8; 20]), - bytes_downloaded: NumberOfBytes(0i64.into()), - bytes_uploaded: NumberOfBytes(0i64.into()), - bytes_left: NumberOfBytes(0i64.into()), - event: AnnounceEvent::Started.into(), - ip_address: Ipv4Addr::new(0, 0, 0, 0).into(), - key: PeerKey::new(0i32), - peers_wanted: NumberOfPeers(1i32.into()), - port: Port(client.client.socket.local_addr().unwrap().port().into()), - }; - - match client.send(announce_request.into()).await { - Ok(_) => (), - Err(err) => panic!("{err}"), - }; - - let response = match client.receive().await { - Ok(response) => response, - Err(err) => panic!("{err}"), - }; - - // println!("test response {response:?}"); - - assert!(is_ipv4_announce_response(&response)); - } - - #[tokio::test] - async fn should_return_an_announce_response() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { - Ok(udp_tracker_client) => udp_tracker_client, - Err(err) => panic!("{err}"), - }; - - let tx_id = TransactionId::new(123); - - let c_id = send_connection_request(tx_id, &client).await; - - send_and_get_announce(tx_id, c_id, &client).await; - - env.stop().await; - } - - #[tokio::test] - async fn should_return_many_announce_response() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { - Ok(udp_tracker_client) => udp_tracker_client, - Err(err) => panic!("{err}"), - }; - - let tx_id = TransactionId::new(123); - - let c_id = send_connection_request(tx_id, &client).await; - - for x in 0..1000 { - tracing::info!("req no: {x}"); - send_and_get_announce(tx_id, c_id, &client).await; - } - - env.stop().await; - } -} - -mod receiving_an_scrape_request { - use aquatic_udp_protocol::{ConnectionId, InfoHash, ScrapeRequest, TransactionId}; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; - use torrust_tracker_configuration::DEFAULT_TIMEOUT; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; - - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::udp::asserts::is_scrape_response; - use crate::servers::udp::contract::send_connection_request; - use crate::servers::udp::Started; - - #[tokio::test] - async fn should_return_a_scrape_response() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - - let client = match UdpTrackerClient::new(env.bind_address(), DEFAULT_TIMEOUT).await { - Ok(udp_tracker_client) => udp_tracker_client, - Err(err) => panic!("{err}"), - }; - - let connection_id = send_connection_request(TransactionId::new(123), &client).await; - - // Send scrape request - - // Full scrapes are not allowed you need to pass an array of info hashes otherwise - // it will return "bad request" error with empty vector - - let empty_info_hash = vec![InfoHash([0u8; 20])]; - - let scrape_request = ScrapeRequest { - connection_id: ConnectionId(connection_id.0), - transaction_id: TransactionId::new(123i32), - info_hashes: empty_info_hash, - }; - - match client.send(scrape_request.into()).await { - Ok(_) => (), - Err(err) => panic!("{err}"), - }; - - let response = match client.receive().await { - Ok(response) => response, - Err(err) => panic!("{err}"), - }; - - assert!(is_scrape_response(&response)); - - env.stop().await; - } -} diff --git a/tests/servers/udp/environment.rs b/tests/servers/udp/environment.rs deleted file mode 100644 index b7ac2336c..000000000 --- a/tests/servers/udp/environment.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use torrust_tracker::bootstrap::app::initialize_with_configuration; -use torrust_tracker::core::Tracker; -use torrust_tracker::servers::registar::Registar; -use torrust_tracker::servers::udp::server::spawner::Spawner; -use torrust_tracker::servers::udp::server::states::{Running, Stopped}; -use torrust_tracker::servers::udp::server::Server; -use torrust_tracker_configuration::{Configuration, UdpTracker, DEFAULT_TIMEOUT}; -use torrust_tracker_primitives::info_hash::InfoHash; -use torrust_tracker_primitives::peer; - -pub struct Environment -where - S: std::fmt::Debug + std::fmt::Display, -{ - pub config: Arc, - pub tracker: Arc, - pub registar: Registar, - pub server: Server, -} - -impl Environment -where - S: std::fmt::Debug + std::fmt::Display, -{ - /// Add a torrent to the tracker - #[allow(dead_code)] - pub fn add_torrent(&self, info_hash: &InfoHash, peer: &peer::Peer) { - self.tracker.upsert_peer_and_get_stats(info_hash, peer); - } -} - -impl Environment { - #[allow(dead_code)] - pub fn new(configuration: &Arc) -> Self { - let tracker = initialize_with_configuration(configuration); - - let udp_tracker = configuration.udp_trackers.clone().expect("missing UDP tracker configuration"); - - let config = Arc::new(udp_tracker[0].clone()); - - let bind_to = config.bind_address; - - let server = Server::new(Spawner::new(bind_to)); - - Self { - config, - tracker, - registar: Registar::default(), - server, - } - } - - #[allow(dead_code)] - pub async fn start(self) -> Environment { - Environment { - config: self.config, - tracker: self.tracker.clone(), - registar: self.registar.clone(), - server: self.server.start(self.tracker, self.registar.give_form()).await.unwrap(), - } - } -} - -impl Environment { - pub async fn new(configuration: &Arc) -> Self { - tokio::time::timeout(DEFAULT_TIMEOUT, Environment::::new(configuration).start()) - .await - .expect("it should create an environment within the timeout") - } - - #[allow(dead_code)] - pub async fn stop(self) -> Environment { - let stopped = tokio::time::timeout(DEFAULT_TIMEOUT, self.server.stop()) - .await - .expect("it should stop the environment within the timeout"); - - Environment { - config: self.config, - tracker: self.tracker, - registar: Registar::default(), - server: stopped.expect("it stop the udp tracker service"), - } - } - - pub fn bind_address(&self) -> SocketAddr { - self.server.state.local_addr - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use tokio::time::sleep; - use torrust_tracker_test_helpers::configuration; - use tracing::level_filters::LevelFilter; - - use crate::common::logging::{tracing_stderr_init, INIT}; - use crate::servers::udp::Started; - - #[tokio::test] - async fn it_should_make_and_stop_udp_server() { - INIT.call_once(|| { - tracing_stderr_init(LevelFilter::ERROR); - }); - - let env = Started::new(&configuration::ephemeral().into()).await; - sleep(Duration::from_secs(1)).await; - env.stop().await; - sleep(Duration::from_secs(1)).await; - } -} diff --git a/tests/servers/udp/mod.rs b/tests/servers/udp/mod.rs deleted file mode 100644 index 7eea8683f..000000000 --- a/tests/servers/udp/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -use torrust_tracker::servers::udp::server::states::Running; - -pub mod asserts; -pub mod contract; -pub mod environment; - -pub type Started = environment::Environment;