From 093e8c9bbb86bde793c8f8903f25b52aa2d7c80d Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 1 Nov 2024 16:31:08 +0000 Subject: [PATCH 1/4] feat: extract new package bittorrent-tracker-client This will allow other projects to reuse the tracker lib clients and console clients. --- Cargo.lock | 32 ++ Cargo.toml | 2 + packages/tracker-client/Cargo.toml | 42 +++ packages/tracker-client/README.md | 25 ++ .../docs/licenses/LICENSE-MIT_0 | 14 + .../src/bin/http_tracker_client.rs | 7 + .../tracker-client/src/bin/tracker_checker.rs | 7 + .../src/bin/udp_tracker_client.rs | 7 + .../src/console/clients/checker/app.rs | 120 ++++++++ .../console/clients/checker/checks/health.rs | 77 +++++ .../console/clients/checker/checks/http.rs | 104 +++++++ .../src/console/clients/checker/checks/mod.rs | 4 + .../console/clients/checker/checks/structs.rs | 12 + .../src/console/clients/checker/checks/udp.rs | 134 +++++++++ .../src/console/clients/checker/config.rs | 282 ++++++++++++++++++ .../src/console/clients/checker/console.rs | 38 +++ .../src/console/clients/checker/logger.rs | 72 +++++ .../src/console/clients/checker/mod.rs | 7 + .../src/console/clients/checker/printer.rs | 9 + .../src/console/clients/checker/service.rs | 62 ++++ .../src/console/clients/http/app.rs | 102 +++++++ .../src/console/clients/http/mod.rs | 34 +++ .../tracker-client/src/console/clients/mod.rs | 4 + .../src/console/clients/udp/app.rs | 208 +++++++++++++ .../src/console/clients/udp/checker.rs | 177 +++++++++++ .../src/console/clients/udp/mod.rs | 51 ++++ .../src/console/clients/udp/responses/dto.rs | 128 ++++++++ .../src/console/clients/udp/responses/json.rs | 25 ++ .../src/console/clients/udp/responses/mod.rs | 2 + packages/tracker-client/src/console/mod.rs | 2 + .../tracker-client/src/http/client/mod.rs | 220 ++++++++++++++ .../src/http/client/requests/announce.rs | 275 +++++++++++++++++ .../src/http/client/requests/mod.rs | 2 + .../src/http/client/requests/scrape.rs | 172 +++++++++++ .../src/http/client/responses/announce.rs | 126 ++++++++ .../src/http/client/responses/error.rs | 7 + .../src/http/client/responses/mod.rs | 3 + .../src/http/client/responses/scrape.rs | 230 ++++++++++++++ packages/tracker-client/src/http/mod.rs | 27 ++ .../tracker-client/src/http/url_encoding.rs | 132 ++++++++ packages/tracker-client/src/lib.rs | 3 + packages/tracker-client/src/udp/client.rs | 270 +++++++++++++++++ packages/tracker-client/src/udp/mod.rs | 68 +++++ 43 files changed, 3325 insertions(+) create mode 100644 packages/tracker-client/Cargo.toml create mode 100644 packages/tracker-client/README.md create mode 100644 packages/tracker-client/docs/licenses/LICENSE-MIT_0 create mode 100644 packages/tracker-client/src/bin/http_tracker_client.rs create mode 100644 packages/tracker-client/src/bin/tracker_checker.rs create mode 100644 packages/tracker-client/src/bin/udp_tracker_client.rs create mode 100644 packages/tracker-client/src/console/clients/checker/app.rs create mode 100644 packages/tracker-client/src/console/clients/checker/checks/health.rs create mode 100644 packages/tracker-client/src/console/clients/checker/checks/http.rs create mode 100644 packages/tracker-client/src/console/clients/checker/checks/mod.rs create mode 100644 packages/tracker-client/src/console/clients/checker/checks/structs.rs create mode 100644 packages/tracker-client/src/console/clients/checker/checks/udp.rs create mode 100644 packages/tracker-client/src/console/clients/checker/config.rs create mode 100644 packages/tracker-client/src/console/clients/checker/console.rs create mode 100644 packages/tracker-client/src/console/clients/checker/logger.rs create mode 100644 packages/tracker-client/src/console/clients/checker/mod.rs create mode 100644 packages/tracker-client/src/console/clients/checker/printer.rs create mode 100644 packages/tracker-client/src/console/clients/checker/service.rs create mode 100644 packages/tracker-client/src/console/clients/http/app.rs create mode 100644 packages/tracker-client/src/console/clients/http/mod.rs create mode 100644 packages/tracker-client/src/console/clients/mod.rs create mode 100644 packages/tracker-client/src/console/clients/udp/app.rs create mode 100644 packages/tracker-client/src/console/clients/udp/checker.rs create mode 100644 packages/tracker-client/src/console/clients/udp/mod.rs create mode 100644 packages/tracker-client/src/console/clients/udp/responses/dto.rs create mode 100644 packages/tracker-client/src/console/clients/udp/responses/json.rs create mode 100644 packages/tracker-client/src/console/clients/udp/responses/mod.rs create mode 100644 packages/tracker-client/src/console/mod.rs create mode 100644 packages/tracker-client/src/http/client/mod.rs create mode 100644 packages/tracker-client/src/http/client/requests/announce.rs create mode 100644 packages/tracker-client/src/http/client/requests/mod.rs create mode 100644 packages/tracker-client/src/http/client/requests/scrape.rs create mode 100644 packages/tracker-client/src/http/client/responses/announce.rs create mode 100644 packages/tracker-client/src/http/client/responses/error.rs create mode 100644 packages/tracker-client/src/http/client/responses/mod.rs create mode 100644 packages/tracker-client/src/http/client/responses/scrape.rs create mode 100644 packages/tracker-client/src/http/mod.rs create mode 100644 packages/tracker-client/src/http/url_encoding.rs create mode 100644 packages/tracker-client/src/lib.rs create mode 100644 packages/tracker-client/src/udp/client.rs create mode 100644 packages/tracker-client/src/udp/mod.rs diff --git a/Cargo.lock b/Cargo.lock index bcb27fb43..00d83fddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,6 +616,37 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "bittorrent-tracker-client" +version = "3.0.0-develop" +dependencies = [ + "anyhow", + "aquatic_udp_protocol", + "bittorrent-primitives", + "clap", + "derive_more", + "futures", + "futures-util", + "hex-literal", + "hyper", + "percent-encoding", + "reqwest", + "serde", + "serde_bencode", + "serde_bytes", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "torrust-tracker-configuration", + "torrust-tracker-located-error", + "torrust-tracker-primitives", + "tracing", + "tracing-subscriber", + "url", + "zerocopy", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -3818,6 +3849,7 @@ dependencies = [ "axum-extra", "axum-server", "bittorrent-primitives", + "bittorrent-tracker-client", "camino", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index e42702d06..574881a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ axum-client-ip = "0" axum-extra = { version = "0", features = ["query"] } axum-server = { version = "0", features = ["tls-rustls"] } bittorrent-primitives = "0.1.0" +bittorrent-tracker-client = { version = "3.0.0-develop", path = "packages/tracker-client" } camino = { version = "1", features = ["serde", "serde1"] } chrono = { version = "0", default-features = false, features = ["clock"] } clap = { version = "4", features = ["derive", "env"] } @@ -100,6 +101,7 @@ members = [ "packages/primitives", "packages/test-helpers", "packages/torrent-repository", + "packages/tracker-client", ] [profile.dev] diff --git a/packages/tracker-client/Cargo.toml b/packages/tracker-client/Cargo.toml new file mode 100644 index 000000000..85e10c03e --- /dev/null +++ b/packages/tracker-client/Cargo.toml @@ -0,0 +1,42 @@ +[package] +description = "A library with the primitive types shared by the Torrust tracker packages." +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] +anyhow = "1" +aquatic_udp_protocol = "0" +bittorrent-primitives = "0.1.0" +clap = { version = "4", features = ["derive", "env"] } +derive_more = { version = "1", features = ["as_ref", "constructor", "from"] } +futures = "0" +futures-util = "0" +hex-literal = "0" +hyper = "1" +percent-encoding = "2" +reqwest = { version = "0", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_bencode = "0" +serde_bytes = "0" +serde_json = { version = "1", features = ["preserve_order"] } +serde_repr = "0" +thiserror = "1" +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" +tracing-subscriber = { version = "0", features = ["json"] } +url = { version = "2", features = ["serde"] } +zerocopy = "0.7" diff --git a/packages/tracker-client/README.md b/packages/tracker-client/README.md new file mode 100644 index 000000000..1d12f9c86 --- /dev/null +++ b/packages/tracker-client/README.md @@ -0,0 +1,25 @@ +# BitTorrent Tracker Client + +A library an console applications to interact with a BitTorrent tracker. + +> **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/packages/tracker-client/src/bin/http_tracker_client.rs b/packages/tracker-client/src/bin/http_tracker_client.rs new file mode 100644 index 000000000..8c2c0356d --- /dev/null +++ b/packages/tracker-client/src/bin/http_tracker_client.rs @@ -0,0 +1,7 @@ +//! Program to make request to HTTP trackers. +use bittorrent_tracker_client::console::clients::http::app; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + app::run().await +} diff --git a/packages/tracker-client/src/bin/tracker_checker.rs b/packages/tracker-client/src/bin/tracker_checker.rs new file mode 100644 index 000000000..eb2a7d82c --- /dev/null +++ b/packages/tracker-client/src/bin/tracker_checker.rs @@ -0,0 +1,7 @@ +//! Program to check running trackers. +use bittorrent_tracker_client::console::clients::checker::app; + +#[tokio::main] +async fn main() { + app::run().await.expect("Some checks fail"); +} diff --git a/packages/tracker-client/src/bin/udp_tracker_client.rs b/packages/tracker-client/src/bin/udp_tracker_client.rs new file mode 100644 index 000000000..5f6b4f50d --- /dev/null +++ b/packages/tracker-client/src/bin/udp_tracker_client.rs @@ -0,0 +1,7 @@ +//! Program to make request to UDP trackers. +use bittorrent_tracker_client::console::clients::udp::app; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + app::run().await +} diff --git a/packages/tracker-client/src/console/clients/checker/app.rs b/packages/tracker-client/src/console/clients/checker/app.rs new file mode 100644 index 000000000..395f65df9 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/app.rs @@ -0,0 +1,120 @@ +//! Program to run checks against running trackers. +//! +//! Run providing a config file path: +//! +//! ```text +//! cargo run --bin tracker_checker -- --config-path "./share/default/config/tracker_checker.json" +//! TORRUST_CHECKER_CONFIG_PATH="./share/default/config/tracker_checker.json" cargo run --bin tracker_checker +//! ``` +//! +//! Run providing the configuration: +//! +//! ```text +//! TORRUST_CHECKER_CONFIG=$(cat "./share/default/config/tracker_checker.json") cargo run --bin tracker_checker +//! ``` +//! +//! Another real example to test the Torrust demo tracker: +//! +//! ```text +//! TORRUST_CHECKER_CONFIG='{ +//! "udp_trackers": ["144.126.245.19:6969"], +//! "http_trackers": ["https://tracker.torrust-demo.com"], +//! "health_checks": ["https://tracker.torrust-demo.com/api/health_check"] +//! }' cargo run --bin tracker_checker +//! ``` +//! +//! The output should be something like the following: +//! +//! ```json +//! { +//! "udp_trackers": [ +//! { +//! "url": "144.126.245.19:6969", +//! "status": { +//! "code": "ok", +//! "message": "" +//! } +//! } +//! ], +//! "http_trackers": [ +//! { +//! "url": "https://tracker.torrust-demo.com/", +//! "status": { +//! "code": "ok", +//! "message": "" +//! } +//! } +//! ], +//! "health_checks": [ +//! { +//! "url": "https://tracker.torrust-demo.com/api/health_check", +//! "status": { +//! "code": "ok", +//! "message": "" +//! } +//! } +//! ] +//! } +//! ``` +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use tracing::level_filters::LevelFilter; + +use super::config::Configuration; +use super::console::Console; +use super::service::{CheckResult, Service}; +use crate::console::clients::checker::config::parse_from_json; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + /// Path to the JSON configuration file. + #[clap(short, long, env = "TORRUST_CHECKER_CONFIG_PATH")] + config_path: Option, + + /// Direct configuration content in JSON. + #[clap(env = "TORRUST_CHECKER_CONFIG", hide_env_values = true)] + config_content: Option, +} + +/// # Errors +/// +/// Will return an error if the configuration was not provided. +pub async fn run() -> Result> { + tracing_stdout_init(LevelFilter::INFO); + + let args = Args::parse(); + + let config = setup_config(args)?; + + let console_printer = Console {}; + + let service = Service { + config: Arc::new(config), + console: console_printer, + }; + + service.run_checks().await.context("it should run the check tasks") +} + +fn tracing_stdout_init(filter: LevelFilter) { + tracing_subscriber::fmt().with_max_level(filter).init(); + tracing::debug!("Logging initialized"); +} + +fn setup_config(args: Args) -> Result { + match (args.config_path, args.config_content) { + (Some(config_path), _) => load_config_from_file(&config_path), + (_, Some(config_content)) => parse_from_json(&config_content).context("invalid config format"), + _ => Err(anyhow::anyhow!("no configuration provided")), + } +} + +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:?}"))?; + + parse_from_json(&file_content).context("invalid config format") +} diff --git a/packages/tracker-client/src/console/clients/checker/checks/health.rs b/packages/tracker-client/src/console/clients/checker/checks/health.rs new file mode 100644 index 000000000..b1fb79148 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/checks/health.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use hyper::StatusCode; +use reqwest::{Client as HttpClient, Response}; +use serde::Serialize; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Clone, Error, Serialize)] +#[serde(into = "String")] +pub enum Error { + #[error("Failed to Build a Http Client: {err:?}")] + ClientBuildingError { err: Arc }, + #[error("Heath check failed to get a response: {err:?}")] + ResponseError { err: Arc }, + #[error("Http check returned a non-success code: \"{code}\" with the response: \"{response:?}\"")] + UnsuccessfulResponse { code: StatusCode, response: Arc }, +} + +impl From for String { + fn from(value: Error) -> Self { + value.to_string() + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Checks { + url: Url, + result: Result, +} + +pub async fn run(health_checks: Vec, timeout: Duration) -> Vec> { + let mut results = Vec::default(); + + tracing::debug!("Health checks ..."); + + for url in health_checks { + let result = match run_health_check(url.clone(), timeout).await { + Ok(response) => Ok(response.status().to_string()), + Err(err) => Err(err), + }; + + let check = Checks { url, result }; + + if check.result.is_err() { + results.push(Err(check)); + } else { + results.push(Ok(check)); + } + } + + results +} + +async fn run_health_check(url: Url, timeout: Duration) -> Result { + let client = HttpClient::builder() + .timeout(timeout) + .build() + .map_err(|e| Error::ClientBuildingError { err: e.into() })?; + + let response = client + .get(url.clone()) + .send() + .await + .map_err(|e| Error::ResponseError { err: e.into() })?; + + if response.status().is_success() { + Ok(response) + } else { + Err(Error::UnsuccessfulResponse { + code: response.status(), + response: response.into(), + }) + } +} diff --git a/packages/tracker-client/src/console/clients/checker/checks/http.rs b/packages/tracker-client/src/console/clients/checker/checks/http.rs new file mode 100644 index 000000000..48ce9678d --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/checks/http.rs @@ -0,0 +1,104 @@ +use std::str::FromStr as _; +use std::time::Duration; + +use bittorrent_primitives::info_hash::InfoHash; +use serde::Serialize; +use url::Url; + +use crate::console::clients::http::Error; +use crate::http::client::responses::announce::Announce; +use crate::http::client::responses::scrape; +use crate::http::client::{requests, Client}; + +#[derive(Debug, Clone, Serialize)] +pub struct Checks { + url: Url, + results: Vec<(Check, Result<(), Error>)>, +} + +#[derive(Debug, Clone, Serialize)] +pub enum Check { + Announce, + Scrape, +} + +pub async fn run(http_trackers: Vec, timeout: Duration) -> Vec> { + let mut results = Vec::default(); + + tracing::debug!("HTTP trackers ..."); + + for ref url in http_trackers { + let mut base_url = url.clone(); + base_url.set_path(""); + + let mut checks = Checks { + url: url.clone(), + results: Vec::default(), + }; + + // Announce + { + let check = check_http_announce(&base_url, timeout).await.map(|_| ()); + + checks.results.push((Check::Announce, check)); + } + + // Scrape + { + let check = check_http_scrape(&base_url, timeout).await.map(|_| ()); + + checks.results.push((Check::Scrape, check)); + } + + if checks.results.iter().any(|f| f.1.is_err()) { + results.push(Err(checks)); + } else { + results.push(Ok(checks)); + } + } + + results +} + +async fn check_http_announce(url: &Url, timeout: Duration) -> Result { + 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 })?; + + let response = client + .announce( + &requests::announce::QueryBuilder::with_default_values() + .with_info_hash(&info_hash) + .query(), + ) + .await + .map_err(|err| Error::HttpClientError { err })?; + + let response = response.bytes().await.map_err(|e| Error::ResponseError { err: e.into() })?; + + let response = serde_bencode::from_bytes::(&response).map_err(|e| Error::ParseBencodeError { + data: response, + err: e.into(), + })?; + + Ok(response) +} + +async fn check_http_scrape(url: &Url, timeout: Duration) -> Result { + 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 })?; + + let response = client.scrape(&query).await.map_err(|err| Error::HttpClientError { err })?; + + let response = response.bytes().await.map_err(|e| Error::ResponseError { err: e.into() })?; + + let response = scrape::Response::try_from_bencoded(&response).map_err(|e| Error::BencodeParseError { + data: response, + err: e.into(), + })?; + + Ok(response) +} diff --git a/packages/tracker-client/src/console/clients/checker/checks/mod.rs b/packages/tracker-client/src/console/clients/checker/checks/mod.rs new file mode 100644 index 000000000..f8b03f749 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/checks/mod.rs @@ -0,0 +1,4 @@ +pub mod health; +pub mod http; +pub mod structs; +pub mod udp; diff --git a/packages/tracker-client/src/console/clients/checker/checks/structs.rs b/packages/tracker-client/src/console/clients/checker/checks/structs.rs new file mode 100644 index 000000000..d28e20c04 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/checks/structs.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Status { + pub code: String, + pub message: String, +} +#[derive(Serialize, Deserialize)] +pub struct CheckerOutput { + pub url: String, + pub status: Status, +} diff --git a/packages/tracker-client/src/console/clients/checker/checks/udp.rs b/packages/tracker-client/src/console/clients/checker/checks/udp.rs new file mode 100644 index 000000000..21bdcd1b7 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/checks/udp.rs @@ -0,0 +1,134 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use aquatic_udp_protocol::TransactionId; +use hex_literal::hex; +use serde::Serialize; +use url::Url; + +use crate::console::clients::udp::checker::Client; +use crate::console::clients::udp::Error; + +#[derive(Debug, Clone, Serialize)] +pub struct Checks { + remote_addr: SocketAddr, + results: Vec<(Check, Result<(), Error>)>, +} + +#[derive(Debug, Clone, Serialize)] +pub enum Check { + Setup, + Connect, + Announce, + Scrape, +} + +#[allow(clippy::missing_panics_doc)] +pub async fn run(udp_trackers: Vec, timeout: Duration) -> Vec> { + let mut results = Vec::default(); + + tracing::debug!("UDP trackers ..."); + + let info_hash = aquatic_udp_protocol::InfoHash(hex!("9c38422213e30bff212b30c360d26f9a02136422")); // # DevSkim: ignore DS173237 + + for remote_url in udp_trackers { + let remote_addr = resolve_socket_addr(&remote_url); + + let mut checks = Checks { + remote_addr, + results: Vec::default(), + }; + + tracing::debug!("UDP tracker: {:?}", remote_url); + + // Setup + let client = match Client::new(remote_addr, timeout).await { + Ok(client) => { + checks.results.push((Check::Setup, Ok(()))); + client + } + Err(err) => { + checks.results.push((Check::Setup, Err(err))); + results.push(Err(checks)); + continue; + } + }; + + let transaction_id = TransactionId::new(1); + + // Connect Remote + let connection_id = match client.send_connection_request(transaction_id).await { + Ok(connection_id) => { + checks.results.push((Check::Connect, Ok(()))); + connection_id + } + Err(err) => { + checks.results.push((Check::Connect, Err(err))); + results.push(Err(checks)); + continue; + } + }; + + // Announce + { + let check = client + .send_announce_request(transaction_id, connection_id, info_hash.into()) + .await + .map(|_| ()); + + checks.results.push((Check::Announce, check)); + } + + // Scrape + { + let check = client + .send_scrape_request(connection_id, transaction_id, &[info_hash.into()]) + .await + .map(|_| ()); + + checks.results.push((Check::Scrape, check)); + } + + if checks.results.iter().any(|f| f.1.is_err()) { + results.push(Err(checks)); + } else { + results.push(Ok(checks)); + } + } + + results +} + +fn resolve_socket_addr(url: &Url) -> SocketAddr { + let socket_addr = url.socket_addrs(|| None).unwrap(); + *socket_addr.first().unwrap() +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use url::Url; + + use crate::console::clients::checker::checks::udp::resolve_socket_addr; + + #[test] + fn it_should_resolve_the_socket_address_for_udp_scheme_urls_containing_a_domain() { + 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) + ); + } + + #[test] + fn it_should_resolve_the_socket_address_for_udp_scheme_urls_containing_an_ip() { + 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) + ); + } +} diff --git a/packages/tracker-client/src/console/clients/checker/config.rs b/packages/tracker-client/src/console/clients/checker/config.rs new file mode 100644 index 000000000..154dcae85 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/config.rs @@ -0,0 +1,282 @@ +use std::error::Error; +use std::fmt; + +use reqwest::Url as ServiceUrl; +use serde::Deserialize; + +/// It parses the configuration from a JSON format. +/// +/// # Errors +/// +/// Will return an error if the configuration is not valid. +/// +/// # Panics +/// +/// Will panic if unable to read the configuration file. +pub fn parse_from_json(json: &str) -> Result { + let plain_config: PlainConfiguration = serde_json::from_str(json).map_err(ConfigurationError::JsonParseError)?; + Configuration::try_from(plain_config) +} + +/// DTO for the configuration to serialize/deserialize configuration. +/// +/// Configuration does not need to be valid. +#[derive(Deserialize)] +struct PlainConfiguration { + pub udp_trackers: Vec, + pub http_trackers: Vec, + pub health_checks: Vec, +} + +/// Validated configuration +pub struct Configuration { + pub udp_trackers: Vec, + pub http_trackers: Vec, + pub health_checks: Vec, +} + +#[derive(Debug)] +pub enum ConfigurationError { + JsonParseError(serde_json::Error), + InvalidUdpAddress(std::net::AddrParseError), + InvalidUrl(url::ParseError), +} + +impl Error for ConfigurationError {} + +impl fmt::Display for ConfigurationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigurationError::JsonParseError(e) => write!(f, "JSON parse error: {e}"), + ConfigurationError::InvalidUdpAddress(e) => write!(f, "Invalid UDP address: {e}"), + ConfigurationError::InvalidUrl(e) => write!(f, "Invalid URL: {e}"), + } + } +} + +impl TryFrom for Configuration { + type Error = ConfigurationError; + + fn try_from(plain_config: PlainConfiguration) -> Result { + let udp_trackers = plain_config + .udp_trackers + .into_iter() + .map(|s| if s.starts_with("udp://") { s } else { format!("udp://{s}") }) + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) + .collect::, _>>()?; + + let http_trackers = plain_config + .http_trackers + .into_iter() + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) + .collect::, _>>()?; + + let health_checks = plain_config + .health_checks + .into_iter() + .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) + .collect::, _>>()?; + + Ok(Configuration { + udp_trackers, + http_trackers, + health_checks, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configuration_should_be_build_from_plain_serializable_configuration() { + let dto = PlainConfiguration { + udp_trackers: vec!["udp://127.0.0.1:8080".to_string()], + http_trackers: vec!["http://127.0.0.1:8080".to_string()], + health_checks: vec!["http://127.0.0.1:8080/health".to_string()], + }; + + let config = Configuration::try_from(dto).expect("A valid configuration"); + + assert_eq!(config.udp_trackers, vec![ServiceUrl::parse("udp://127.0.0.1:8080").unwrap()]); + + assert_eq!( + config.http_trackers, + vec![ServiceUrl::parse("http://127.0.0.1:8080").unwrap()] + ); + + assert_eq!( + config.health_checks, + vec![ServiceUrl::parse("http://127.0.0.1:8080/health").unwrap()] + ); + } + + mod building_configuration_from_plain_configuration_for { + + mod udp_trackers { + use crate::console::clients::checker::config::{Configuration, PlainConfiguration, ServiceUrl}; + + /* The plain configuration should allow UDP URLs with: + + - IP or domain. + - With or without scheme. + - With or without `announce` suffix. + - With or without `/` at the end of the authority section (with empty path). + + For example: + + 127.0.0.1:6969 + 127.0.0.1:6969/ + 127.0.0.1:6969/announce + + localhost:6969 + localhost:6969/ + localhost:6969/announce + + udp://127.0.0.1:6969 + udp://127.0.0.1:6969/ + udp://127.0.0.1:6969/announce + + udp://localhost:6969 + udp://localhost:6969/ + udp://localhost:6969/announce + + */ + + #[test] + fn it_should_fail_when_a_tracker_udp_url_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec!["invalid URL".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + + #[test] + fn it_should_add_the_udp_scheme_to_the_udp_url_when_it_is_missing() { + let plain_config = PlainConfiguration { + udp_trackers: vec!["127.0.0.1:6969".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!(config.udp_trackers[0], "udp://127.0.0.1:6969".parse::().unwrap()); + } + + #[test] + fn it_should_allow_using_domains() { + let plain_config = PlainConfiguration { + udp_trackers: vec!["udp://localhost:6969".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!(config.udp_trackers[0], "udp://localhost:6969".parse::().unwrap()); + } + + #[test] + fn it_should_allow_the_url_to_have_an_empty_path() { + let plain_config = PlainConfiguration { + udp_trackers: vec!["127.0.0.1:6969/".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!(config.udp_trackers[0], "udp://127.0.0.1:6969/".parse::().unwrap()); + } + + #[test] + fn it_should_allow_the_url_to_contain_a_path() { + // This is the common format for UDP tracker URLs: + // udp://domain.com:6969/announce + + let plain_config = PlainConfiguration { + udp_trackers: vec!["127.0.0.1:6969/announce".to_string()], + http_trackers: vec![], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!( + config.udp_trackers[0], + "udp://127.0.0.1:6969/announce".parse::().unwrap() + ); + } + } + + mod http_trackers { + use crate::console::clients::checker::config::{Configuration, PlainConfiguration, ServiceUrl}; + + #[test] + fn it_should_fail_when_a_tracker_http_url_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec!["invalid URL".to_string()], + health_checks: vec![], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + + #[test] + fn it_should_allow_the_url_to_contain_a_path() { + // This is the common format for HTTP tracker URLs: + // http://domain.com:7070/announce + + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec!["http://127.0.0.1:7070/announce".to_string()], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!( + config.http_trackers[0], + "http://127.0.0.1:7070/announce".parse::().unwrap() + ); + } + + #[test] + fn it_should_allow_the_url_to_contain_an_empty_path() { + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec!["http://127.0.0.1:7070/".to_string()], + health_checks: vec![], + }; + + let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); + + assert_eq!( + config.http_trackers[0], + "http://127.0.0.1:7070/".parse::().unwrap() + ); + } + } + + mod health_checks { + use crate::console::clients::checker::config::{Configuration, PlainConfiguration}; + + #[test] + fn it_should_fail_when_a_health_check_http_url_is_invalid() { + let plain_config = PlainConfiguration { + udp_trackers: vec![], + http_trackers: vec![], + health_checks: vec!["invalid URL".to_string()], + }; + + assert!(Configuration::try_from(plain_config).is_err()); + } + } + } +} diff --git a/packages/tracker-client/src/console/clients/checker/console.rs b/packages/tracker-client/src/console/clients/checker/console.rs new file mode 100644 index 000000000..b55c559fc --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/console.rs @@ -0,0 +1,38 @@ +use super::printer::{Printer, CLEAR_SCREEN}; + +pub struct Console {} + +impl Default for Console { + fn default() -> Self { + Self::new() + } +} + +impl Console { + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +impl Printer for Console { + fn clear(&self) { + self.print(CLEAR_SCREEN); + } + + fn print(&self, output: &str) { + print!("{}", &output); + } + + fn eprint(&self, output: &str) { + eprint!("{}", &output); + } + + fn println(&self, output: &str) { + println!("{}", &output); + } + + fn eprintln(&self, output: &str) { + eprintln!("{}", &output); + } +} diff --git a/packages/tracker-client/src/console/clients/checker/logger.rs b/packages/tracker-client/src/console/clients/checker/logger.rs new file mode 100644 index 000000000..50e97189f --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/logger.rs @@ -0,0 +1,72 @@ +use std::cell::RefCell; + +use super::printer::{Printer, CLEAR_SCREEN}; + +pub struct Logger { + output: RefCell, +} + +impl Default for Logger { + fn default() -> Self { + Self::new() + } +} + +impl Logger { + #[must_use] + pub fn new() -> Self { + Self { + output: RefCell::new(String::new()), + } + } + + pub fn log(&self) -> String { + self.output.borrow().clone() + } +} + +impl Printer for Logger { + fn clear(&self) { + self.print(CLEAR_SCREEN); + } + + fn print(&self, output: &str) { + *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); + } + + fn eprint(&self, output: &str) { + *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); + } + + fn println(&self, output: &str) { + self.print(&format!("{}/n", &output)); + } + + fn eprintln(&self, output: &str) { + self.eprint(&format!("{}/n", &output)); + } +} + +#[cfg(test)] +mod tests { + use crate::console::clients::checker::logger::Logger; + use crate::console::clients::checker::printer::{Printer, CLEAR_SCREEN}; + + #[test] + fn should_capture_the_clear_screen_command() { + let console_logger = Logger::new(); + + console_logger.clear(); + + assert_eq!(CLEAR_SCREEN, console_logger.log()); + } + + #[test] + fn should_capture_the_print_command_output() { + let console_logger = Logger::new(); + + console_logger.print("OUTPUT"); + + assert_eq!("OUTPUT", console_logger.log()); + } +} diff --git a/packages/tracker-client/src/console/clients/checker/mod.rs b/packages/tracker-client/src/console/clients/checker/mod.rs new file mode 100644 index 000000000..d26a4a686 --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/mod.rs @@ -0,0 +1,7 @@ +pub mod app; +pub mod checks; +pub mod config; +pub mod console; +pub mod logger; +pub mod printer; +pub mod service; diff --git a/packages/tracker-client/src/console/clients/checker/printer.rs b/packages/tracker-client/src/console/clients/checker/printer.rs new file mode 100644 index 000000000..d590dfedb --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/printer.rs @@ -0,0 +1,9 @@ +pub const CLEAR_SCREEN: &str = "\x1B[2J\x1B[1;1H"; + +pub trait Printer { + fn clear(&self); + fn print(&self, output: &str); + fn eprint(&self, output: &str); + fn println(&self, output: &str); + fn eprintln(&self, output: &str); +} diff --git a/packages/tracker-client/src/console/clients/checker/service.rs b/packages/tracker-client/src/console/clients/checker/service.rs new file mode 100644 index 000000000..acd312d8c --- /dev/null +++ b/packages/tracker-client/src/console/clients/checker/service.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use futures::FutureExt as _; +use serde::Serialize; +use tokio::task::{JoinError, JoinSet}; +use torrust_tracker_configuration::DEFAULT_TIMEOUT; + +use super::checks::{health, http, udp}; +use super::config::Configuration; +use super::console::Console; +use crate::console::clients::checker::printer::Printer; + +pub struct Service { + pub(crate) config: Arc, + pub(crate) console: Console, +} + +#[derive(Debug, Clone, Serialize)] +pub enum CheckResult { + Udp(Result), + Http(Result), + Health(Result), +} + +impl Service { + /// # Errors + /// + /// It will return an error if some of the tests panic or otherwise fail to run. + /// On success it will return a vector of `Ok(())` of [`CheckResult`]. + /// + /// # Panics + /// + /// It would panic if `serde_json` produces invalid json for the `to_string_pretty` function. + pub async fn run_checks(self) -> Result, JoinError> { + tracing::info!("Running checks for trackers ..."); + + let mut check_results = Vec::default(); + + let mut checks = JoinSet::new(); + checks.spawn( + udp::run(self.config.udp_trackers.clone(), DEFAULT_TIMEOUT).map(|mut f| f.drain(..).map(CheckResult::Udp).collect()), + ); + checks.spawn( + http::run(self.config.http_trackers.clone(), DEFAULT_TIMEOUT) + .map(|mut f| f.drain(..).map(CheckResult::Http).collect()), + ); + checks.spawn( + health::run(self.config.health_checks.clone(), DEFAULT_TIMEOUT) + .map(|mut f| f.drain(..).map(CheckResult::Health).collect()), + ); + + while let Some(results) = checks.join_next().await { + check_results.append(&mut results?); + } + + let json_output = serde_json::json!(check_results); + self.console + .println(&serde_json::to_string_pretty(&json_output).expect("it should consume valid json")); + + Ok(check_results) + } +} diff --git a/packages/tracker-client/src/console/clients/http/app.rs b/packages/tracker-client/src/console/clients/http/app.rs new file mode 100644 index 000000000..8db6fe46d --- /dev/null +++ b/packages/tracker-client/src/console/clients/http/app.rs @@ -0,0 +1,102 @@ +//! HTTP Tracker client: +//! +//! Examples: +//! +//! `Announce` request: +//! +//! ```text +//! cargo run --bin http_tracker_client announce http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! `Scrape` request: +//! +//! ```text +//! cargo run --bin http_tracker_client scrape http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +use std::str::FromStr; +use std::time::Duration; + +use anyhow::Context; +use bittorrent_primitives::info_hash::InfoHash; +use clap::{Parser, Subcommand}; +use reqwest::Url; +use torrust_tracker_configuration::DEFAULT_TIMEOUT; + +use crate::http::client::requests::announce::QueryBuilder; +use crate::http::client::responses::announce::Announce; +use crate::http::client::responses::scrape; +use crate::http::client::{requests, Client}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Announce { tracker_url: String, info_hash: String }, + Scrape { tracker_url: String, info_hashes: Vec }, +} + +/// # Errors +/// +/// Will return an error if the command fails. +pub async fn run() -> anyhow::Result<()> { + let args = Args::parse(); + + match args.command { + Command::Announce { tracker_url, info_hash } => { + announce_command(tracker_url, info_hash, DEFAULT_TIMEOUT).await?; + } + Command::Scrape { + tracker_url, + info_hashes, + } => { + scrape_command(&tracker_url, &info_hashes, DEFAULT_TIMEOUT).await?; + } + } + + Ok(()) +} + +async fn announce_command(tracker_url: String, info_hash: String, timeout: Duration) -> anyhow::Result<()> { + let base_url = Url::parse(&tracker_url).context("failed to parse HTTP tracker base URL")?; + let info_hash = + InfoHash::from_str(&info_hash).expect("Invalid infohash. Example infohash: `9c38422213e30bff212b30c360d26f9a02136422`"); + + let response = Client::new(base_url, timeout)? + .announce(&QueryBuilder::with_default_values().with_info_hash(&info_hash).query()) + .await?; + + let body = response.bytes().await?; + + let announce_response: Announce = serde_bencode::from_bytes(&body) + .unwrap_or_else(|_| panic!("response body should be a valid announce response, got: \"{:#?}\"", &body)); + + let json = serde_json::to_string(&announce_response).context("failed to serialize scrape response into JSON")?; + + println!("{json}"); + + Ok(()) +} + +async fn scrape_command(tracker_url: &str, info_hashes: &[String], timeout: Duration) -> anyhow::Result<()> { + let base_url = Url::parse(tracker_url).context("failed to parse HTTP tracker base URL")?; + + let query = requests::scrape::Query::try_from(info_hashes).context("failed to parse infohashes")?; + + let response = Client::new(base_url, timeout)?.scrape(&query).await?; + + let body = response.bytes().await?; + + let scrape_response = scrape::Response::try_from_bencoded(&body) + .unwrap_or_else(|_| panic!("response body should be a valid scrape response, got: \"{:#?}\"", &body)); + + let json = serde_json::to_string(&scrape_response).context("failed to serialize scrape response into JSON")?; + + println!("{json}"); + + Ok(()) +} diff --git a/packages/tracker-client/src/console/clients/http/mod.rs b/packages/tracker-client/src/console/clients/http/mod.rs new file mode 100644 index 000000000..e4b6fbe57 --- /dev/null +++ b/packages/tracker-client/src/console/clients/http/mod.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use serde::Serialize; +use thiserror::Error; + +use crate::http::client::responses::scrape::BencodeParseError; + +pub mod app; + +#[derive(Debug, Clone, Error, Serialize)] +#[serde(into = "String")] +pub enum Error { + #[error("Http request did not receive a response within the timeout: {err:?}")] + HttpClientError { err: crate::http::client::Error }, + #[error("Http failed to get a response at all: {err:?}")] + ResponseError { err: Arc }, + #[error("Failed to deserialize the bencoded response data with the error: \"{err:?}\"")] + ParseBencodeError { + data: hyper::body::Bytes, + err: Arc, + }, + + #[error("Failed to deserialize the bencoded response data with the error: \"{err:?}\"")] + BencodeParseError { + data: hyper::body::Bytes, + err: Arc, + }, +} + +impl From for String { + fn from(value: Error) -> Self { + value.to_string() + } +} diff --git a/packages/tracker-client/src/console/clients/mod.rs b/packages/tracker-client/src/console/clients/mod.rs new file mode 100644 index 000000000..8492f8ba5 --- /dev/null +++ b/packages/tracker-client/src/console/clients/mod.rs @@ -0,0 +1,4 @@ +//! Console clients. +pub mod checker; +pub mod http; +pub mod udp; diff --git a/packages/tracker-client/src/console/clients/udp/app.rs b/packages/tracker-client/src/console/clients/udp/app.rs new file mode 100644 index 000000000..a2736c365 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/app.rs @@ -0,0 +1,208 @@ +//! UDP Tracker client: +//! +//! Examples: +//! +//! Announce request: +//! +//! ```text +//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! Announce response: +//! +//! ```json +//! { +//! "transaction_id": -888840697 +//! "announce_interval": 120, +//! "leechers": 0, +//! "seeders": 1, +//! "peers": [ +//! "123.123.123.123:51289" +//! ], +//! } +//! ``` +//! +//! Scrape request: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! Scrape response: +//! +//! ```json +//! { +//! "transaction_id": -888840697, +//! "torrent_stats": [ +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! }, +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! } +//! ] +//! } +//! ``` +//! +//! You can use an URL with instead of the socket address. For example: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! The protocol (`udp://`) in the URL is mandatory. The path (`\scrape`) is optional. It always uses `\scrape`. +use std::net::{SocketAddr, ToSocketAddrs}; +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 tracing::level_filters::LevelFilter; +use url::Url; + +use super::Error; +use crate::console::clients::udp::checker; +use crate::console::clients::udp::responses::dto::SerializableResponse; +use crate::console::clients::udp::responses::json::ToJson; + +const RANDOM_TRANSACTION_ID: i32 = -888_840_697; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Announce { + #[arg(value_parser = parse_socket_addr)] + tracker_socket_addr: SocketAddr, + #[arg(value_parser = parse_info_hash)] + info_hash: TorrustInfoHash, + }, + Scrape { + #[arg(value_parser = parse_socket_addr)] + tracker_socket_addr: SocketAddr, + #[arg(value_parser = parse_info_hash, num_args = 1..=74, value_delimiter = ' ')] + info_hashes: Vec, + }, +} + +/// # Errors +/// +/// Will return an error if the command fails. +/// +/// +pub async fn run() -> anyhow::Result<()> { + tracing_stdout_init(LevelFilter::INFO); + + let args = Args::parse(); + + let response = match args.command { + Command::Announce { + tracker_socket_addr: remote_addr, + info_hash, + } => handle_announce(remote_addr, &info_hash).await?, + Command::Scrape { + tracker_socket_addr: remote_addr, + info_hashes, + } => handle_scrape(remote_addr, &info_hashes).await?, + }; + + let response: SerializableResponse = response.into(); + let response_json = response.to_json_string()?; + + print!("{response_json}"); + + Ok(()) +} + +fn tracing_stdout_init(filter: LevelFilter) { + tracing_subscriber::fmt().with_max_level(filter).init(); + tracing::debug!("Logging initialized"); +} + +async fn handle_announce(remote_addr: SocketAddr, info_hash: &TorrustInfoHash) -> Result { + let transaction_id = TransactionId::new(RANDOM_TRANSACTION_ID); + + let client = checker::Client::new(remote_addr, DEFAULT_TIMEOUT).await?; + + let connection_id = client.send_connection_request(transaction_id).await?; + + client.send_announce_request(transaction_id, connection_id, *info_hash).await +} + +async fn handle_scrape(remote_addr: SocketAddr, info_hashes: &[TorrustInfoHash]) -> Result { + let transaction_id = TransactionId::new(RANDOM_TRANSACTION_ID); + + let client = checker::Client::new(remote_addr, DEFAULT_TIMEOUT).await?; + + let connection_id = client.send_connection_request(transaction_id).await?; + + client.send_scrape_request(connection_id, transaction_id, info_hashes).await +} + +fn parse_socket_addr(tracker_socket_addr_str: &str) -> anyhow::Result { + tracing::debug!("Tracker socket address: {tracker_socket_addr_str:#?}"); + + // Check if the address is a valid URL. If so, extract the host and port. + let resolved_addr = if let Ok(url) = Url::parse(tracker_socket_addr_str) { + tracing::debug!("Tracker socket address URL: {url:?}"); + + let host = url + .host_str() + .with_context(|| format!("invalid host in URL: `{tracker_socket_addr_str}`"))? + .to_owned(); + + let port = url + .port() + .with_context(|| format!("port not found in URL: `{tracker_socket_addr_str}`"))? + .to_owned(); + + (host, port) + } else { + // If not a URL, assume it's a host:port pair. + + let parts: Vec<&str> = tracker_socket_addr_str.split(':').collect(); + + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "invalid address format: `{}`. Expected format is host:port", + tracker_socket_addr_str + )); + } + + let host = parts[0].to_owned(); + + let port = parts[1] + .parse::() + .with_context(|| format!("invalid port: `{}`", parts[1]))? + .to_owned(); + + (host, port) + }; + + tracing::debug!("Resolved address: {resolved_addr:#?}"); + + // Perform DNS resolution. + let socket_addrs: Vec<_> = resolved_addr.to_socket_addrs()?.collect(); + if socket_addrs.is_empty() { + Err(anyhow::anyhow!("DNS resolution failed for `{}`", tracker_socket_addr_str)) + } else { + Ok(socket_addrs[0]) + } +} + +fn parse_info_hash(info_hash_str: &str) -> anyhow::Result { + TorrustInfoHash::from_str(info_hash_str) + .map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{info_hash_str}`: {e:?}"))) +} diff --git a/packages/tracker-client/src/console/clients/udp/checker.rs b/packages/tracker-client/src/console/clients/udp/checker.rs new file mode 100644 index 000000000..b9fd3a729 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/checker.rs @@ -0,0 +1,177 @@ +use std::net::{Ipv4Addr, SocketAddr}; +use std::num::NonZeroU16; +use std::time::Duration; + +use aquatic_udp_protocol::common::InfoHash; +use aquatic_udp_protocol::{ + AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, + PeerId, PeerKey, Port, Response, ScrapeRequest, TransactionId, +}; +use bittorrent_primitives::info_hash::InfoHash as TorrustInfoHash; + +use super::Error; +use crate::udp::client::UdpTrackerClient; + +/// A UDP Tracker client to make test requests (checks). +#[derive(Debug)] +pub struct Client { + client: UdpTrackerClient, +} + +impl Client { + /// Creates a new `[Client]` for checking a UDP Tracker Service + /// + /// # Errors + /// + /// It will error if unable to bind and connect to the udp remote address. + /// + pub async fn new(remote_addr: SocketAddr, timeout: Duration) -> Result { + let client = UdpTrackerClient::new(remote_addr, timeout) + .await + .map_err(|err| Error::UnableToBindAndConnect { remote_addr, err })?; + + Ok(Self { client }) + } + + /// Returns the local addr of this [`Client`]. + /// + /// # Errors + /// + /// This function will return an error if the socket is somehow not bound. + pub fn local_addr(&self) -> std::io::Result { + self.client.client.socket.local_addr() + } + + /// Sends a connection request to the UDP Tracker server. + /// + /// # Errors + /// + /// Will return and error if + /// + /// - It can't connect to the remote UDP socket. + /// - It can't make a connection request successfully to the remote UDP + /// server (after successfully connecting to the remote UDP socket). + /// + /// # Panics + /// + /// Will panic if it receives an unexpected response. + pub async fn send_connection_request(&self, transaction_id: TransactionId) -> Result { + tracing::debug!("Sending connection request with transaction id: {transaction_id:#?}"); + + let connect_request = ConnectRequest { transaction_id }; + + let _ = self + .client + .send(connect_request.into()) + .await + .map_err(|err| Error::UnableToSendConnectionRequest { err })?; + + let response = self + .client + .receive() + .await + .map_err(|err| Error::UnableToReceiveConnectResponse { err })?; + + match response { + Response::Connect(connect_response) => Ok(connect_response.connection_id), + _ => Err(Error::UnexpectedConnectionResponse { response }), + } + } + + /// Sends an announce request to the UDP Tracker server. + /// + /// # Errors + /// + /// Will return and error if the client is not connected. You have to connect + /// before calling this function. + /// + /// # Panics + /// + /// It will panic if the `local_address` has a zero port. + pub async fn send_announce_request( + &self, + transaction_id: TransactionId, + connection_id: ConnectionId, + info_hash: TorrustInfoHash, + ) -> Result { + tracing::debug!("Sending announce request with transaction id: {transaction_id:#?}"); + + let port = NonZeroU16::new( + self.client + .client + .socket + .local_addr() + .expect("it should get the local address") + .port(), + ) + .expect("it should no be zero"); + + let announce_request = AnnounceRequest { + connection_id, + action_placeholder: AnnounceActionPlaceholder::default(), + transaction_id, + info_hash: InfoHash(info_hash.bytes()), + peer_id: PeerId(*b"-qB00000000000000001"), + 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::new(port), + }; + + let _ = self + .client + .send(announce_request.into()) + .await + .map_err(|err| Error::UnableToSendAnnounceRequest { err })?; + + let response = self + .client + .receive() + .await + .map_err(|err| Error::UnableToReceiveAnnounceResponse { err })?; + + Ok(response) + } + + /// Sends a scrape request to the UDP Tracker server. + /// + /// # Errors + /// + /// Will return and error if the client is not connected. You have to connect + /// before calling this function. + pub async fn send_scrape_request( + &self, + connection_id: ConnectionId, + transaction_id: TransactionId, + info_hashes: &[TorrustInfoHash], + ) -> Result { + tracing::debug!("Sending scrape request with transaction id: {transaction_id:#?}"); + + let scrape_request = ScrapeRequest { + connection_id, + transaction_id, + info_hashes: info_hashes + .iter() + .map(|torrust_info_hash| InfoHash(torrust_info_hash.bytes())) + .collect(), + }; + + let _ = self + .client + .send(scrape_request.into()) + .await + .map_err(|err| Error::UnableToSendScrapeRequest { err })?; + + let response = self + .client + .receive() + .await + .map_err(|err| Error::UnableToReceiveScrapeResponse { err })?; + + Ok(response) + } +} diff --git a/packages/tracker-client/src/console/clients/udp/mod.rs b/packages/tracker-client/src/console/clients/udp/mod.rs new file mode 100644 index 000000000..ae6271a78 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/mod.rs @@ -0,0 +1,51 @@ +use std::net::SocketAddr; + +use aquatic_udp_protocol::Response; +use serde::Serialize; +use thiserror::Error; + +use crate::udp; + +pub mod app; +pub mod checker; +pub mod responses; + +#[derive(Error, Debug, Clone, Serialize)] +#[serde(into = "String")] +pub enum Error { + #[error("Failed to Connect to: {remote_addr}, with error: {err}")] + UnableToBindAndConnect { remote_addr: SocketAddr, err: udp::Error }, + + #[error("Failed to send a connection request, with error: {err}")] + UnableToSendConnectionRequest { err: udp::Error }, + + #[error("Failed to receive a connect response, with error: {err}")] + UnableToReceiveConnectResponse { err: udp::Error }, + + #[error("Failed to send a announce request, with error: {err}")] + UnableToSendAnnounceRequest { err: udp::Error }, + + #[error("Failed to receive a announce response, with error: {err}")] + UnableToReceiveAnnounceResponse { err: udp::Error }, + + #[error("Failed to send a scrape request, with error: {err}")] + UnableToSendScrapeRequest { err: udp::Error }, + + #[error("Failed to receive a scrape response, with error: {err}")] + UnableToReceiveScrapeResponse { err: udp::Error }, + + #[error("Failed to receive a response, with error: {err}")] + UnableToReceiveResponse { err: udp::Error }, + + #[error("Failed to get local address for connection: {err}")] + UnableToGetLocalAddr { err: udp::Error }, + + #[error("Failed to get a connection response: {response:?}")] + UnexpectedConnectionResponse { response: Response }, +} + +impl From for String { + fn from(value: Error) -> Self { + value.to_string() + } +} diff --git a/packages/tracker-client/src/console/clients/udp/responses/dto.rs b/packages/tracker-client/src/console/clients/udp/responses/dto.rs new file mode 100644 index 000000000..93320b0f7 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/responses/dto.rs @@ -0,0 +1,128 @@ +//! Aquatic responses are not serializable. These are the serializable wrappers. +use std::net::{Ipv4Addr, Ipv6Addr}; + +use aquatic_udp_protocol::Response::{self}; +use aquatic_udp_protocol::{AnnounceResponse, ConnectResponse, ErrorResponse, Ipv4AddrBytes, Ipv6AddrBytes, ScrapeResponse}; +use serde::Serialize; + +#[derive(Serialize)] +pub enum SerializableResponse { + Connect(ConnectSerializableResponse), + AnnounceIpv4(AnnounceSerializableResponse), + AnnounceIpv6(AnnounceSerializableResponse), + Scrape(ScrapeSerializableResponse), + Error(ErrorSerializableResponse), +} + +impl From for SerializableResponse { + fn from(response: Response) -> Self { + match response { + Response::Connect(response) => SerializableResponse::Connect(ConnectSerializableResponse::from(response)), + Response::AnnounceIpv4(response) => SerializableResponse::AnnounceIpv4(AnnounceSerializableResponse::from(response)), + Response::AnnounceIpv6(response) => SerializableResponse::AnnounceIpv6(AnnounceSerializableResponse::from(response)), + Response::Scrape(response) => SerializableResponse::Scrape(ScrapeSerializableResponse::from(response)), + Response::Error(response) => SerializableResponse::Error(ErrorSerializableResponse::from(response)), + } + } +} + +#[derive(Serialize)] +pub struct ConnectSerializableResponse { + transaction_id: i32, + connection_id: i64, +} + +impl From for ConnectSerializableResponse { + fn from(connect: ConnectResponse) -> Self { + Self { + transaction_id: connect.transaction_id.0.into(), + connection_id: connect.connection_id.0.into(), + } + } +} + +#[derive(Serialize)] +pub struct AnnounceSerializableResponse { + transaction_id: i32, + announce_interval: i32, + leechers: i32, + seeders: i32, + peers: Vec, +} + +impl From> for AnnounceSerializableResponse { + fn from(announce: AnnounceResponse) -> Self { + Self { + transaction_id: announce.fixed.transaction_id.0.into(), + announce_interval: announce.fixed.announce_interval.0.into(), + leechers: announce.fixed.leechers.0.into(), + seeders: announce.fixed.seeders.0.into(), + peers: announce + .peers + .iter() + .map(|peer| format!("{}:{}", Ipv4Addr::from(peer.ip_address), peer.port.0)) + .collect::>(), + } + } +} + +impl From> for AnnounceSerializableResponse { + fn from(announce: AnnounceResponse) -> Self { + Self { + transaction_id: announce.fixed.transaction_id.0.into(), + announce_interval: announce.fixed.announce_interval.0.into(), + leechers: announce.fixed.leechers.0.into(), + seeders: announce.fixed.seeders.0.into(), + peers: announce + .peers + .iter() + .map(|peer| format!("{}:{}", Ipv6Addr::from(peer.ip_address), peer.port.0)) + .collect::>(), + } + } +} + +#[derive(Serialize)] +pub struct ScrapeSerializableResponse { + transaction_id: i32, + torrent_stats: Vec, +} + +impl From for ScrapeSerializableResponse { + fn from(scrape: ScrapeResponse) -> Self { + Self { + transaction_id: scrape.transaction_id.0.into(), + torrent_stats: scrape + .torrent_stats + .iter() + .map(|torrent_scrape_statistics| TorrentStats { + seeders: torrent_scrape_statistics.seeders.0.into(), + completed: torrent_scrape_statistics.completed.0.into(), + leechers: torrent_scrape_statistics.leechers.0.into(), + }) + .collect::>(), + } + } +} + +#[derive(Serialize)] +pub struct ErrorSerializableResponse { + transaction_id: i32, + message: String, +} + +impl From for ErrorSerializableResponse { + fn from(error: ErrorResponse) -> Self { + Self { + transaction_id: error.transaction_id.0.into(), + message: error.message.to_string(), + } + } +} + +#[derive(Serialize)] +struct TorrentStats { + seeders: i32, + completed: i32, + leechers: i32, +} diff --git a/packages/tracker-client/src/console/clients/udp/responses/json.rs b/packages/tracker-client/src/console/clients/udp/responses/json.rs new file mode 100644 index 000000000..5d2bd6b89 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/responses/json.rs @@ -0,0 +1,25 @@ +use anyhow::Context; +use serde::Serialize; + +use super::dto::SerializableResponse; + +#[allow(clippy::module_name_repetitions)] +pub trait ToJson { + /// + /// Returns a string with the JSON serialized version of the response + /// + /// # Errors + /// + /// Will return an error if serialization fails. + /// + fn to_json_string(&self) -> anyhow::Result + where + Self: Serialize, + { + let pretty_json = serde_json::to_string_pretty(self).context("response JSON serialization")?; + + Ok(pretty_json) + } +} + +impl ToJson for SerializableResponse {} diff --git a/packages/tracker-client/src/console/clients/udp/responses/mod.rs b/packages/tracker-client/src/console/clients/udp/responses/mod.rs new file mode 100644 index 000000000..e6d2e5e51 --- /dev/null +++ b/packages/tracker-client/src/console/clients/udp/responses/mod.rs @@ -0,0 +1,2 @@ +pub mod dto; +pub mod json; diff --git a/packages/tracker-client/src/console/mod.rs b/packages/tracker-client/src/console/mod.rs new file mode 100644 index 000000000..4b4cb9de4 --- /dev/null +++ b/packages/tracker-client/src/console/mod.rs @@ -0,0 +1,2 @@ +//! Console apps. +pub mod clients; diff --git a/packages/tracker-client/src/http/client/mod.rs b/packages/tracker-client/src/http/client/mod.rs new file mode 100644 index 000000000..3c904a7c9 --- /dev/null +++ b/packages/tracker-client/src/http/client/mod.rs @@ -0,0 +1,220 @@ +pub mod requests; +pub mod responses; + +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; + +#[derive(Debug, Clone, Error)] +pub enum Error { + #[error("Failed to Build a Http Client: {err:?}")] + ClientBuildingError { err: Arc }, + #[error("Failed to get a response: {err:?}")] + ResponseError { err: Arc }, + #[error("Returned a non-success code: \"{code}\" with the response: \"{response:?}\"")] + UnsuccessfulResponse { code: StatusCode, response: Arc }, +} + +/// HTTP Tracker Client +pub struct Client { + client: reqwest::Client, + base_url: Url, + key: Option, +} + +/// URL components in this context: +/// +/// ```text +/// http://127.0.0.1:62304/announce/YZ....rJ?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 +/// \_____________________/\_______________/ \__________________________________________________________/ +/// | | | +/// base url path query +/// ``` +impl Client { + /// # Errors + /// + /// This method fails if the client builder fails. + pub fn new(base_url: Url, timeout: Duration) -> Result { + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|e| Error::ClientBuildingError { err: e.into() })?; + + Ok(Self { + base_url, + client, + key: None, + }) + } + + /// Creates the new client binding it to an specific local address. + /// + /// # Errors + /// + /// This method fails if the client builder fails. + pub fn bind(base_url: Url, timeout: Duration, local_address: IpAddr) -> Result { + let client = reqwest::Client::builder() + .timeout(timeout) + .local_address(local_address) + .build() + .map_err(|e| Error::ClientBuildingError { err: e.into() })?; + + Ok(Self { + base_url, + client, + key: None, + }) + } + + /// # Errors + /// + /// This method fails if the client builder fails. + pub fn authenticated(base_url: Url, timeout: Duration, key: Key) -> Result { + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|e| Error::ClientBuildingError { err: e.into() })?; + + Ok(Self { + base_url, + client, + key: Some(key), + }) + } + + /// # Errors + /// + /// This method fails if the returned response was not successful + pub async fn announce(&self, query: &announce::Query) -> Result { + let response = self.get(&self.build_announce_path_and_query(query)).await?; + + if response.status().is_success() { + Ok(response) + } else { + Err(Error::UnsuccessfulResponse { + code: response.status(), + response: response.into(), + }) + } + } + + /// # Errors + /// + /// This method fails if the returned response was not successful + pub async fn scrape(&self, query: &scrape::Query) -> Result { + let response = self.get(&self.build_scrape_path_and_query(query)).await?; + + if response.status().is_success() { + Ok(response) + } else { + Err(Error::UnsuccessfulResponse { + code: response.status(), + response: response.into(), + }) + } + } + + /// # Errors + /// + /// This method fails if the returned response was not successful + pub async fn announce_with_header(&self, query: &announce::Query, key: &str, value: &str) -> Result { + let response = self + .get_with_header(&self.build_announce_path_and_query(query), key, value) + .await?; + + if response.status().is_success() { + Ok(response) + } else { + Err(Error::UnsuccessfulResponse { + code: response.status(), + response: response.into(), + }) + } + } + + /// # Errors + /// + /// This method fails if the returned response was not successful + pub async fn health_check(&self) -> Result { + let response = self.get(&self.build_path("health_check")).await?; + + if response.status().is_success() { + Ok(response) + } else { + Err(Error::UnsuccessfulResponse { + code: response.status(), + response: response.into(), + }) + } + } + + /// # Errors + /// + /// This method fails if there was an error while sending request. + pub async fn get(&self, path: &str) -> Result { + self.client + .get(self.build_url(path)) + .send() + .await + .map_err(|e| Error::ResponseError { err: e.into() }) + } + + /// # Errors + /// + /// 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 + .get(self.build_url(path)) + .header(key, value) + .send() + .await + .map_err(|e| Error::ResponseError { err: e.into() }) + } + + fn build_announce_path_and_query(&self, query: &announce::Query) -> String { + format!("{}?{query}", self.build_path("announce")) + } + + fn build_scrape_path_and_query(&self, query: &scrape::Query) -> String { + format!("{}?{query}", self.build_path("scrape")) + } + + fn build_path(&self, path: &str) -> String { + match &self.key { + Some(key) => format!("{path}/{key}"), + None => path.to_string(), + } + } + + fn build_url(&self, path: &str) -> String { + let base_url = self.base_url(); + format!("{base_url}{path}") + } + + fn base_url(&self) -> String { + 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/packages/tracker-client/src/http/client/requests/announce.rs b/packages/tracker-client/src/http/client/requests/announce.rs new file mode 100644 index 000000000..8f81cc80e --- /dev/null +++ b/packages/tracker-client/src/http/client/requests/announce.rs @@ -0,0 +1,275 @@ +use std::fmt; +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 crate::http::{percent_encode_byte_array, ByteArray20}; + +pub struct Query { + pub info_hash: ByteArray20, + pub peer_addr: IpAddr, + pub downloaded: BaseTenASCII, + pub uploaded: BaseTenASCII, + pub peer_id: ByteArray20, + pub port: PortNumber, + pub left: BaseTenASCII, + pub event: Option, + pub compact: Option, +} + +impl fmt::Display for Query { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.build()) + } +} + +/// HTTP Tracker Announce Request: +/// +/// +/// +/// Some parameters in the specification are not implemented in this tracker yet. +impl Query { + /// It builds the URL query component for the announce request. + /// + /// This custom URL query params encoding is needed because `reqwest` does not allow + /// bytes arrays in query parameters. More info on this issue: + /// + /// + #[must_use] + pub fn build(&self) -> String { + self.params().to_string() + } + + #[must_use] + pub fn params(&self) -> QueryParams { + QueryParams::from(self) + } +} + +pub type BaseTenASCII = u64; +pub type PortNumber = u16; + +pub enum Event { + //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::Completed => write!(f, "completed"), + } + } +} + +#[derive(Serialize_repr, PartialEq, Debug)] +#[repr(u8)] +pub enum Compact { + Accepted = 1, + NotAccepted = 0, +} + +impl fmt::Display for Compact { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Compact::Accepted => write!(f, "1"), + Compact::NotAccepted => write!(f, "0"), + } + } +} + +pub struct QueryBuilder { + announce_query: Query, +} + +impl QueryBuilder { + /// # Panics + /// + /// Will panic if the default info-hash value is not a valid info-hash. + #[must_use] + pub fn with_default_values() -> QueryBuilder { + let default_announce_query = Query { + 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), + compact: Some(Compact::NotAccepted), + }; + Self { + announce_query: default_announce_query, + } + } + + #[must_use] + pub fn with_info_hash(mut self, info_hash: &InfoHash) -> Self { + self.announce_query.info_hash = info_hash.0; + self + } + + #[must_use] + pub fn with_peer_id(mut self, peer_id: &PeerId) -> Self { + self.announce_query.peer_id = peer_id.0; + self + } + + #[must_use] + pub fn with_compact(mut self, compact: Compact) -> Self { + self.announce_query.compact = Some(compact); + self + } + + #[must_use] + pub fn with_peer_addr(mut self, peer_addr: &IpAddr) -> Self { + self.announce_query.peer_addr = *peer_addr; + self + } + + #[must_use] + pub fn without_compact(mut self) -> Self { + self.announce_query.compact = None; + self + } + + #[must_use] + pub fn query(self) -> Query { + self.announce_query + } +} + +/// It contains all the GET parameters that can be used in a HTTP Announce request. +/// +/// Sample Announce URL with all the GET parameters (mandatory and optional): +/// +/// ```text +/// http://127.0.0.1:7070/announce? +/// info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 (mandatory) +/// peer_addr=192.168.1.88 +/// downloaded=0 +/// uploaded=0 +/// peer_id=%2DqB00000000000000000 (mandatory) +/// port=17548 (mandatory) +/// left=0 +/// event=completed +/// compact=0 +/// ``` +pub struct QueryParams { + pub info_hash: Option, + pub peer_addr: Option, + pub downloaded: Option, + pub uploaded: Option, + pub peer_id: Option, + pub port: Option, + pub left: Option, + pub event: Option, + pub compact: Option, +} + +impl std::fmt::Display for QueryParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut params = vec![]; + + if let Some(info_hash) = &self.info_hash { + params.push(("info_hash", info_hash)); + } + if let Some(peer_addr) = &self.peer_addr { + params.push(("peer_addr", peer_addr)); + } + if let Some(downloaded) = &self.downloaded { + params.push(("downloaded", downloaded)); + } + if let Some(uploaded) = &self.uploaded { + params.push(("uploaded", uploaded)); + } + if let Some(peer_id) = &self.peer_id { + params.push(("peer_id", peer_id)); + } + if let Some(port) = &self.port { + params.push(("port", port)); + } + if let Some(left) = &self.left { + params.push(("left", left)); + } + if let Some(event) = &self.event { + params.push(("event", event)); + } + if let Some(compact) = &self.compact { + params.push(("compact", compact)); + } + + let query = params + .iter() + .map(|param| format!("{}={}", param.0, param.1)) + .collect::>() + .join("&"); + + write!(f, "{query}") + } +} + +impl QueryParams { + pub fn from(announce_query: &Query) -> Self { + let event = announce_query.event.as_ref().map(std::string::ToString::to_string); + let compact = announce_query.compact.as_ref().map(std::string::ToString::to_string); + + Self { + info_hash: Some(percent_encode_byte_array(&announce_query.info_hash)), + peer_addr: Some(announce_query.peer_addr.to_string()), + downloaded: Some(announce_query.downloaded.to_string()), + uploaded: Some(announce_query.uploaded.to_string()), + peer_id: Some(percent_encode_byte_array(&announce_query.peer_id)), + port: Some(announce_query.port.to_string()), + left: Some(announce_query.left.to_string()), + event, + compact, + } + } + + pub fn remove_optional_params(&mut self) { + // todo: make them optional with the Option<...> in the AnnounceQuery struct + // if they are really optional. So that we can crete a minimal AnnounceQuery + // instead of removing the optional params afterwards. + // + // The original specification on: + // + // says only `ip` and `event` are optional. + // + // On + // says only `ip`, `numwant`, `key` and `trackerid` are optional. + // + // but the server is responding if all these params are not included. + self.peer_addr = None; + self.downloaded = None; + self.uploaded = None; + self.left = None; + self.event = None; + self.compact = None; + } + + /// # Panics + /// + /// Will panic if invalid param name is provided. + pub fn set(&mut self, param_name: &str, param_value: &str) { + match param_name { + "info_hash" => self.info_hash = Some(param_value.to_string()), + "peer_addr" => self.peer_addr = Some(param_value.to_string()), + "downloaded" => self.downloaded = Some(param_value.to_string()), + "uploaded" => self.uploaded = Some(param_value.to_string()), + "peer_id" => self.peer_id = Some(param_value.to_string()), + "port" => self.port = Some(param_value.to_string()), + "left" => self.left = Some(param_value.to_string()), + "event" => self.event = Some(param_value.to_string()), + "compact" => self.compact = Some(param_value.to_string()), + &_ => panic!("Invalid param name for announce query"), + } + } +} diff --git a/packages/tracker-client/src/http/client/requests/mod.rs b/packages/tracker-client/src/http/client/requests/mod.rs new file mode 100644 index 000000000..776d2dfbf --- /dev/null +++ b/packages/tracker-client/src/http/client/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod announce; +pub mod scrape; diff --git a/packages/tracker-client/src/http/client/requests/scrape.rs b/packages/tracker-client/src/http/client/requests/scrape.rs new file mode 100644 index 000000000..1b423390b --- /dev/null +++ b/packages/tracker-client/src/http/client/requests/scrape.rs @@ -0,0 +1,172 @@ +use std::error::Error; +use std::fmt::{self}; +use std::str::FromStr; + +use bittorrent_primitives::info_hash::InfoHash; + +use crate::http::{percent_encode_byte_array, ByteArray20}; + +pub struct Query { + pub info_hash: Vec, +} + +impl fmt::Display for Query { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.build()) + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct ConversionError(String); + +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid infohash: {}", self.0) + } +} + +impl Error for ConversionError {} + +impl TryFrom<&[String]> for Query { + type Error = ConversionError; + + fn try_from(info_hashes: &[String]) -> Result { + let mut validated_info_hashes: Vec = Vec::new(); + + for info_hash in info_hashes { + let validated_info_hash = InfoHash::from_str(info_hash).map_err(|_| ConversionError(info_hash.clone()))?; + validated_info_hashes.push(validated_info_hash.0); + } + + Ok(Self { + info_hash: validated_info_hashes, + }) + } +} + +impl TryFrom> for Query { + type Error = ConversionError; + + fn try_from(info_hashes: Vec) -> Result { + let mut validated_info_hashes: Vec = Vec::new(); + + for info_hash in info_hashes { + let validated_info_hash = InfoHash::from_str(&info_hash).map_err(|_| ConversionError(info_hash.clone()))?; + validated_info_hashes.push(validated_info_hash.0); + } + + Ok(Self { + info_hash: validated_info_hashes, + }) + } +} + +/// HTTP Tracker Scrape Request: +/// +/// +impl Query { + /// It builds the URL query component for the scrape request. + /// + /// This custom URL query params encoding is needed because `reqwest` does not allow + /// bytes arrays in query parameters. More info on this issue: + /// + /// + #[must_use] + pub fn build(&self) -> String { + self.params().to_string() + } + + #[must_use] + pub fn params(&self) -> QueryParams { + QueryParams::from(self) + } +} + +pub struct QueryBuilder { + scrape_query: Query, +} + +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 + }; + Self { + scrape_query: default_scrape_query, + } + } +} + +impl QueryBuilder { + #[must_use] + pub fn with_one_info_hash(mut self, info_hash: &InfoHash) -> Self { + self.scrape_query.info_hash = [info_hash.0].to_vec(); + self + } + + #[must_use] + pub fn add_info_hash(mut self, info_hash: &InfoHash) -> Self { + self.scrape_query.info_hash.push(info_hash.0); + self + } + + #[must_use] + pub fn query(self) -> Query { + self.scrape_query + } +} + +/// It contains all the GET parameters that can be used in a HTTP Scrape request. +/// +/// The `info_hash` param is the percent encoded of the the 20-byte array info hash. +/// +/// Sample Scrape URL with all the GET parameters: +/// +/// For `IpV4`: +/// +/// ```text +/// http://127.0.0.1:7070/scrape?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 +/// ``` +/// +/// For `IpV6`: +/// +/// ```text +/// http://[::1]:7070/scrape?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 +/// ``` +/// +/// You can add as many info hashes as you want, just adding the same param again. +pub struct QueryParams { + pub info_hash: Vec, +} + +impl QueryParams { + pub fn set_one_info_hash_param(&mut self, info_hash: &str) { + self.info_hash = vec![info_hash.to_string()]; + } +} + +impl std::fmt::Display for QueryParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let query = self + .info_hash + .iter() + .map(|info_hash| format!("info_hash={}", &info_hash)) + .collect::>() + .join("&"); + + write!(f, "{query}") + } +} + +impl QueryParams { + pub fn from(scrape_query: &Query) -> Self { + let info_hashes = scrape_query + .info_hash + .iter() + .map(percent_encode_byte_array) + .collect::>(); + + Self { info_hash: info_hashes } + } +} diff --git a/packages/tracker-client/src/http/client/responses/announce.rs b/packages/tracker-client/src/http/client/responses/announce.rs new file mode 100644 index 000000000..7f2d3611c --- /dev/null +++ b/packages/tracker-client/src/http/client/responses/announce.rs @@ -0,0 +1,126 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use torrust_tracker_primitives::peer; +use zerocopy::AsBytes as _; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Announce { + pub complete: u32, + pub incomplete: u32, + pub interval: u32, + #[serde(rename = "min interval")] + pub min_interval: u32, + pub peers: Vec, // Peers using IPV4 and IPV6 +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct DictionaryPeer { + pub ip: String, + #[serde(rename = "peer id")] + #[serde(with = "serde_bytes")] + pub peer_id: Vec, + pub port: u16, +} + +impl From for DictionaryPeer { + fn from(peer: peer::Peer) -> Self { + DictionaryPeer { + peer_id: peer.peer_id.as_bytes().to_vec(), + ip: peer.peer_addr.ip().to_string(), + port: peer.peer_addr.port(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct DeserializedCompact { + pub complete: u32, + pub incomplete: u32, + pub interval: u32, + #[serde(rename = "min interval")] + pub min_interval: u32, + #[serde(with = "serde_bytes")] + pub peers: Vec, +} + +impl DeserializedCompact { + /// # Errors + /// + /// Will return an error if bytes can't be deserialized. + pub fn from_bytes(bytes: &[u8]) -> Result { + serde_bencode::from_bytes::(bytes) + } +} + +#[derive(Debug, PartialEq)] +pub struct Compact { + // code-review: there could be a way to deserialize this struct directly + // by using serde instead of doing it manually. Or at least using a custom deserializer. + pub complete: u32, + pub incomplete: u32, + pub interval: u32, + pub min_interval: u32, + pub peers: CompactPeerList, +} + +#[derive(Debug, PartialEq)] +pub struct CompactPeerList { + peers: Vec, +} + +impl CompactPeerList { + #[must_use] + pub fn new(peers: Vec) -> Self { + Self { peers } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CompactPeer { + ip: Ipv4Addr, + port: u16, +} + +impl CompactPeer { + /// # Panics + /// + /// Will panic if the provided socket address is a IPv6 IP address. + /// It's not supported for compact peers. + #[must_use] + pub fn new(socket_addr: &SocketAddr) -> Self { + match socket_addr.ip() { + IpAddr::V4(ip) => Self { + ip, + port: socket_addr.port(), + }, + IpAddr::V6(_ip) => panic!("IPV6 is not supported for compact peer"), + } + } + + #[must_use] + pub fn new_from_bytes(bytes: &[u8]) -> Self { + Self { + ip: Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]), + port: u16::from_be_bytes([bytes[4], bytes[5]]), + } + } +} + +impl From for Compact { + fn from(compact_announce: DeserializedCompact) -> Self { + let mut peers = vec![]; + + for peer_bytes in compact_announce.peers.chunks_exact(6) { + peers.push(CompactPeer::new_from_bytes(peer_bytes)); + } + + Self { + complete: compact_announce.complete, + incomplete: compact_announce.incomplete, + interval: compact_announce.interval, + min_interval: compact_announce.min_interval, + peers: CompactPeerList::new(peers), + } + } +} diff --git a/packages/tracker-client/src/http/client/responses/error.rs b/packages/tracker-client/src/http/client/responses/error.rs new file mode 100644 index 000000000..00befdb54 --- /dev/null +++ b/packages/tracker-client/src/http/client/responses/error.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Error { + #[serde(rename = "failure reason")] + pub failure_reason: String, +} diff --git a/packages/tracker-client/src/http/client/responses/mod.rs b/packages/tracker-client/src/http/client/responses/mod.rs new file mode 100644 index 000000000..bdc689056 --- /dev/null +++ b/packages/tracker-client/src/http/client/responses/mod.rs @@ -0,0 +1,3 @@ +pub mod announce; +pub mod error; +pub mod scrape; diff --git a/packages/tracker-client/src/http/client/responses/scrape.rs b/packages/tracker-client/src/http/client/responses/scrape.rs new file mode 100644 index 000000000..6c0e8800a --- /dev/null +++ b/packages/tracker-client/src/http/client/responses/scrape.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; +use std::fmt::Write; +use std::str; + +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; +use serde_bencode::value::Value; + +use crate::http::{ByteArray20, InfoHash}; + +#[derive(Debug, PartialEq, Default, Deserialize)] +pub struct Response { + pub files: HashMap, +} + +impl Response { + #[must_use] + pub fn with_one_file(info_hash_bytes: ByteArray20, file: File) -> Self { + let mut files: HashMap = HashMap::new(); + files.insert(info_hash_bytes, file); + Self { files } + } + + /// # Errors + /// + /// Will return an error if the deserialized bencoded response can't not be converted into a valid response. + /// + /// # Panics + /// + /// Will panic if it can't deserialize the bencoded response. + pub fn try_from_bencoded(bytes: &[u8]) -> Result { + let scrape_response: DeserializedResponse = + serde_bencode::from_bytes(bytes).expect("provided bytes should be a valid bencoded response"); + Self::try_from(scrape_response) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] +pub struct File { + pub complete: i64, // The number of active peers that have completed downloading + pub downloaded: i64, // The number of peers that have ever completed downloading + pub incomplete: i64, // The number of active peers that have not completed downloading +} + +impl File { + #[must_use] + pub fn zeroed() -> Self { + Self::default() + } +} + +impl TryFrom for Response { + type Error = BencodeParseError; + + fn try_from(scrape_response: DeserializedResponse) -> Result { + parse_bencoded_response(&scrape_response.files) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct DeserializedResponse { + pub files: Value, +} + +// Custom serialization for Response +impl Serialize for Response { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.files.len()))?; + for (key, value) in &self.files { + // Convert ByteArray20 key to hex string + let hex_key = byte_array_to_hex_string(key); + map.serialize_entry(&hex_key, value)?; + } + map.end() + } +} + +// Helper function to convert ByteArray20 to hex string +fn byte_array_to_hex_string(byte_array: &ByteArray20) -> String { + let mut hex_string = String::with_capacity(byte_array.len() * 2); + for byte in byte_array { + write!(hex_string, "{byte:02x}").expect("Writing to string should never fail"); + } + hex_string +} + +#[derive(Default)] +pub struct ResponseBuilder { + response: Response, +} + +impl ResponseBuilder { + #[must_use] + pub fn add_file(mut self, info_hash_bytes: ByteArray20, file: File) -> Self { + self.response.files.insert(info_hash_bytes, file); + self + } + + #[must_use] + pub fn build(self) -> Response { + self.response + } +} + +#[derive(Debug)] +pub enum BencodeParseError { + InvalidValueExpectedDict { value: Value }, + InvalidValueExpectedInt { value: Value }, + InvalidFileField { value: Value }, + MissingFileField { field_name: String }, +} + +/// It parses a bencoded scrape response into a `Response` struct. +/// +/// For example: +/// +/// ```text +/// d5:filesd20:xxxxxxxxxxxxxxxxxxxxd8:completei11e10:downloadedi13772e10:incompletei19e +/// 20:yyyyyyyyyyyyyyyyyyyyd8:completei21e10:downloadedi206e10:incompletei20eee +/// ``` +/// +/// Response (JSON encoded for readability): +/// +/// ```text +/// { +/// 'files': { +/// 'xxxxxxxxxxxxxxxxxxxx': {'complete': 11, 'downloaded': 13772, 'incomplete': 19}, +/// 'yyyyyyyyyyyyyyyyyyyy': {'complete': 21, 'downloaded': 206, 'incomplete': 20} +/// } +/// } +fn parse_bencoded_response(value: &Value) -> Result { + let mut files: HashMap = HashMap::new(); + + match value { + Value::Dict(dict) => { + for file_element in dict { + let info_hash_byte_vec = file_element.0; + let file_value = file_element.1; + + let file = parse_bencoded_file(file_value).unwrap(); + + files.insert(InfoHash::new(info_hash_byte_vec).bytes(), file); + } + } + _ => return Err(BencodeParseError::InvalidValueExpectedDict { value: value.clone() }), + } + + Ok(Response { files }) +} + +/// It parses a bencoded dictionary into a `File` struct. +/// +/// For example: +/// +/// +/// ```text +/// d8:completei11e10:downloadedi13772e10:incompletei19ee +/// ``` +/// +/// into: +/// +/// ```text +/// File { +/// complete: 11, +/// downloaded: 13772, +/// incomplete: 19, +/// } +/// ``` +fn parse_bencoded_file(value: &Value) -> Result { + let file = match &value { + Value::Dict(dict) => { + let mut complete = None; + let mut downloaded = None; + let mut incomplete = None; + + for file_field in dict { + let field_name = file_field.0; + + let field_value = match file_field.1 { + Value::Int(number) => Ok(*number), + _ => Err(BencodeParseError::InvalidValueExpectedInt { + value: file_field.1.clone(), + }), + }?; + + if field_name == b"complete" { + complete = Some(field_value); + } else if field_name == b"downloaded" { + downloaded = Some(field_value); + } else if field_name == b"incomplete" { + incomplete = Some(field_value); + } else { + return Err(BencodeParseError::InvalidFileField { + value: file_field.1.clone(), + }); + } + } + + if complete.is_none() { + return Err(BencodeParseError::MissingFileField { + field_name: "complete".to_string(), + }); + } + + if downloaded.is_none() { + return Err(BencodeParseError::MissingFileField { + field_name: "downloaded".to_string(), + }); + } + + if incomplete.is_none() { + return Err(BencodeParseError::MissingFileField { + field_name: "incomplete".to_string(), + }); + } + + File { + complete: complete.unwrap(), + downloaded: downloaded.unwrap(), + incomplete: incomplete.unwrap(), + } + } + _ => return Err(BencodeParseError::InvalidValueExpectedDict { value: value.clone() }), + }; + + Ok(file) +} diff --git a/packages/tracker-client/src/http/mod.rs b/packages/tracker-client/src/http/mod.rs new file mode 100644 index 000000000..dc144814d --- /dev/null +++ b/packages/tracker-client/src/http/mod.rs @@ -0,0 +1,27 @@ +pub mod client; +pub mod url_encoding; + +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() +} + +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/packages/tracker-client/src/http/url_encoding.rs b/packages/tracker-client/src/http/url_encoding.rs new file mode 100644 index 000000000..ee7ab166e --- /dev/null +++ b/packages/tracker-client/src/http/url_encoding.rs @@ -0,0 +1,132 @@ +//! This module contains functions for percent decoding infohashes and peer IDs. +//! +//! Percent encoding is an encoding format used to encode arbitrary data in a +//! format that is safe to use in URLs. It is used by the HTTP tracker protocol +//! to encode infohashes and peer ids in the URLs of requests. +//! +//! `BitTorrent` infohashes and peer ids are percent encoded like any other +//! arbitrary URL parameter. But they are encoded from binary data (byte arrays) +//! which may not be valid UTF-8. That makes hard to use the `percent_encoding` +//! crate to decode them because all of them expect a well-formed UTF-8 string. +//! However, percent encoding is not limited to UTF-8 strings. +//! +//! More information about "Percent Encoding" can be found here: +//! +//! - +//! - +//! - +use aquatic_udp_protocol::PeerId; +use bittorrent_primitives::info_hash::{self, InfoHash}; +use torrust_tracker_primitives::peer; + +/* code-review: this module is duplicated in torrust_tracker::servers::http::percent_encoding. + Should we move it to torrust_tracker_primitives? +*/ + +/// Percent decodes a percent encoded infohash. Internally an +/// [`InfoHash`] is a 20-byte array. +/// +/// For example, given the infohash `3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0`, +/// it's percent encoded representation is `%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0`. +/// +/// ```rust +/// use std::str::FromStr; +/// use torrust_tracker::servers::http::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"; +/// +/// let info_hash = percent_decode_info_hash(encoded_infohash).unwrap(); +/// +/// assert_eq!( +/// info_hash, +/// InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap() +/// ); +/// ``` +/// +/// # Errors +/// +/// Will return `Err` if the decoded bytes do not represent a valid +/// [`InfoHash`]. +pub fn percent_decode_info_hash(raw_info_hash: &str) -> Result { + let bytes = percent_encoding::percent_decode_str(raw_info_hash).collect::>(); + InfoHash::try_from(bytes) +} + +/// Percent decodes a percent encoded peer id. Internally a peer [`Id`](PeerId) +/// is a 20-byte array. +/// +/// For example, given the peer id `*b"-qB00000000000000000"`, +/// it's percent encoded representation is `%2DqB00000000000000000`. +/// +/// ```rust +/// use std::str::FromStr; +/// +/// use aquatic_udp_protocol::PeerId; +/// use torrust_tracker::servers::http::percent_encoding::percent_decode_peer_id; +/// use bittorrent_primitives::info_hash::InfoHash; +/// +/// let encoded_peer_id = "%2DqB00000000000000000"; +/// +/// let peer_id = percent_decode_peer_id(encoded_peer_id).unwrap(); +/// +/// assert_eq!(peer_id, PeerId(*b"-qB00000000000000000")); +/// ``` +/// +/// # Errors +/// +/// Will return `Err` if if the decoded bytes do not represent a valid [`PeerId`]. +pub fn percent_decode_peer_id(raw_peer_id: &str) -> Result { + let bytes = percent_encoding::percent_decode_str(raw_peer_id).collect::>(); + Ok(*peer::Id::try_from(bytes)?) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use aquatic_udp_protocol::PeerId; + use bittorrent_primitives::info_hash::InfoHash; + + use crate::http::url_encoding::{percent_decode_info_hash, percent_decode_peer_id}; + + #[test] + fn it_should_decode_a_percent_encoded_info_hash() { + let encoded_infohash = "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"; + + let info_hash = percent_decode_info_hash(encoded_infohash).unwrap(); + + assert_eq!( + info_hash, + InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap() + ); + } + + #[test] + fn it_should_fail_decoding_an_invalid_percent_encoded_info_hash() { + let invalid_encoded_infohash = "invalid percent-encoded infohash"; + + let info_hash = percent_decode_info_hash(invalid_encoded_infohash); + + assert!(info_hash.is_err()); + } + + #[test] + fn it_should_decode_a_percent_encoded_peer_id() { + let encoded_peer_id = "%2DqB00000000000000000"; + + let peer_id = percent_decode_peer_id(encoded_peer_id).unwrap(); + + assert_eq!(peer_id, PeerId(*b"-qB00000000000000000")); + } + + #[test] + fn it_should_fail_decoding_an_invalid_percent_encoded_peer_id() { + let invalid_encoded_peer_id = "invalid percent-encoded peer id"; + + let peer_id = percent_decode_peer_id(invalid_encoded_peer_id); + + assert!(peer_id.is_err()); + } +} diff --git a/packages/tracker-client/src/lib.rs b/packages/tracker-client/src/lib.rs new file mode 100644 index 000000000..344e1b577 --- /dev/null +++ b/packages/tracker-client/src/lib.rs @@ -0,0 +1,3 @@ +pub mod console; +pub mod http; +pub mod udp; diff --git a/packages/tracker-client/src/udp/client.rs b/packages/tracker-client/src/udp/client.rs new file mode 100644 index 000000000..facdfac38 --- /dev/null +++ b/packages/tracker-client/src/udp/client.rs @@ -0,0 +1,270 @@ +use core::result::Result::{Err, Ok}; +use std::io::Cursor; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use aquatic_udp_protocol::{ConnectRequest, Request, Response, TransactionId}; +use tokio::net::UdpSocket; +use tokio::time; +use torrust_tracker_configuration::DEFAULT_TIMEOUT; +use zerocopy::network_endian::I32; + +use super::Error; +use crate::udp::MAX_PACKET_SIZE; + +pub const UDP_CLIENT_LOG_TARGET: &str = "UDP CLIENT"; + +#[allow(clippy::module_name_repetitions)] +#[derive(Debug)] +pub struct UdpClient { + /// The socket to connect to + pub socket: Arc, + + /// Timeout for sending and receiving packets + pub timeout: Duration, +} + +impl UdpClient { + /// Creates a new `UdpClient` bound to the default port and ipv6 address + /// + /// # Errors + /// + /// Will return error if unable to bind to any port or ip address. + /// + async fn bound_to_default_ipv4(timeout: Duration) -> Result { + let addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0); + + Self::bound(addr, timeout).await + } + + /// Creates a new `UdpClient` bound to the default port and ipv6 address + /// + /// # Errors + /// + /// Will return error if unable to bind to any port or ip address. + /// + async fn bound_to_default_ipv6(timeout: Duration) -> Result { + let addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0); + + Self::bound(addr, timeout).await + } + + /// Creates a new `UdpClient` connected to a Udp server + /// + /// # Errors + /// + /// Will return any errors present in the call stack + /// + pub async fn connected(remote_addr: SocketAddr, timeout: Duration) -> Result { + let client = if remote_addr.is_ipv4() { + Self::bound_to_default_ipv4(timeout).await? + } else { + Self::bound_to_default_ipv6(timeout).await? + }; + + client.connect(remote_addr).await?; + Ok(client) + } + + /// Creates a `[UdpClient]` bound to a Socket. + /// + /// # Panics + /// + /// Panics if unable to get the `local_addr` of the bound socket. + /// + /// # Errors + /// + /// This function will return an error if the binding takes to long + /// or if there is an underlying OS error. + pub async fn bound(addr: SocketAddr, timeout: Duration) -> Result { + tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "binding to socket: {addr:?} ..."); + + let socket = time::timeout(timeout, UdpSocket::bind(addr)) + .await + .map_err(|_| Error::TimeoutWhileBindingToSocket { addr })? + .map_err(|e| Error::UnableToBindToSocket { err: e.into(), addr })?; + + let addr = socket.local_addr().expect("it should get the local address"); + + tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "bound to socket: {addr:?}."); + + let udp_client = Self { + socket: Arc::new(socket), + timeout, + }; + + Ok(udp_client) + } + + /// # Errors + /// + /// Will return error if can't connect to the socket. + pub async fn connect(&self, remote_addr: SocketAddr) -> Result<(), Error> { + tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "connecting to remote: {remote_addr:?} ..."); + + let () = time::timeout(self.timeout, self.socket.connect(remote_addr)) + .await + .map_err(|_| Error::TimeoutWhileConnectingToRemote { remote_addr })? + .map_err(|e| Error::UnableToConnectToRemote { + err: e.into(), + remote_addr, + })?; + + tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "connected to remote: {remote_addr:?}."); + + Ok(()) + } + + /// # Errors + /// + /// Will return error if: + /// + /// - Can't write to the socket. + /// - Can't send data. + pub async fn send(&self, bytes: &[u8]) -> Result { + tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "sending {bytes:?} ..."); + + let () = time::timeout(self.timeout, self.socket.writable()) + .await + .map_err(|_| Error::TimeoutWaitForWriteableSocket)? + .map_err(|e| Error::UnableToGetWritableSocket { err: e.into() })?; + + let sent_bytes = time::timeout(self.timeout, self.socket.send(bytes)) + .await + .map_err(|_| Error::TimeoutWhileSendingData { data: bytes.to_vec() })? + .map_err(|e| Error::UnableToSendData { + err: e.into(), + data: bytes.to_vec(), + })?; + + tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "sent {sent_bytes} bytes to remote."); + + Ok(sent_bytes) + } + + /// # Errors + /// + /// Will return error if: + /// + /// - Can't read from the socket. + /// - Can't receive data. + /// + /// # Panics + /// + pub async fn receive(&self) -> Result, Error> { + tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "receiving ..."); + + let mut buffer = [0u8; MAX_PACKET_SIZE]; + + let () = time::timeout(self.timeout, self.socket.readable()) + .await + .map_err(|_| Error::TimeoutWaitForReadableSocket)? + .map_err(|e| Error::UnableToGetReadableSocket { err: e.into() })?; + + let received_bytes = time::timeout(self.timeout, self.socket.recv(&mut buffer)) + .await + .map_err(|_| Error::TimeoutWhileReceivingData)? + .map_err(|e| Error::UnableToReceivingData { err: e.into() })?; + + let mut received: Vec = buffer.to_vec(); + Vec::truncate(&mut received, received_bytes); + + tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "received {received_bytes} bytes: {received:?}"); + + Ok(received) + } +} + +#[allow(clippy::module_name_repetitions)] +#[derive(Debug)] +pub struct UdpTrackerClient { + pub client: UdpClient, +} + +impl UdpTrackerClient { + /// Creates a new `UdpTrackerClient` connected to a Udp Tracker server + /// + /// # Errors + /// + /// If unable to connect to the remote address. + /// + pub async fn new(remote_addr: SocketAddr, timeout: Duration) -> Result { + let client = UdpClient::connected(remote_addr, timeout).await?; + Ok(UdpTrackerClient { client }) + } + + /// # Errors + /// + /// Will return error if can't write request to bytes. + pub async fn send(&self, request: Request) -> Result { + tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "sending request {request:?} ..."); + + // Write request into a buffer + // todo: optimize the pre-allocated amount based upon request type. + let mut writer = Cursor::new(Vec::with_capacity(200)); + let () = request + .write_bytes(&mut writer) + .map_err(|e| Error::UnableToWriteDataFromRequest { err: e.into(), request })?; + + self.client.send(writer.get_ref()).await + } + + /// # Errors + /// + /// Will return error if can't create response from the received payload (bytes buffer). + pub async fn receive(&self) -> Result { + let response = self.client.receive().await?; + + tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "received {} bytes: {response:?}", response.len()); + + Response::parse_bytes(&response, true).map_err(|e| Error::UnableToParseResponse { err: e.into(), response }) + } +} + +/// Helper Function to Check if a UDP Service is Connectable +/// +/// # Panics +/// +/// It will return an error if unable to connect to the UDP service. +/// +/// # Errors +/// +pub async fn check(remote_addr: &SocketAddr) -> Result { + tracing::debug!("Checking Service (detail): {remote_addr:?}."); + + match UdpTrackerClient::new(*remote_addr, DEFAULT_TIMEOUT).await { + Ok(client) => { + let connect_request = ConnectRequest { + transaction_id: TransactionId(I32::new(123)), + }; + + // client.send() return usize, but doesn't use here + 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)) { + Ok("Connected".to_string()) + } else { + Err("Did not Connect".to_string()) + } + }; + + let sleep = time::sleep(Duration::from_millis(2000)); + tokio::pin!(sleep); + + tokio::select! { + () = &mut sleep => { + Err("Timed Out".to_string()) + } + response = client.receive() => { + process(response.unwrap()) + } + } + } + Err(e) => Err(format!("{e:?}")), + } +} diff --git a/packages/tracker-client/src/udp/mod.rs b/packages/tracker-client/src/udp/mod.rs new file mode 100644 index 000000000..b9d5f34f6 --- /dev/null +++ b/packages/tracker-client/src/udp/mod.rs @@ -0,0 +1,68 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use aquatic_udp_protocol::Request; +use thiserror::Error; +use torrust_tracker_located_error::DynError; + +pub mod client; + +/// The maximum number of bytes in a UDP packet. +pub const MAX_PACKET_SIZE: usize = 1496; +/// A magic 64-bit integer constant defined in the protocol that is used to +/// identify the protocol. +pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; + +#[derive(Debug, Clone, Error)] +pub enum Error { + #[error("Timeout while waiting for socket to bind: {addr:?}")] + TimeoutWhileBindingToSocket { addr: SocketAddr }, + + #[error("Failed to bind to socket: {addr:?}, with error: {err:?}")] + UnableToBindToSocket { err: Arc, addr: SocketAddr }, + + #[error("Timeout while waiting for connection to remote: {remote_addr:?}")] + TimeoutWhileConnectingToRemote { remote_addr: SocketAddr }, + + #[error("Failed to connect to remote: {remote_addr:?}, with error: {err:?}")] + UnableToConnectToRemote { + err: Arc, + remote_addr: SocketAddr, + }, + + #[error("Timeout while waiting for the socket to become writable.")] + TimeoutWaitForWriteableSocket, + + #[error("Failed to get writable socket: {err:?}")] + UnableToGetWritableSocket { err: Arc }, + + #[error("Timeout while trying to send data: {data:?}")] + TimeoutWhileSendingData { data: Vec }, + + #[error("Failed to send data: {data:?}, with error: {err:?}")] + UnableToSendData { err: Arc, data: Vec }, + + #[error("Timeout while waiting for the socket to become readable.")] + TimeoutWaitForReadableSocket, + + #[error("Failed to get readable socket: {err:?}")] + UnableToGetReadableSocket { err: Arc }, + + #[error("Timeout while trying to receive data.")] + TimeoutWhileReceivingData, + + #[error("Failed to receive data: {err:?}")] + UnableToReceivingData { err: Arc }, + + #[error("Failed to get data from request: {request:?}, with error: {err:?}")] + UnableToWriteDataFromRequest { err: Arc, request: Request }, + + #[error("Failed to parse response: {response:?}, with error: {err:?}")] + UnableToParseResponse { err: Arc, response: Vec }, +} + +impl From for DynError { + fn from(e: Error) -> Self { + Arc::new(Box::new(e)) + } +} From 31ac6cf215f61b17d3593a0f6d97713aa287f8d6 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 1 Nov 2024 16:43:41 +0000 Subject: [PATCH 2/4] refactor: use extracted bittorrent-tracker-client --- src/bin/http_tracker_client.rs | 7 - src/bin/tracker_checker.rs | 7 - src/bin/udp_tracker_client.rs | 7 - src/console/clients/checker/app.rs | 120 -------- src/console/clients/checker/checks/health.rs | 77 ----- src/console/clients/checker/checks/http.rs | 104 ------- src/console/clients/checker/checks/mod.rs | 4 - src/console/clients/checker/checks/structs.rs | 12 - src/console/clients/checker/checks/udp.rs | 134 --------- src/console/clients/checker/config.rs | 282 ------------------ src/console/clients/checker/console.rs | 38 --- src/console/clients/checker/logger.rs | 72 ----- src/console/clients/checker/mod.rs | 7 - src/console/clients/checker/printer.rs | 9 - src/console/clients/checker/service.rs | 62 ---- src/console/clients/http/app.rs | 102 ------- src/console/clients/http/mod.rs | 36 --- src/console/clients/mod.rs | 4 - src/console/clients/udp/app.rs | 208 ------------- src/console/clients/udp/checker.rs | 177 ----------- src/console/clients/udp/mod.rs | 51 ---- src/console/clients/udp/responses/dto.rs | 128 -------- src/console/clients/udp/responses/json.rs | 25 -- src/console/clients/udp/responses/mod.rs | 2 - src/console/mod.rs | 1 - src/servers/udp/server/launcher.rs | 2 +- .../bit_torrent/tracker/http/client/mod.rs | 204 ------------- .../tracker/http/client/requests/announce.rs | 275 ----------------- .../tracker/http/client/requests/mod.rs | 2 - .../tracker/http/client/requests/scrape.rs | 172 ----------- .../tracker/http/client/responses/announce.rs | 126 -------- .../tracker/http/client/responses/error.rs | 7 - .../tracker/http/client/responses/mod.rs | 3 - .../tracker/http/client/responses/scrape.rs | 230 -------------- src/shared/bit_torrent/tracker/http/mod.rs | 26 -- src/shared/bit_torrent/tracker/mod.rs | 1 - src/shared/bit_torrent/tracker/udp/client.rs | 270 ----------------- src/shared/bit_torrent/tracker/udp/mod.rs | 64 +--- tests/servers/udp/contract.rs | 8 +- 39 files changed, 6 insertions(+), 3060 deletions(-) delete mode 100644 src/bin/http_tracker_client.rs delete mode 100644 src/bin/tracker_checker.rs delete mode 100644 src/bin/udp_tracker_client.rs delete mode 100644 src/console/clients/checker/app.rs delete mode 100644 src/console/clients/checker/checks/health.rs delete mode 100644 src/console/clients/checker/checks/http.rs delete mode 100644 src/console/clients/checker/checks/mod.rs delete mode 100644 src/console/clients/checker/checks/structs.rs delete mode 100644 src/console/clients/checker/checks/udp.rs delete mode 100644 src/console/clients/checker/config.rs delete mode 100644 src/console/clients/checker/console.rs delete mode 100644 src/console/clients/checker/logger.rs delete mode 100644 src/console/clients/checker/mod.rs delete mode 100644 src/console/clients/checker/printer.rs delete mode 100644 src/console/clients/checker/service.rs delete mode 100644 src/console/clients/http/app.rs delete mode 100644 src/console/clients/http/mod.rs delete mode 100644 src/console/clients/mod.rs delete mode 100644 src/console/clients/udp/app.rs delete mode 100644 src/console/clients/udp/checker.rs delete mode 100644 src/console/clients/udp/mod.rs delete mode 100644 src/console/clients/udp/responses/dto.rs delete mode 100644 src/console/clients/udp/responses/json.rs delete mode 100644 src/console/clients/udp/responses/mod.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/mod.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/requests/announce.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/requests/mod.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/requests/scrape.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/responses/announce.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/responses/error.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/responses/mod.rs delete mode 100644 src/shared/bit_torrent/tracker/http/client/responses/scrape.rs delete mode 100644 src/shared/bit_torrent/tracker/http/mod.rs delete mode 100644 src/shared/bit_torrent/tracker/udp/client.rs diff --git a/src/bin/http_tracker_client.rs b/src/bin/http_tracker_client.rs deleted file mode 100644 index 0de040549..000000000 --- a/src/bin/http_tracker_client.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Program to make request to HTTP trackers. -use torrust_tracker::console::clients::http::app; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - app::run().await -} diff --git a/src/bin/tracker_checker.rs b/src/bin/tracker_checker.rs deleted file mode 100644 index 87aeedeac..000000000 --- a/src/bin/tracker_checker.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Program to check running trackers. -use torrust_tracker::console::clients::checker::app; - -#[tokio::main] -async fn main() { - app::run().await.expect("Some checks fail"); -} diff --git a/src/bin/udp_tracker_client.rs b/src/bin/udp_tracker_client.rs deleted file mode 100644 index 909b296ca..000000000 --- a/src/bin/udp_tracker_client.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Program to make request to UDP trackers. -use torrust_tracker::console::clients::udp::app; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - app::run().await -} diff --git a/src/console/clients/checker/app.rs b/src/console/clients/checker/app.rs deleted file mode 100644 index 395f65df9..000000000 --- a/src/console/clients/checker/app.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Program to run checks against running trackers. -//! -//! Run providing a config file path: -//! -//! ```text -//! cargo run --bin tracker_checker -- --config-path "./share/default/config/tracker_checker.json" -//! TORRUST_CHECKER_CONFIG_PATH="./share/default/config/tracker_checker.json" cargo run --bin tracker_checker -//! ``` -//! -//! Run providing the configuration: -//! -//! ```text -//! TORRUST_CHECKER_CONFIG=$(cat "./share/default/config/tracker_checker.json") cargo run --bin tracker_checker -//! ``` -//! -//! Another real example to test the Torrust demo tracker: -//! -//! ```text -//! TORRUST_CHECKER_CONFIG='{ -//! "udp_trackers": ["144.126.245.19:6969"], -//! "http_trackers": ["https://tracker.torrust-demo.com"], -//! "health_checks": ["https://tracker.torrust-demo.com/api/health_check"] -//! }' cargo run --bin tracker_checker -//! ``` -//! -//! The output should be something like the following: -//! -//! ```json -//! { -//! "udp_trackers": [ -//! { -//! "url": "144.126.245.19:6969", -//! "status": { -//! "code": "ok", -//! "message": "" -//! } -//! } -//! ], -//! "http_trackers": [ -//! { -//! "url": "https://tracker.torrust-demo.com/", -//! "status": { -//! "code": "ok", -//! "message": "" -//! } -//! } -//! ], -//! "health_checks": [ -//! { -//! "url": "https://tracker.torrust-demo.com/api/health_check", -//! "status": { -//! "code": "ok", -//! "message": "" -//! } -//! } -//! ] -//! } -//! ``` -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, Result}; -use clap::Parser; -use tracing::level_filters::LevelFilter; - -use super::config::Configuration; -use super::console::Console; -use super::service::{CheckResult, Service}; -use crate::console::clients::checker::config::parse_from_json; - -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] -struct Args { - /// Path to the JSON configuration file. - #[clap(short, long, env = "TORRUST_CHECKER_CONFIG_PATH")] - config_path: Option, - - /// Direct configuration content in JSON. - #[clap(env = "TORRUST_CHECKER_CONFIG", hide_env_values = true)] - config_content: Option, -} - -/// # Errors -/// -/// Will return an error if the configuration was not provided. -pub async fn run() -> Result> { - tracing_stdout_init(LevelFilter::INFO); - - let args = Args::parse(); - - let config = setup_config(args)?; - - let console_printer = Console {}; - - let service = Service { - config: Arc::new(config), - console: console_printer, - }; - - service.run_checks().await.context("it should run the check tasks") -} - -fn tracing_stdout_init(filter: LevelFilter) { - tracing_subscriber::fmt().with_max_level(filter).init(); - tracing::debug!("Logging initialized"); -} - -fn setup_config(args: Args) -> Result { - match (args.config_path, args.config_content) { - (Some(config_path), _) => load_config_from_file(&config_path), - (_, Some(config_content)) => parse_from_json(&config_content).context("invalid config format"), - _ => Err(anyhow::anyhow!("no configuration provided")), - } -} - -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:?}"))?; - - parse_from_json(&file_content).context("invalid config format") -} diff --git a/src/console/clients/checker/checks/health.rs b/src/console/clients/checker/checks/health.rs deleted file mode 100644 index b1fb79148..000000000 --- a/src/console/clients/checker/checks/health.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use hyper::StatusCode; -use reqwest::{Client as HttpClient, Response}; -use serde::Serialize; -use thiserror::Error; -use url::Url; - -#[derive(Debug, Clone, Error, Serialize)] -#[serde(into = "String")] -pub enum Error { - #[error("Failed to Build a Http Client: {err:?}")] - ClientBuildingError { err: Arc }, - #[error("Heath check failed to get a response: {err:?}")] - ResponseError { err: Arc }, - #[error("Http check returned a non-success code: \"{code}\" with the response: \"{response:?}\"")] - UnsuccessfulResponse { code: StatusCode, response: Arc }, -} - -impl From for String { - fn from(value: Error) -> Self { - value.to_string() - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct Checks { - url: Url, - result: Result, -} - -pub async fn run(health_checks: Vec, timeout: Duration) -> Vec> { - let mut results = Vec::default(); - - tracing::debug!("Health checks ..."); - - for url in health_checks { - let result = match run_health_check(url.clone(), timeout).await { - Ok(response) => Ok(response.status().to_string()), - Err(err) => Err(err), - }; - - let check = Checks { url, result }; - - if check.result.is_err() { - results.push(Err(check)); - } else { - results.push(Ok(check)); - } - } - - results -} - -async fn run_health_check(url: Url, timeout: Duration) -> Result { - let client = HttpClient::builder() - .timeout(timeout) - .build() - .map_err(|e| Error::ClientBuildingError { err: e.into() })?; - - let response = client - .get(url.clone()) - .send() - .await - .map_err(|e| Error::ResponseError { err: e.into() })?; - - if response.status().is_success() { - Ok(response) - } else { - Err(Error::UnsuccessfulResponse { - code: response.status(), - response: response.into(), - }) - } -} diff --git a/src/console/clients/checker/checks/http.rs b/src/console/clients/checker/checks/http.rs deleted file mode 100644 index b64297bed..000000000 --- a/src/console/clients/checker/checks/http.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::str::FromStr as _; -use std::time::Duration; - -use bittorrent_primitives::info_hash::InfoHash; -use serde::Serialize; -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 { - url: Url, - results: Vec<(Check, Result<(), Error>)>, -} - -#[derive(Debug, Clone, Serialize)] -pub enum Check { - Announce, - Scrape, -} - -pub async fn run(http_trackers: Vec, timeout: Duration) -> Vec> { - let mut results = Vec::default(); - - tracing::debug!("HTTP trackers ..."); - - for ref url in http_trackers { - let mut base_url = url.clone(); - base_url.set_path(""); - - let mut checks = Checks { - url: url.clone(), - results: Vec::default(), - }; - - // Announce - { - let check = check_http_announce(&base_url, timeout).await.map(|_| ()); - - checks.results.push((Check::Announce, check)); - } - - // Scrape - { - let check = check_http_scrape(&base_url, timeout).await.map(|_| ()); - - checks.results.push((Check::Scrape, check)); - } - - if checks.results.iter().any(|f| f.1.is_err()) { - results.push(Err(checks)); - } else { - results.push(Ok(checks)); - } - } - - results -} - -async fn check_http_announce(url: &Url, timeout: Duration) -> Result { - 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 })?; - - let response = client - .announce( - &requests::announce::QueryBuilder::with_default_values() - .with_info_hash(&info_hash) - .query(), - ) - .await - .map_err(|err| Error::HttpClientError { err })?; - - let response = response.bytes().await.map_err(|e| Error::ResponseError { err: e.into() })?; - - let response = serde_bencode::from_bytes::(&response).map_err(|e| Error::ParseBencodeError { - data: response, - err: e.into(), - })?; - - Ok(response) -} - -async fn check_http_scrape(url: &Url, timeout: Duration) -> Result { - 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 })?; - - let response = client.scrape(&query).await.map_err(|err| Error::HttpClientError { err })?; - - let response = response.bytes().await.map_err(|e| Error::ResponseError { err: e.into() })?; - - let response = scrape::Response::try_from_bencoded(&response).map_err(|e| Error::BencodeParseError { - data: response, - err: e.into(), - })?; - - Ok(response) -} diff --git a/src/console/clients/checker/checks/mod.rs b/src/console/clients/checker/checks/mod.rs deleted file mode 100644 index f8b03f749..000000000 --- a/src/console/clients/checker/checks/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod health; -pub mod http; -pub mod structs; -pub mod udp; diff --git a/src/console/clients/checker/checks/structs.rs b/src/console/clients/checker/checks/structs.rs deleted file mode 100644 index d28e20c04..000000000 --- a/src/console/clients/checker/checks/structs.rs +++ /dev/null @@ -1,12 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct Status { - pub code: String, - pub message: String, -} -#[derive(Serialize, Deserialize)] -pub struct CheckerOutput { - pub url: String, - pub status: Status, -} diff --git a/src/console/clients/checker/checks/udp.rs b/src/console/clients/checker/checks/udp.rs deleted file mode 100644 index 21bdcd1b7..000000000 --- a/src/console/clients/checker/checks/udp.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::net::SocketAddr; -use std::time::Duration; - -use aquatic_udp_protocol::TransactionId; -use hex_literal::hex; -use serde::Serialize; -use url::Url; - -use crate::console::clients::udp::checker::Client; -use crate::console::clients::udp::Error; - -#[derive(Debug, Clone, Serialize)] -pub struct Checks { - remote_addr: SocketAddr, - results: Vec<(Check, Result<(), Error>)>, -} - -#[derive(Debug, Clone, Serialize)] -pub enum Check { - Setup, - Connect, - Announce, - Scrape, -} - -#[allow(clippy::missing_panics_doc)] -pub async fn run(udp_trackers: Vec, timeout: Duration) -> Vec> { - let mut results = Vec::default(); - - tracing::debug!("UDP trackers ..."); - - let info_hash = aquatic_udp_protocol::InfoHash(hex!("9c38422213e30bff212b30c360d26f9a02136422")); // # DevSkim: ignore DS173237 - - for remote_url in udp_trackers { - let remote_addr = resolve_socket_addr(&remote_url); - - let mut checks = Checks { - remote_addr, - results: Vec::default(), - }; - - tracing::debug!("UDP tracker: {:?}", remote_url); - - // Setup - let client = match Client::new(remote_addr, timeout).await { - Ok(client) => { - checks.results.push((Check::Setup, Ok(()))); - client - } - Err(err) => { - checks.results.push((Check::Setup, Err(err))); - results.push(Err(checks)); - continue; - } - }; - - let transaction_id = TransactionId::new(1); - - // Connect Remote - let connection_id = match client.send_connection_request(transaction_id).await { - Ok(connection_id) => { - checks.results.push((Check::Connect, Ok(()))); - connection_id - } - Err(err) => { - checks.results.push((Check::Connect, Err(err))); - results.push(Err(checks)); - continue; - } - }; - - // Announce - { - let check = client - .send_announce_request(transaction_id, connection_id, info_hash.into()) - .await - .map(|_| ()); - - checks.results.push((Check::Announce, check)); - } - - // Scrape - { - let check = client - .send_scrape_request(connection_id, transaction_id, &[info_hash.into()]) - .await - .map(|_| ()); - - checks.results.push((Check::Scrape, check)); - } - - if checks.results.iter().any(|f| f.1.is_err()) { - results.push(Err(checks)); - } else { - results.push(Ok(checks)); - } - } - - results -} - -fn resolve_socket_addr(url: &Url) -> SocketAddr { - let socket_addr = url.socket_addrs(|| None).unwrap(); - *socket_addr.first().unwrap() -} - -#[cfg(test)] -mod tests { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; - - use url::Url; - - use crate::console::clients::checker::checks::udp::resolve_socket_addr; - - #[test] - fn it_should_resolve_the_socket_address_for_udp_scheme_urls_containing_a_domain() { - 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) - ); - } - - #[test] - fn it_should_resolve_the_socket_address_for_udp_scheme_urls_containing_an_ip() { - 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) - ); - } -} diff --git a/src/console/clients/checker/config.rs b/src/console/clients/checker/config.rs deleted file mode 100644 index 154dcae85..000000000 --- a/src/console/clients/checker/config.rs +++ /dev/null @@ -1,282 +0,0 @@ -use std::error::Error; -use std::fmt; - -use reqwest::Url as ServiceUrl; -use serde::Deserialize; - -/// It parses the configuration from a JSON format. -/// -/// # Errors -/// -/// Will return an error if the configuration is not valid. -/// -/// # Panics -/// -/// Will panic if unable to read the configuration file. -pub fn parse_from_json(json: &str) -> Result { - let plain_config: PlainConfiguration = serde_json::from_str(json).map_err(ConfigurationError::JsonParseError)?; - Configuration::try_from(plain_config) -} - -/// DTO for the configuration to serialize/deserialize configuration. -/// -/// Configuration does not need to be valid. -#[derive(Deserialize)] -struct PlainConfiguration { - pub udp_trackers: Vec, - pub http_trackers: Vec, - pub health_checks: Vec, -} - -/// Validated configuration -pub struct Configuration { - pub udp_trackers: Vec, - pub http_trackers: Vec, - pub health_checks: Vec, -} - -#[derive(Debug)] -pub enum ConfigurationError { - JsonParseError(serde_json::Error), - InvalidUdpAddress(std::net::AddrParseError), - InvalidUrl(url::ParseError), -} - -impl Error for ConfigurationError {} - -impl fmt::Display for ConfigurationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ConfigurationError::JsonParseError(e) => write!(f, "JSON parse error: {e}"), - ConfigurationError::InvalidUdpAddress(e) => write!(f, "Invalid UDP address: {e}"), - ConfigurationError::InvalidUrl(e) => write!(f, "Invalid URL: {e}"), - } - } -} - -impl TryFrom for Configuration { - type Error = ConfigurationError; - - fn try_from(plain_config: PlainConfiguration) -> Result { - let udp_trackers = plain_config - .udp_trackers - .into_iter() - .map(|s| if s.starts_with("udp://") { s } else { format!("udp://{s}") }) - .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) - .collect::, _>>()?; - - let http_trackers = plain_config - .http_trackers - .into_iter() - .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) - .collect::, _>>()?; - - let health_checks = plain_config - .health_checks - .into_iter() - .map(|s| s.parse::().map_err(ConfigurationError::InvalidUrl)) - .collect::, _>>()?; - - Ok(Configuration { - udp_trackers, - http_trackers, - health_checks, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn configuration_should_be_build_from_plain_serializable_configuration() { - let dto = PlainConfiguration { - udp_trackers: vec!["udp://127.0.0.1:8080".to_string()], - http_trackers: vec!["http://127.0.0.1:8080".to_string()], - health_checks: vec!["http://127.0.0.1:8080/health".to_string()], - }; - - let config = Configuration::try_from(dto).expect("A valid configuration"); - - assert_eq!(config.udp_trackers, vec![ServiceUrl::parse("udp://127.0.0.1:8080").unwrap()]); - - assert_eq!( - config.http_trackers, - vec![ServiceUrl::parse("http://127.0.0.1:8080").unwrap()] - ); - - assert_eq!( - config.health_checks, - vec![ServiceUrl::parse("http://127.0.0.1:8080/health").unwrap()] - ); - } - - mod building_configuration_from_plain_configuration_for { - - mod udp_trackers { - use crate::console::clients::checker::config::{Configuration, PlainConfiguration, ServiceUrl}; - - /* The plain configuration should allow UDP URLs with: - - - IP or domain. - - With or without scheme. - - With or without `announce` suffix. - - With or without `/` at the end of the authority section (with empty path). - - For example: - - 127.0.0.1:6969 - 127.0.0.1:6969/ - 127.0.0.1:6969/announce - - localhost:6969 - localhost:6969/ - localhost:6969/announce - - udp://127.0.0.1:6969 - udp://127.0.0.1:6969/ - udp://127.0.0.1:6969/announce - - udp://localhost:6969 - udp://localhost:6969/ - udp://localhost:6969/announce - - */ - - #[test] - fn it_should_fail_when_a_tracker_udp_url_is_invalid() { - let plain_config = PlainConfiguration { - udp_trackers: vec!["invalid URL".to_string()], - http_trackers: vec![], - health_checks: vec![], - }; - - assert!(Configuration::try_from(plain_config).is_err()); - } - - #[test] - fn it_should_add_the_udp_scheme_to_the_udp_url_when_it_is_missing() { - let plain_config = PlainConfiguration { - udp_trackers: vec!["127.0.0.1:6969".to_string()], - http_trackers: vec![], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!(config.udp_trackers[0], "udp://127.0.0.1:6969".parse::().unwrap()); - } - - #[test] - fn it_should_allow_using_domains() { - let plain_config = PlainConfiguration { - udp_trackers: vec!["udp://localhost:6969".to_string()], - http_trackers: vec![], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!(config.udp_trackers[0], "udp://localhost:6969".parse::().unwrap()); - } - - #[test] - fn it_should_allow_the_url_to_have_an_empty_path() { - let plain_config = PlainConfiguration { - udp_trackers: vec!["127.0.0.1:6969/".to_string()], - http_trackers: vec![], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!(config.udp_trackers[0], "udp://127.0.0.1:6969/".parse::().unwrap()); - } - - #[test] - fn it_should_allow_the_url_to_contain_a_path() { - // This is the common format for UDP tracker URLs: - // udp://domain.com:6969/announce - - let plain_config = PlainConfiguration { - udp_trackers: vec!["127.0.0.1:6969/announce".to_string()], - http_trackers: vec![], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!( - config.udp_trackers[0], - "udp://127.0.0.1:6969/announce".parse::().unwrap() - ); - } - } - - mod http_trackers { - use crate::console::clients::checker::config::{Configuration, PlainConfiguration, ServiceUrl}; - - #[test] - fn it_should_fail_when_a_tracker_http_url_is_invalid() { - let plain_config = PlainConfiguration { - udp_trackers: vec![], - http_trackers: vec!["invalid URL".to_string()], - health_checks: vec![], - }; - - assert!(Configuration::try_from(plain_config).is_err()); - } - - #[test] - fn it_should_allow_the_url_to_contain_a_path() { - // This is the common format for HTTP tracker URLs: - // http://domain.com:7070/announce - - let plain_config = PlainConfiguration { - udp_trackers: vec![], - http_trackers: vec!["http://127.0.0.1:7070/announce".to_string()], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!( - config.http_trackers[0], - "http://127.0.0.1:7070/announce".parse::().unwrap() - ); - } - - #[test] - fn it_should_allow_the_url_to_contain_an_empty_path() { - let plain_config = PlainConfiguration { - udp_trackers: vec![], - http_trackers: vec!["http://127.0.0.1:7070/".to_string()], - health_checks: vec![], - }; - - let config = Configuration::try_from(plain_config).expect("Invalid plain configuration"); - - assert_eq!( - config.http_trackers[0], - "http://127.0.0.1:7070/".parse::().unwrap() - ); - } - } - - mod health_checks { - use crate::console::clients::checker::config::{Configuration, PlainConfiguration}; - - #[test] - fn it_should_fail_when_a_health_check_http_url_is_invalid() { - let plain_config = PlainConfiguration { - udp_trackers: vec![], - http_trackers: vec![], - health_checks: vec!["invalid URL".to_string()], - }; - - assert!(Configuration::try_from(plain_config).is_err()); - } - } - } -} diff --git a/src/console/clients/checker/console.rs b/src/console/clients/checker/console.rs deleted file mode 100644 index b55c559fc..000000000 --- a/src/console/clients/checker/console.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::printer::{Printer, CLEAR_SCREEN}; - -pub struct Console {} - -impl Default for Console { - fn default() -> Self { - Self::new() - } -} - -impl Console { - #[must_use] - pub fn new() -> Self { - Self {} - } -} - -impl Printer for Console { - fn clear(&self) { - self.print(CLEAR_SCREEN); - } - - fn print(&self, output: &str) { - print!("{}", &output); - } - - fn eprint(&self, output: &str) { - eprint!("{}", &output); - } - - fn println(&self, output: &str) { - println!("{}", &output); - } - - fn eprintln(&self, output: &str) { - eprintln!("{}", &output); - } -} diff --git a/src/console/clients/checker/logger.rs b/src/console/clients/checker/logger.rs deleted file mode 100644 index 50e97189f..000000000 --- a/src/console/clients/checker/logger.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::cell::RefCell; - -use super::printer::{Printer, CLEAR_SCREEN}; - -pub struct Logger { - output: RefCell, -} - -impl Default for Logger { - fn default() -> Self { - Self::new() - } -} - -impl Logger { - #[must_use] - pub fn new() -> Self { - Self { - output: RefCell::new(String::new()), - } - } - - pub fn log(&self) -> String { - self.output.borrow().clone() - } -} - -impl Printer for Logger { - fn clear(&self) { - self.print(CLEAR_SCREEN); - } - - fn print(&self, output: &str) { - *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); - } - - fn eprint(&self, output: &str) { - *self.output.borrow_mut() = format!("{}{}", self.output.borrow(), &output); - } - - fn println(&self, output: &str) { - self.print(&format!("{}/n", &output)); - } - - fn eprintln(&self, output: &str) { - self.eprint(&format!("{}/n", &output)); - } -} - -#[cfg(test)] -mod tests { - use crate::console::clients::checker::logger::Logger; - use crate::console::clients::checker::printer::{Printer, CLEAR_SCREEN}; - - #[test] - fn should_capture_the_clear_screen_command() { - let console_logger = Logger::new(); - - console_logger.clear(); - - assert_eq!(CLEAR_SCREEN, console_logger.log()); - } - - #[test] - fn should_capture_the_print_command_output() { - let console_logger = Logger::new(); - - console_logger.print("OUTPUT"); - - assert_eq!("OUTPUT", console_logger.log()); - } -} diff --git a/src/console/clients/checker/mod.rs b/src/console/clients/checker/mod.rs deleted file mode 100644 index d26a4a686..000000000 --- a/src/console/clients/checker/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod app; -pub mod checks; -pub mod config; -pub mod console; -pub mod logger; -pub mod printer; -pub mod service; diff --git a/src/console/clients/checker/printer.rs b/src/console/clients/checker/printer.rs deleted file mode 100644 index d590dfedb..000000000 --- a/src/console/clients/checker/printer.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub const CLEAR_SCREEN: &str = "\x1B[2J\x1B[1;1H"; - -pub trait Printer { - fn clear(&self); - fn print(&self, output: &str); - fn eprint(&self, output: &str); - fn println(&self, output: &str); - fn eprintln(&self, output: &str); -} diff --git a/src/console/clients/checker/service.rs b/src/console/clients/checker/service.rs deleted file mode 100644 index acd312d8c..000000000 --- a/src/console/clients/checker/service.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; - -use futures::FutureExt as _; -use serde::Serialize; -use tokio::task::{JoinError, JoinSet}; -use torrust_tracker_configuration::DEFAULT_TIMEOUT; - -use super::checks::{health, http, udp}; -use super::config::Configuration; -use super::console::Console; -use crate::console::clients::checker::printer::Printer; - -pub struct Service { - pub(crate) config: Arc, - pub(crate) console: Console, -} - -#[derive(Debug, Clone, Serialize)] -pub enum CheckResult { - Udp(Result), - Http(Result), - Health(Result), -} - -impl Service { - /// # Errors - /// - /// It will return an error if some of the tests panic or otherwise fail to run. - /// On success it will return a vector of `Ok(())` of [`CheckResult`]. - /// - /// # Panics - /// - /// It would panic if `serde_json` produces invalid json for the `to_string_pretty` function. - pub async fn run_checks(self) -> Result, JoinError> { - tracing::info!("Running checks for trackers ..."); - - let mut check_results = Vec::default(); - - let mut checks = JoinSet::new(); - checks.spawn( - udp::run(self.config.udp_trackers.clone(), DEFAULT_TIMEOUT).map(|mut f| f.drain(..).map(CheckResult::Udp).collect()), - ); - checks.spawn( - http::run(self.config.http_trackers.clone(), DEFAULT_TIMEOUT) - .map(|mut f| f.drain(..).map(CheckResult::Http).collect()), - ); - checks.spawn( - health::run(self.config.health_checks.clone(), DEFAULT_TIMEOUT) - .map(|mut f| f.drain(..).map(CheckResult::Health).collect()), - ); - - while let Some(results) = checks.join_next().await { - check_results.append(&mut results?); - } - - let json_output = serde_json::json!(check_results); - self.console - .println(&serde_json::to_string_pretty(&json_output).expect("it should consume valid json")); - - Ok(check_results) - } -} diff --git a/src/console/clients/http/app.rs b/src/console/clients/http/app.rs deleted file mode 100644 index 6730c027d..000000000 --- a/src/console/clients/http/app.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! HTTP Tracker client: -//! -//! Examples: -//! -//! `Announce` request: -//! -//! ```text -//! cargo run --bin http_tracker_client announce http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! ``` -//! -//! `Scrape` request: -//! -//! ```text -//! cargo run --bin http_tracker_client scrape http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! ``` -use std::str::FromStr; -use std::time::Duration; - -use anyhow::Context; -use bittorrent_primitives::info_hash::InfoHash; -use clap::{Parser, Subcommand}; -use reqwest::Url; -use torrust_tracker_configuration::DEFAULT_TIMEOUT; - -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)] -struct Args { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - Announce { tracker_url: String, info_hash: String }, - Scrape { tracker_url: String, info_hashes: Vec }, -} - -/// # Errors -/// -/// Will return an error if the command fails. -pub async fn run() -> anyhow::Result<()> { - let args = Args::parse(); - - match args.command { - Command::Announce { tracker_url, info_hash } => { - announce_command(tracker_url, info_hash, DEFAULT_TIMEOUT).await?; - } - Command::Scrape { - tracker_url, - info_hashes, - } => { - scrape_command(&tracker_url, &info_hashes, DEFAULT_TIMEOUT).await?; - } - } - - Ok(()) -} - -async fn announce_command(tracker_url: String, info_hash: String, timeout: Duration) -> anyhow::Result<()> { - let base_url = Url::parse(&tracker_url).context("failed to parse HTTP tracker base URL")?; - let info_hash = - InfoHash::from_str(&info_hash).expect("Invalid infohash. Example infohash: `9c38422213e30bff212b30c360d26f9a02136422`"); - - let response = Client::new(base_url, timeout)? - .announce(&QueryBuilder::with_default_values().with_info_hash(&info_hash).query()) - .await?; - - let body = response.bytes().await?; - - let announce_response: Announce = serde_bencode::from_bytes(&body) - .unwrap_or_else(|_| panic!("response body should be a valid announce response, got: \"{:#?}\"", &body)); - - let json = serde_json::to_string(&announce_response).context("failed to serialize scrape response into JSON")?; - - println!("{json}"); - - Ok(()) -} - -async fn scrape_command(tracker_url: &str, info_hashes: &[String], timeout: Duration) -> anyhow::Result<()> { - let base_url = Url::parse(tracker_url).context("failed to parse HTTP tracker base URL")?; - - let query = requests::scrape::Query::try_from(info_hashes).context("failed to parse infohashes")?; - - let response = Client::new(base_url, timeout)?.scrape(&query).await?; - - let body = response.bytes().await?; - - let scrape_response = scrape::Response::try_from_bencoded(&body) - .unwrap_or_else(|_| panic!("response body should be a valid scrape response, got: \"{:#?}\"", &body)); - - let json = serde_json::to_string(&scrape_response).context("failed to serialize scrape response into JSON")?; - - println!("{json}"); - - Ok(()) -} diff --git a/src/console/clients/http/mod.rs b/src/console/clients/http/mod.rs deleted file mode 100644 index eaa71957f..000000000 --- a/src/console/clients/http/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::sync::Arc; - -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)] -#[serde(into = "String")] -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, - }, - #[error("Http failed to get a response at all: {err:?}")] - ResponseError { err: Arc }, - #[error("Failed to deserialize the bencoded response data with the error: \"{err:?}\"")] - ParseBencodeError { - data: hyper::body::Bytes, - err: Arc, - }, - - #[error("Failed to deserialize the bencoded response data with the error: \"{err:?}\"")] - BencodeParseError { - data: hyper::body::Bytes, - err: Arc, - }, -} - -impl From for String { - fn from(value: Error) -> Self { - value.to_string() - } -} diff --git a/src/console/clients/mod.rs b/src/console/clients/mod.rs deleted file mode 100644 index 8492f8ba5..000000000 --- a/src/console/clients/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Console clients. -pub mod checker; -pub mod http; -pub mod udp; diff --git a/src/console/clients/udp/app.rs b/src/console/clients/udp/app.rs deleted file mode 100644 index a2736c365..000000000 --- a/src/console/clients/udp/app.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! UDP Tracker client: -//! -//! Examples: -//! -//! Announce request: -//! -//! ```text -//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! ``` -//! -//! Announce response: -//! -//! ```json -//! { -//! "transaction_id": -888840697 -//! "announce_interval": 120, -//! "leechers": 0, -//! "seeders": 1, -//! "peers": [ -//! "123.123.123.123:51289" -//! ], -//! } -//! ``` -//! -//! Scrape request: -//! -//! ```text -//! cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! ``` -//! -//! Scrape response: -//! -//! ```json -//! { -//! "transaction_id": -888840697, -//! "torrent_stats": [ -//! { -//! "completed": 0, -//! "leechers": 0, -//! "seeders": 0 -//! }, -//! { -//! "completed": 0, -//! "leechers": 0, -//! "seeders": 0 -//! } -//! ] -//! } -//! ``` -//! -//! You can use an URL with instead of the socket address. For example: -//! -//! ```text -//! cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq -//! ``` -//! -//! The protocol (`udp://`) in the URL is mandatory. The path (`\scrape`) is optional. It always uses `\scrape`. -use std::net::{SocketAddr, ToSocketAddrs}; -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 tracing::level_filters::LevelFilter; -use url::Url; - -use super::Error; -use crate::console::clients::udp::checker; -use crate::console::clients::udp::responses::dto::SerializableResponse; -use crate::console::clients::udp::responses::json::ToJson; - -const RANDOM_TRANSACTION_ID: i32 = -888_840_697; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - Announce { - #[arg(value_parser = parse_socket_addr)] - tracker_socket_addr: SocketAddr, - #[arg(value_parser = parse_info_hash)] - info_hash: TorrustInfoHash, - }, - Scrape { - #[arg(value_parser = parse_socket_addr)] - tracker_socket_addr: SocketAddr, - #[arg(value_parser = parse_info_hash, num_args = 1..=74, value_delimiter = ' ')] - info_hashes: Vec, - }, -} - -/// # Errors -/// -/// Will return an error if the command fails. -/// -/// -pub async fn run() -> anyhow::Result<()> { - tracing_stdout_init(LevelFilter::INFO); - - let args = Args::parse(); - - let response = match args.command { - Command::Announce { - tracker_socket_addr: remote_addr, - info_hash, - } => handle_announce(remote_addr, &info_hash).await?, - Command::Scrape { - tracker_socket_addr: remote_addr, - info_hashes, - } => handle_scrape(remote_addr, &info_hashes).await?, - }; - - let response: SerializableResponse = response.into(); - let response_json = response.to_json_string()?; - - print!("{response_json}"); - - Ok(()) -} - -fn tracing_stdout_init(filter: LevelFilter) { - tracing_subscriber::fmt().with_max_level(filter).init(); - tracing::debug!("Logging initialized"); -} - -async fn handle_announce(remote_addr: SocketAddr, info_hash: &TorrustInfoHash) -> Result { - let transaction_id = TransactionId::new(RANDOM_TRANSACTION_ID); - - let client = checker::Client::new(remote_addr, DEFAULT_TIMEOUT).await?; - - let connection_id = client.send_connection_request(transaction_id).await?; - - client.send_announce_request(transaction_id, connection_id, *info_hash).await -} - -async fn handle_scrape(remote_addr: SocketAddr, info_hashes: &[TorrustInfoHash]) -> Result { - let transaction_id = TransactionId::new(RANDOM_TRANSACTION_ID); - - let client = checker::Client::new(remote_addr, DEFAULT_TIMEOUT).await?; - - let connection_id = client.send_connection_request(transaction_id).await?; - - client.send_scrape_request(connection_id, transaction_id, info_hashes).await -} - -fn parse_socket_addr(tracker_socket_addr_str: &str) -> anyhow::Result { - tracing::debug!("Tracker socket address: {tracker_socket_addr_str:#?}"); - - // Check if the address is a valid URL. If so, extract the host and port. - let resolved_addr = if let Ok(url) = Url::parse(tracker_socket_addr_str) { - tracing::debug!("Tracker socket address URL: {url:?}"); - - let host = url - .host_str() - .with_context(|| format!("invalid host in URL: `{tracker_socket_addr_str}`"))? - .to_owned(); - - let port = url - .port() - .with_context(|| format!("port not found in URL: `{tracker_socket_addr_str}`"))? - .to_owned(); - - (host, port) - } else { - // If not a URL, assume it's a host:port pair. - - let parts: Vec<&str> = tracker_socket_addr_str.split(':').collect(); - - if parts.len() != 2 { - return Err(anyhow::anyhow!( - "invalid address format: `{}`. Expected format is host:port", - tracker_socket_addr_str - )); - } - - let host = parts[0].to_owned(); - - let port = parts[1] - .parse::() - .with_context(|| format!("invalid port: `{}`", parts[1]))? - .to_owned(); - - (host, port) - }; - - tracing::debug!("Resolved address: {resolved_addr:#?}"); - - // Perform DNS resolution. - let socket_addrs: Vec<_> = resolved_addr.to_socket_addrs()?.collect(); - if socket_addrs.is_empty() { - Err(anyhow::anyhow!("DNS resolution failed for `{}`", tracker_socket_addr_str)) - } else { - Ok(socket_addrs[0]) - } -} - -fn parse_info_hash(info_hash_str: &str) -> anyhow::Result { - TorrustInfoHash::from_str(info_hash_str) - .map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{info_hash_str}`: {e:?}"))) -} diff --git a/src/console/clients/udp/checker.rs b/src/console/clients/udp/checker.rs deleted file mode 100644 index 14e94c132..000000000 --- a/src/console/clients/udp/checker.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::net::{Ipv4Addr, SocketAddr}; -use std::num::NonZeroU16; -use std::time::Duration; - -use aquatic_udp_protocol::common::InfoHash; -use aquatic_udp_protocol::{ - AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, - PeerId, PeerKey, Port, Response, ScrapeRequest, TransactionId, -}; -use bittorrent_primitives::info_hash::InfoHash as TorrustInfoHash; - -use super::Error; -use crate::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; - -/// A UDP Tracker client to make test requests (checks). -#[derive(Debug)] -pub struct Client { - client: UdpTrackerClient, -} - -impl Client { - /// Creates a new `[Client]` for checking a UDP Tracker Service - /// - /// # Errors - /// - /// It will error if unable to bind and connect to the udp remote address. - /// - pub async fn new(remote_addr: SocketAddr, timeout: Duration) -> Result { - let client = UdpTrackerClient::new(remote_addr, timeout) - .await - .map_err(|err| Error::UnableToBindAndConnect { remote_addr, err })?; - - Ok(Self { client }) - } - - /// Returns the local addr of this [`Client`]. - /// - /// # Errors - /// - /// This function will return an error if the socket is somehow not bound. - pub fn local_addr(&self) -> std::io::Result { - self.client.client.socket.local_addr() - } - - /// Sends a connection request to the UDP Tracker server. - /// - /// # Errors - /// - /// Will return and error if - /// - /// - It can't connect to the remote UDP socket. - /// - It can't make a connection request successfully to the remote UDP - /// server (after successfully connecting to the remote UDP socket). - /// - /// # Panics - /// - /// Will panic if it receives an unexpected response. - pub async fn send_connection_request(&self, transaction_id: TransactionId) -> Result { - tracing::debug!("Sending connection request with transaction id: {transaction_id:#?}"); - - let connect_request = ConnectRequest { transaction_id }; - - let _ = self - .client - .send(connect_request.into()) - .await - .map_err(|err| Error::UnableToSendConnectionRequest { err })?; - - let response = self - .client - .receive() - .await - .map_err(|err| Error::UnableToReceiveConnectResponse { err })?; - - match response { - Response::Connect(connect_response) => Ok(connect_response.connection_id), - _ => Err(Error::UnexpectedConnectionResponse { response }), - } - } - - /// Sends an announce request to the UDP Tracker server. - /// - /// # Errors - /// - /// Will return and error if the client is not connected. You have to connect - /// before calling this function. - /// - /// # Panics - /// - /// It will panic if the `local_address` has a zero port. - pub async fn send_announce_request( - &self, - transaction_id: TransactionId, - connection_id: ConnectionId, - info_hash: TorrustInfoHash, - ) -> Result { - tracing::debug!("Sending announce request with transaction id: {transaction_id:#?}"); - - let port = NonZeroU16::new( - self.client - .client - .socket - .local_addr() - .expect("it should get the local address") - .port(), - ) - .expect("it should no be zero"); - - let announce_request = AnnounceRequest { - connection_id, - action_placeholder: AnnounceActionPlaceholder::default(), - transaction_id, - info_hash: InfoHash(info_hash.bytes()), - peer_id: PeerId(*b"-qB00000000000000001"), - 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::new(port), - }; - - let _ = self - .client - .send(announce_request.into()) - .await - .map_err(|err| Error::UnableToSendAnnounceRequest { err })?; - - let response = self - .client - .receive() - .await - .map_err(|err| Error::UnableToReceiveAnnounceResponse { err })?; - - Ok(response) - } - - /// Sends a scrape request to the UDP Tracker server. - /// - /// # Errors - /// - /// Will return and error if the client is not connected. You have to connect - /// before calling this function. - pub async fn send_scrape_request( - &self, - connection_id: ConnectionId, - transaction_id: TransactionId, - info_hashes: &[TorrustInfoHash], - ) -> Result { - tracing::debug!("Sending scrape request with transaction id: {transaction_id:#?}"); - - let scrape_request = ScrapeRequest { - connection_id, - transaction_id, - info_hashes: info_hashes - .iter() - .map(|torrust_info_hash| InfoHash(torrust_info_hash.bytes())) - .collect(), - }; - - let _ = self - .client - .send(scrape_request.into()) - .await - .map_err(|err| Error::UnableToSendScrapeRequest { err })?; - - let response = self - .client - .receive() - .await - .map_err(|err| Error::UnableToReceiveScrapeResponse { err })?; - - Ok(response) - } -} diff --git a/src/console/clients/udp/mod.rs b/src/console/clients/udp/mod.rs deleted file mode 100644 index b92bed096..000000000 --- a/src/console/clients/udp/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::net::SocketAddr; - -use aquatic_udp_protocol::Response; -use serde::Serialize; -use thiserror::Error; - -use crate::shared::bit_torrent::tracker::udp; - -pub mod app; -pub mod checker; -pub mod responses; - -#[derive(Error, Debug, Clone, Serialize)] -#[serde(into = "String")] -pub enum Error { - #[error("Failed to Connect to: {remote_addr}, with error: {err}")] - UnableToBindAndConnect { remote_addr: SocketAddr, err: udp::Error }, - - #[error("Failed to send a connection request, with error: {err}")] - UnableToSendConnectionRequest { err: udp::Error }, - - #[error("Failed to receive a connect response, with error: {err}")] - UnableToReceiveConnectResponse { err: udp::Error }, - - #[error("Failed to send a announce request, with error: {err}")] - UnableToSendAnnounceRequest { err: udp::Error }, - - #[error("Failed to receive a announce response, with error: {err}")] - UnableToReceiveAnnounceResponse { err: udp::Error }, - - #[error("Failed to send a scrape request, with error: {err}")] - UnableToSendScrapeRequest { err: udp::Error }, - - #[error("Failed to receive a scrape response, with error: {err}")] - UnableToReceiveScrapeResponse { err: udp::Error }, - - #[error("Failed to receive a response, with error: {err}")] - UnableToReceiveResponse { err: udp::Error }, - - #[error("Failed to get local address for connection: {err}")] - UnableToGetLocalAddr { err: udp::Error }, - - #[error("Failed to get a connection response: {response:?}")] - UnexpectedConnectionResponse { response: Response }, -} - -impl From for String { - fn from(value: Error) -> Self { - value.to_string() - } -} diff --git a/src/console/clients/udp/responses/dto.rs b/src/console/clients/udp/responses/dto.rs deleted file mode 100644 index 93320b0f7..000000000 --- a/src/console/clients/udp/responses/dto.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Aquatic responses are not serializable. These are the serializable wrappers. -use std::net::{Ipv4Addr, Ipv6Addr}; - -use aquatic_udp_protocol::Response::{self}; -use aquatic_udp_protocol::{AnnounceResponse, ConnectResponse, ErrorResponse, Ipv4AddrBytes, Ipv6AddrBytes, ScrapeResponse}; -use serde::Serialize; - -#[derive(Serialize)] -pub enum SerializableResponse { - Connect(ConnectSerializableResponse), - AnnounceIpv4(AnnounceSerializableResponse), - AnnounceIpv6(AnnounceSerializableResponse), - Scrape(ScrapeSerializableResponse), - Error(ErrorSerializableResponse), -} - -impl From for SerializableResponse { - fn from(response: Response) -> Self { - match response { - Response::Connect(response) => SerializableResponse::Connect(ConnectSerializableResponse::from(response)), - Response::AnnounceIpv4(response) => SerializableResponse::AnnounceIpv4(AnnounceSerializableResponse::from(response)), - Response::AnnounceIpv6(response) => SerializableResponse::AnnounceIpv6(AnnounceSerializableResponse::from(response)), - Response::Scrape(response) => SerializableResponse::Scrape(ScrapeSerializableResponse::from(response)), - Response::Error(response) => SerializableResponse::Error(ErrorSerializableResponse::from(response)), - } - } -} - -#[derive(Serialize)] -pub struct ConnectSerializableResponse { - transaction_id: i32, - connection_id: i64, -} - -impl From for ConnectSerializableResponse { - fn from(connect: ConnectResponse) -> Self { - Self { - transaction_id: connect.transaction_id.0.into(), - connection_id: connect.connection_id.0.into(), - } - } -} - -#[derive(Serialize)] -pub struct AnnounceSerializableResponse { - transaction_id: i32, - announce_interval: i32, - leechers: i32, - seeders: i32, - peers: Vec, -} - -impl From> for AnnounceSerializableResponse { - fn from(announce: AnnounceResponse) -> Self { - Self { - transaction_id: announce.fixed.transaction_id.0.into(), - announce_interval: announce.fixed.announce_interval.0.into(), - leechers: announce.fixed.leechers.0.into(), - seeders: announce.fixed.seeders.0.into(), - peers: announce - .peers - .iter() - .map(|peer| format!("{}:{}", Ipv4Addr::from(peer.ip_address), peer.port.0)) - .collect::>(), - } - } -} - -impl From> for AnnounceSerializableResponse { - fn from(announce: AnnounceResponse) -> Self { - Self { - transaction_id: announce.fixed.transaction_id.0.into(), - announce_interval: announce.fixed.announce_interval.0.into(), - leechers: announce.fixed.leechers.0.into(), - seeders: announce.fixed.seeders.0.into(), - peers: announce - .peers - .iter() - .map(|peer| format!("{}:{}", Ipv6Addr::from(peer.ip_address), peer.port.0)) - .collect::>(), - } - } -} - -#[derive(Serialize)] -pub struct ScrapeSerializableResponse { - transaction_id: i32, - torrent_stats: Vec, -} - -impl From for ScrapeSerializableResponse { - fn from(scrape: ScrapeResponse) -> Self { - Self { - transaction_id: scrape.transaction_id.0.into(), - torrent_stats: scrape - .torrent_stats - .iter() - .map(|torrent_scrape_statistics| TorrentStats { - seeders: torrent_scrape_statistics.seeders.0.into(), - completed: torrent_scrape_statistics.completed.0.into(), - leechers: torrent_scrape_statistics.leechers.0.into(), - }) - .collect::>(), - } - } -} - -#[derive(Serialize)] -pub struct ErrorSerializableResponse { - transaction_id: i32, - message: String, -} - -impl From for ErrorSerializableResponse { - fn from(error: ErrorResponse) -> Self { - Self { - transaction_id: error.transaction_id.0.into(), - message: error.message.to_string(), - } - } -} - -#[derive(Serialize)] -struct TorrentStats { - seeders: i32, - completed: i32, - leechers: i32, -} diff --git a/src/console/clients/udp/responses/json.rs b/src/console/clients/udp/responses/json.rs deleted file mode 100644 index 5d2bd6b89..000000000 --- a/src/console/clients/udp/responses/json.rs +++ /dev/null @@ -1,25 +0,0 @@ -use anyhow::Context; -use serde::Serialize; - -use super::dto::SerializableResponse; - -#[allow(clippy::module_name_repetitions)] -pub trait ToJson { - /// - /// Returns a string with the JSON serialized version of the response - /// - /// # Errors - /// - /// Will return an error if serialization fails. - /// - fn to_json_string(&self) -> anyhow::Result - where - Self: Serialize, - { - let pretty_json = serde_json::to_string_pretty(self).context("response JSON serialization")?; - - Ok(pretty_json) - } -} - -impl ToJson for SerializableResponse {} diff --git a/src/console/clients/udp/responses/mod.rs b/src/console/clients/udp/responses/mod.rs deleted file mode 100644 index e6d2e5e51..000000000 --- a/src/console/clients/udp/responses/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod dto; -pub mod json; 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/servers/udp/server/launcher.rs b/src/servers/udp/server/launcher.rs index c9ad213f6..7f31d7739 100644 --- a/src/servers/udp/server/launcher.rs +++ b/src/servers/udp/server/launcher.rs @@ -2,6 +2,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; +use bittorrent_tracker_client::udp::client::check; use derive_more::Constructor; use futures_util::StreamExt; use tokio::select; @@ -18,7 +19,6 @@ 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)] diff --git a/src/shared/bit_torrent/tracker/http/client/mod.rs b/src/shared/bit_torrent/tracker/http/client/mod.rs deleted file mode 100644 index 4c70cd68b..000000000 --- a/src/shared/bit_torrent/tracker/http/client/mod.rs +++ /dev/null @@ -1,204 +0,0 @@ -pub mod requests; -pub mod responses; - -use std::net::IpAddr; -use std::sync::Arc; -use std::time::Duration; - -use hyper::StatusCode; -use requests::{announce, scrape}; -use reqwest::{Response, Url}; -use thiserror::Error; - -use crate::core::auth::Key; - -#[derive(Debug, Clone, Error)] -pub enum Error { - #[error("Failed to Build a Http Client: {err:?}")] - ClientBuildingError { err: Arc }, - #[error("Failed to get a response: {err:?}")] - ResponseError { err: Arc }, - #[error("Returned a non-success code: \"{code}\" with the response: \"{response:?}\"")] - UnsuccessfulResponse { code: StatusCode, response: Arc }, -} - -/// HTTP Tracker Client -pub struct Client { - client: reqwest::Client, - base_url: Url, - key: Option, -} - -/// URL components in this context: -/// -/// ```text -/// http://127.0.0.1:62304/announce/YZ....rJ?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 -/// \_____________________/\_______________/ \__________________________________________________________/ -/// | | | -/// base url path query -/// ``` -impl Client { - /// # Errors - /// - /// This method fails if the client builder fails. - pub fn new(base_url: Url, timeout: Duration) -> Result { - let client = reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|e| Error::ClientBuildingError { err: e.into() })?; - - Ok(Self { - base_url, - client, - key: None, - }) - } - - /// Creates the new client binding it to an specific local address. - /// - /// # Errors - /// - /// This method fails if the client builder fails. - pub fn bind(base_url: Url, timeout: Duration, local_address: IpAddr) -> Result { - let client = reqwest::Client::builder() - .timeout(timeout) - .local_address(local_address) - .build() - .map_err(|e| Error::ClientBuildingError { err: e.into() })?; - - Ok(Self { - base_url, - client, - key: None, - }) - } - - /// # Errors - /// - /// This method fails if the client builder fails. - pub fn authenticated(base_url: Url, timeout: Duration, key: Key) -> Result { - let client = reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|e| Error::ClientBuildingError { err: e.into() })?; - - Ok(Self { - base_url, - client, - key: Some(key), - }) - } - - /// # Errors - /// - /// This method fails if the returned response was not successful - pub async fn announce(&self, query: &announce::Query) -> Result { - let response = self.get(&self.build_announce_path_and_query(query)).await?; - - if response.status().is_success() { - Ok(response) - } else { - Err(Error::UnsuccessfulResponse { - code: response.status(), - response: response.into(), - }) - } - } - - /// # Errors - /// - /// This method fails if the returned response was not successful - pub async fn scrape(&self, query: &scrape::Query) -> Result { - let response = self.get(&self.build_scrape_path_and_query(query)).await?; - - if response.status().is_success() { - Ok(response) - } else { - Err(Error::UnsuccessfulResponse { - code: response.status(), - response: response.into(), - }) - } - } - - /// # Errors - /// - /// This method fails if the returned response was not successful - pub async fn announce_with_header(&self, query: &announce::Query, key: &str, value: &str) -> Result { - let response = self - .get_with_header(&self.build_announce_path_and_query(query), key, value) - .await?; - - if response.status().is_success() { - Ok(response) - } else { - Err(Error::UnsuccessfulResponse { - code: response.status(), - response: response.into(), - }) - } - } - - /// # Errors - /// - /// This method fails if the returned response was not successful - pub async fn health_check(&self) -> Result { - let response = self.get(&self.build_path("health_check")).await?; - - if response.status().is_success() { - Ok(response) - } else { - Err(Error::UnsuccessfulResponse { - code: response.status(), - response: response.into(), - }) - } - } - - /// # Errors - /// - /// This method fails if there was an error while sending request. - pub async fn get(&self, path: &str) -> Result { - self.client - .get(self.build_url(path)) - .send() - .await - .map_err(|e| Error::ResponseError { err: e.into() }) - } - - /// # Errors - /// - /// 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 - .get(self.build_url(path)) - .header(key, value) - .send() - .await - .map_err(|e| Error::ResponseError { err: e.into() }) - } - - fn build_announce_path_and_query(&self, query: &announce::Query) -> String { - format!("{}?{query}", self.build_path("announce")) - } - - fn build_scrape_path_and_query(&self, query: &scrape::Query) -> String { - format!("{}?{query}", self.build_path("scrape")) - } - - fn build_path(&self, path: &str) -> String { - match &self.key { - Some(key) => format!("{path}/{key}"), - None => path.to_string(), - } - } - - fn build_url(&self, path: &str) -> String { - let base_url = self.base_url(); - format!("{base_url}{path}") - } - - fn base_url(&self) -> String { - self.base_url.to_string() - } -} diff --git a/src/shared/bit_torrent/tracker/http/client/requests/announce.rs b/src/shared/bit_torrent/tracker/http/client/requests/announce.rs deleted file mode 100644 index f3ce327ea..000000000 --- a/src/shared/bit_torrent/tracker/http/client/requests/announce.rs +++ /dev/null @@ -1,275 +0,0 @@ -use std::fmt; -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 crate::shared::bit_torrent::tracker::http::{percent_encode_byte_array, ByteArray20}; - -pub struct Query { - pub info_hash: ByteArray20, - pub peer_addr: IpAddr, - pub downloaded: BaseTenASCII, - pub uploaded: BaseTenASCII, - pub peer_id: ByteArray20, - pub port: PortNumber, - pub left: BaseTenASCII, - pub event: Option, - pub compact: Option, -} - -impl fmt::Display for Query { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.build()) - } -} - -/// HTTP Tracker Announce Request: -/// -/// -/// -/// Some parameters in the specification are not implemented in this tracker yet. -impl Query { - /// It builds the URL query component for the announce request. - /// - /// This custom URL query params encoding is needed because `reqwest` does not allow - /// bytes arrays in query parameters. More info on this issue: - /// - /// - #[must_use] - pub fn build(&self) -> String { - self.params().to_string() - } - - #[must_use] - pub fn params(&self) -> QueryParams { - QueryParams::from(self) - } -} - -pub type BaseTenASCII = u64; -pub type PortNumber = u16; - -pub enum Event { - //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::Completed => write!(f, "completed"), - } - } -} - -#[derive(Serialize_repr, PartialEq, Debug)] -#[repr(u8)] -pub enum Compact { - Accepted = 1, - NotAccepted = 0, -} - -impl fmt::Display for Compact { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Compact::Accepted => write!(f, "1"), - Compact::NotAccepted => write!(f, "0"), - } - } -} - -pub struct QueryBuilder { - announce_query: Query, -} - -impl QueryBuilder { - /// # Panics - /// - /// Will panic if the default info-hash value is not a valid info-hash. - #[must_use] - pub fn with_default_values() -> QueryBuilder { - let default_announce_query = Query { - 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), - compact: Some(Compact::NotAccepted), - }; - Self { - announce_query: default_announce_query, - } - } - - #[must_use] - pub fn with_info_hash(mut self, info_hash: &InfoHash) -> Self { - self.announce_query.info_hash = info_hash.0; - self - } - - #[must_use] - pub fn with_peer_id(mut self, peer_id: &PeerId) -> Self { - self.announce_query.peer_id = peer_id.0; - self - } - - #[must_use] - pub fn with_compact(mut self, compact: Compact) -> Self { - self.announce_query.compact = Some(compact); - self - } - - #[must_use] - pub fn with_peer_addr(mut self, peer_addr: &IpAddr) -> Self { - self.announce_query.peer_addr = *peer_addr; - self - } - - #[must_use] - pub fn without_compact(mut self) -> Self { - self.announce_query.compact = None; - self - } - - #[must_use] - pub fn query(self) -> Query { - self.announce_query - } -} - -/// It contains all the GET parameters that can be used in a HTTP Announce request. -/// -/// Sample Announce URL with all the GET parameters (mandatory and optional): -/// -/// ```text -/// http://127.0.0.1:7070/announce? -/// info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 (mandatory) -/// peer_addr=192.168.1.88 -/// downloaded=0 -/// uploaded=0 -/// peer_id=%2DqB00000000000000000 (mandatory) -/// port=17548 (mandatory) -/// left=0 -/// event=completed -/// compact=0 -/// ``` -pub struct QueryParams { - pub info_hash: Option, - pub peer_addr: Option, - pub downloaded: Option, - pub uploaded: Option, - pub peer_id: Option, - pub port: Option, - pub left: Option, - pub event: Option, - pub compact: Option, -} - -impl std::fmt::Display for QueryParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut params = vec![]; - - if let Some(info_hash) = &self.info_hash { - params.push(("info_hash", info_hash)); - } - if let Some(peer_addr) = &self.peer_addr { - params.push(("peer_addr", peer_addr)); - } - if let Some(downloaded) = &self.downloaded { - params.push(("downloaded", downloaded)); - } - if let Some(uploaded) = &self.uploaded { - params.push(("uploaded", uploaded)); - } - if let Some(peer_id) = &self.peer_id { - params.push(("peer_id", peer_id)); - } - if let Some(port) = &self.port { - params.push(("port", port)); - } - if let Some(left) = &self.left { - params.push(("left", left)); - } - if let Some(event) = &self.event { - params.push(("event", event)); - } - if let Some(compact) = &self.compact { - params.push(("compact", compact)); - } - - let query = params - .iter() - .map(|param| format!("{}={}", param.0, param.1)) - .collect::>() - .join("&"); - - write!(f, "{query}") - } -} - -impl QueryParams { - pub fn from(announce_query: &Query) -> Self { - let event = announce_query.event.as_ref().map(std::string::ToString::to_string); - let compact = announce_query.compact.as_ref().map(std::string::ToString::to_string); - - Self { - info_hash: Some(percent_encode_byte_array(&announce_query.info_hash)), - peer_addr: Some(announce_query.peer_addr.to_string()), - downloaded: Some(announce_query.downloaded.to_string()), - uploaded: Some(announce_query.uploaded.to_string()), - peer_id: Some(percent_encode_byte_array(&announce_query.peer_id)), - port: Some(announce_query.port.to_string()), - left: Some(announce_query.left.to_string()), - event, - compact, - } - } - - pub fn remove_optional_params(&mut self) { - // todo: make them optional with the Option<...> in the AnnounceQuery struct - // if they are really optional. So that we can crete a minimal AnnounceQuery - // instead of removing the optional params afterwards. - // - // The original specification on: - // - // says only `ip` and `event` are optional. - // - // On - // says only `ip`, `numwant`, `key` and `trackerid` are optional. - // - // but the server is responding if all these params are not included. - self.peer_addr = None; - self.downloaded = None; - self.uploaded = None; - self.left = None; - self.event = None; - self.compact = None; - } - - /// # Panics - /// - /// Will panic if invalid param name is provided. - pub fn set(&mut self, param_name: &str, param_value: &str) { - match param_name { - "info_hash" => self.info_hash = Some(param_value.to_string()), - "peer_addr" => self.peer_addr = Some(param_value.to_string()), - "downloaded" => self.downloaded = Some(param_value.to_string()), - "uploaded" => self.uploaded = Some(param_value.to_string()), - "peer_id" => self.peer_id = Some(param_value.to_string()), - "port" => self.port = Some(param_value.to_string()), - "left" => self.left = Some(param_value.to_string()), - "event" => self.event = Some(param_value.to_string()), - "compact" => self.compact = Some(param_value.to_string()), - &_ => panic!("Invalid param name for announce query"), - } - } -} diff --git a/src/shared/bit_torrent/tracker/http/client/requests/mod.rs b/src/shared/bit_torrent/tracker/http/client/requests/mod.rs deleted file mode 100644 index 776d2dfbf..000000000 --- a/src/shared/bit_torrent/tracker/http/client/requests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod announce; -pub mod scrape; diff --git a/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs b/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs deleted file mode 100644 index 58b9e0dc7..000000000 --- a/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::error::Error; -use std::fmt::{self}; -use std::str::FromStr; - -use bittorrent_primitives::info_hash::InfoHash; - -use crate::shared::bit_torrent::tracker::http::{percent_encode_byte_array, ByteArray20}; - -pub struct Query { - pub info_hash: Vec, -} - -impl fmt::Display for Query { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.build()) - } -} - -#[derive(Debug)] -#[allow(dead_code)] -pub struct ConversionError(String); - -impl fmt::Display for ConversionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Invalid infohash: {}", self.0) - } -} - -impl Error for ConversionError {} - -impl TryFrom<&[String]> for Query { - type Error = ConversionError; - - fn try_from(info_hashes: &[String]) -> Result { - let mut validated_info_hashes: Vec = Vec::new(); - - for info_hash in info_hashes { - let validated_info_hash = InfoHash::from_str(info_hash).map_err(|_| ConversionError(info_hash.clone()))?; - validated_info_hashes.push(validated_info_hash.0); - } - - Ok(Self { - info_hash: validated_info_hashes, - }) - } -} - -impl TryFrom> for Query { - type Error = ConversionError; - - fn try_from(info_hashes: Vec) -> Result { - let mut validated_info_hashes: Vec = Vec::new(); - - for info_hash in info_hashes { - let validated_info_hash = InfoHash::from_str(&info_hash).map_err(|_| ConversionError(info_hash.clone()))?; - validated_info_hashes.push(validated_info_hash.0); - } - - Ok(Self { - info_hash: validated_info_hashes, - }) - } -} - -/// HTTP Tracker Scrape Request: -/// -/// -impl Query { - /// It builds the URL query component for the scrape request. - /// - /// This custom URL query params encoding is needed because `reqwest` does not allow - /// bytes arrays in query parameters. More info on this issue: - /// - /// - #[must_use] - pub fn build(&self) -> String { - self.params().to_string() - } - - #[must_use] - pub fn params(&self) -> QueryParams { - QueryParams::from(self) - } -} - -pub struct QueryBuilder { - scrape_query: Query, -} - -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 - }; - Self { - scrape_query: default_scrape_query, - } - } -} - -impl QueryBuilder { - #[must_use] - pub fn with_one_info_hash(mut self, info_hash: &InfoHash) -> Self { - self.scrape_query.info_hash = [info_hash.0].to_vec(); - self - } - - #[must_use] - pub fn add_info_hash(mut self, info_hash: &InfoHash) -> Self { - self.scrape_query.info_hash.push(info_hash.0); - self - } - - #[must_use] - pub fn query(self) -> Query { - self.scrape_query - } -} - -/// It contains all the GET parameters that can be used in a HTTP Scrape request. -/// -/// The `info_hash` param is the percent encoded of the the 20-byte array info hash. -/// -/// Sample Scrape URL with all the GET parameters: -/// -/// For `IpV4`: -/// -/// ```text -/// http://127.0.0.1:7070/scrape?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 -/// ``` -/// -/// For `IpV6`: -/// -/// ```text -/// http://[::1]:7070/scrape?info_hash=%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22 -/// ``` -/// -/// You can add as many info hashes as you want, just adding the same param again. -pub struct QueryParams { - pub info_hash: Vec, -} - -impl QueryParams { - pub fn set_one_info_hash_param(&mut self, info_hash: &str) { - self.info_hash = vec![info_hash.to_string()]; - } -} - -impl std::fmt::Display for QueryParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let query = self - .info_hash - .iter() - .map(|info_hash| format!("info_hash={}", &info_hash)) - .collect::>() - .join("&"); - - write!(f, "{query}") - } -} - -impl QueryParams { - pub fn from(scrape_query: &Query) -> Self { - let info_hashes = scrape_query - .info_hash - .iter() - .map(percent_encode_byte_array) - .collect::>(); - - Self { info_hash: info_hashes } - } -} diff --git a/src/shared/bit_torrent/tracker/http/client/responses/announce.rs b/src/shared/bit_torrent/tracker/http/client/responses/announce.rs deleted file mode 100644 index 7f2d3611c..000000000 --- a/src/shared/bit_torrent/tracker/http/client/responses/announce.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - -use serde::{Deserialize, Serialize}; -use torrust_tracker_primitives::peer; -use zerocopy::AsBytes as _; - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct Announce { - pub complete: u32, - pub incomplete: u32, - pub interval: u32, - #[serde(rename = "min interval")] - pub min_interval: u32, - pub peers: Vec, // Peers using IPV4 and IPV6 -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct DictionaryPeer { - pub ip: String, - #[serde(rename = "peer id")] - #[serde(with = "serde_bytes")] - pub peer_id: Vec, - pub port: u16, -} - -impl From for DictionaryPeer { - fn from(peer: peer::Peer) -> Self { - DictionaryPeer { - peer_id: peer.peer_id.as_bytes().to_vec(), - ip: peer.peer_addr.ip().to_string(), - port: peer.peer_addr.port(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct DeserializedCompact { - pub complete: u32, - pub incomplete: u32, - pub interval: u32, - #[serde(rename = "min interval")] - pub min_interval: u32, - #[serde(with = "serde_bytes")] - pub peers: Vec, -} - -impl DeserializedCompact { - /// # Errors - /// - /// Will return an error if bytes can't be deserialized. - pub fn from_bytes(bytes: &[u8]) -> Result { - serde_bencode::from_bytes::(bytes) - } -} - -#[derive(Debug, PartialEq)] -pub struct Compact { - // code-review: there could be a way to deserialize this struct directly - // by using serde instead of doing it manually. Or at least using a custom deserializer. - pub complete: u32, - pub incomplete: u32, - pub interval: u32, - pub min_interval: u32, - pub peers: CompactPeerList, -} - -#[derive(Debug, PartialEq)] -pub struct CompactPeerList { - peers: Vec, -} - -impl CompactPeerList { - #[must_use] - pub fn new(peers: Vec) -> Self { - Self { peers } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CompactPeer { - ip: Ipv4Addr, - port: u16, -} - -impl CompactPeer { - /// # Panics - /// - /// Will panic if the provided socket address is a IPv6 IP address. - /// It's not supported for compact peers. - #[must_use] - pub fn new(socket_addr: &SocketAddr) -> Self { - match socket_addr.ip() { - IpAddr::V4(ip) => Self { - ip, - port: socket_addr.port(), - }, - IpAddr::V6(_ip) => panic!("IPV6 is not supported for compact peer"), - } - } - - #[must_use] - pub fn new_from_bytes(bytes: &[u8]) -> Self { - Self { - ip: Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]), - port: u16::from_be_bytes([bytes[4], bytes[5]]), - } - } -} - -impl From for Compact { - fn from(compact_announce: DeserializedCompact) -> Self { - let mut peers = vec![]; - - for peer_bytes in compact_announce.peers.chunks_exact(6) { - peers.push(CompactPeer::new_from_bytes(peer_bytes)); - } - - Self { - complete: compact_announce.complete, - incomplete: compact_announce.incomplete, - interval: compact_announce.interval, - min_interval: compact_announce.min_interval, - peers: CompactPeerList::new(peers), - } - } -} diff --git a/src/shared/bit_torrent/tracker/http/client/responses/error.rs b/src/shared/bit_torrent/tracker/http/client/responses/error.rs deleted file mode 100644 index 00befdb54..000000000 --- a/src/shared/bit_torrent/tracker/http/client/responses/error.rs +++ /dev/null @@ -1,7 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct Error { - #[serde(rename = "failure reason")] - pub failure_reason: String, -} diff --git a/src/shared/bit_torrent/tracker/http/client/responses/mod.rs b/src/shared/bit_torrent/tracker/http/client/responses/mod.rs deleted file mode 100644 index bdc689056..000000000 --- a/src/shared/bit_torrent/tracker/http/client/responses/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod announce; -pub mod error; -pub mod scrape; diff --git a/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs b/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs deleted file mode 100644 index 25a2f0a81..000000000 --- a/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Write; -use std::str; - -use serde::ser::SerializeMap; -use serde::{Deserialize, Serialize, Serializer}; -use serde_bencode::value::Value; - -use crate::shared::bit_torrent::tracker::http::{ByteArray20, InfoHash}; - -#[derive(Debug, PartialEq, Default, Deserialize)] -pub struct Response { - pub files: HashMap, -} - -impl Response { - #[must_use] - pub fn with_one_file(info_hash_bytes: ByteArray20, file: File) -> Self { - let mut files: HashMap = HashMap::new(); - files.insert(info_hash_bytes, file); - Self { files } - } - - /// # Errors - /// - /// Will return an error if the deserialized bencoded response can't not be converted into a valid response. - /// - /// # Panics - /// - /// Will panic if it can't deserialize the bencoded response. - pub fn try_from_bencoded(bytes: &[u8]) -> Result { - let scrape_response: DeserializedResponse = - serde_bencode::from_bytes(bytes).expect("provided bytes should be a valid bencoded response"); - Self::try_from(scrape_response) - } -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Default)] -pub struct File { - pub complete: i64, // The number of active peers that have completed downloading - pub downloaded: i64, // The number of peers that have ever completed downloading - pub incomplete: i64, // The number of active peers that have not completed downloading -} - -impl File { - #[must_use] - pub fn zeroed() -> Self { - Self::default() - } -} - -impl TryFrom for Response { - type Error = BencodeParseError; - - fn try_from(scrape_response: DeserializedResponse) -> Result { - parse_bencoded_response(&scrape_response.files) - } -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -struct DeserializedResponse { - pub files: Value, -} - -// Custom serialization for Response -impl Serialize for Response { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut map = serializer.serialize_map(Some(self.files.len()))?; - for (key, value) in &self.files { - // Convert ByteArray20 key to hex string - let hex_key = byte_array_to_hex_string(key); - map.serialize_entry(&hex_key, value)?; - } - map.end() - } -} - -// Helper function to convert ByteArray20 to hex string -fn byte_array_to_hex_string(byte_array: &ByteArray20) -> String { - let mut hex_string = String::with_capacity(byte_array.len() * 2); - for byte in byte_array { - write!(hex_string, "{byte:02x}").expect("Writing to string should never fail"); - } - hex_string -} - -#[derive(Default)] -pub struct ResponseBuilder { - response: Response, -} - -impl ResponseBuilder { - #[must_use] - pub fn add_file(mut self, info_hash_bytes: ByteArray20, file: File) -> Self { - self.response.files.insert(info_hash_bytes, file); - self - } - - #[must_use] - pub fn build(self) -> Response { - self.response - } -} - -#[derive(Debug)] -pub enum BencodeParseError { - InvalidValueExpectedDict { value: Value }, - InvalidValueExpectedInt { value: Value }, - InvalidFileField { value: Value }, - MissingFileField { field_name: String }, -} - -/// It parses a bencoded scrape response into a `Response` struct. -/// -/// For example: -/// -/// ```text -/// d5:filesd20:xxxxxxxxxxxxxxxxxxxxd8:completei11e10:downloadedi13772e10:incompletei19e -/// 20:yyyyyyyyyyyyyyyyyyyyd8:completei21e10:downloadedi206e10:incompletei20eee -/// ``` -/// -/// Response (JSON encoded for readability): -/// -/// ```text -/// { -/// 'files': { -/// 'xxxxxxxxxxxxxxxxxxxx': {'complete': 11, 'downloaded': 13772, 'incomplete': 19}, -/// 'yyyyyyyyyyyyyyyyyyyy': {'complete': 21, 'downloaded': 206, 'incomplete': 20} -/// } -/// } -fn parse_bencoded_response(value: &Value) -> Result { - let mut files: HashMap = HashMap::new(); - - match value { - Value::Dict(dict) => { - for file_element in dict { - let info_hash_byte_vec = file_element.0; - let file_value = file_element.1; - - let file = parse_bencoded_file(file_value).unwrap(); - - files.insert(InfoHash::new(info_hash_byte_vec).bytes(), file); - } - } - _ => return Err(BencodeParseError::InvalidValueExpectedDict { value: value.clone() }), - } - - Ok(Response { files }) -} - -/// It parses a bencoded dictionary into a `File` struct. -/// -/// For example: -/// -/// -/// ```text -/// d8:completei11e10:downloadedi13772e10:incompletei19ee -/// ``` -/// -/// into: -/// -/// ```text -/// File { -/// complete: 11, -/// downloaded: 13772, -/// incomplete: 19, -/// } -/// ``` -fn parse_bencoded_file(value: &Value) -> Result { - let file = match &value { - Value::Dict(dict) => { - let mut complete = None; - let mut downloaded = None; - let mut incomplete = None; - - for file_field in dict { - let field_name = file_field.0; - - let field_value = match file_field.1 { - Value::Int(number) => Ok(*number), - _ => Err(BencodeParseError::InvalidValueExpectedInt { - value: file_field.1.clone(), - }), - }?; - - if field_name == b"complete" { - complete = Some(field_value); - } else if field_name == b"downloaded" { - downloaded = Some(field_value); - } else if field_name == b"incomplete" { - incomplete = Some(field_value); - } else { - return Err(BencodeParseError::InvalidFileField { - value: file_field.1.clone(), - }); - } - } - - if complete.is_none() { - return Err(BencodeParseError::MissingFileField { - field_name: "complete".to_string(), - }); - } - - if downloaded.is_none() { - return Err(BencodeParseError::MissingFileField { - field_name: "downloaded".to_string(), - }); - } - - if incomplete.is_none() { - return Err(BencodeParseError::MissingFileField { - field_name: "incomplete".to_string(), - }); - } - - File { - complete: complete.unwrap(), - downloaded: downloaded.unwrap(), - incomplete: incomplete.unwrap(), - } - } - _ => return Err(BencodeParseError::InvalidValueExpectedDict { value: value.clone() }), - }; - - Ok(file) -} diff --git a/src/shared/bit_torrent/tracker/http/mod.rs b/src/shared/bit_torrent/tracker/http/mod.rs deleted file mode 100644 index 15723c1b7..000000000 --- a/src/shared/bit_torrent/tracker/http/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -pub mod client; - -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() -} - -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/src/shared/bit_torrent/tracker/mod.rs b/src/shared/bit_torrent/tracker/mod.rs index b08eaa622..7e5aaa137 100644 --- a/src/shared/bit_torrent/tracker/mod.rs +++ b/src/shared/bit_torrent/tracker/mod.rs @@ -1,2 +1 @@ -pub mod http; pub mod udp; diff --git a/src/shared/bit_torrent/tracker/udp/client.rs b/src/shared/bit_torrent/tracker/udp/client.rs deleted file mode 100644 index edb8adc85..000000000 --- a/src/shared/bit_torrent/tracker/udp/client.rs +++ /dev/null @@ -1,270 +0,0 @@ -use core::result::Result::{Err, Ok}; -use std::io::Cursor; -use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::sync::Arc; -use std::time::Duration; - -use aquatic_udp_protocol::{ConnectRequest, Request, Response, TransactionId}; -use tokio::net::UdpSocket; -use tokio::time; -use torrust_tracker_configuration::DEFAULT_TIMEOUT; -use zerocopy::network_endian::I32; - -use super::Error; -use crate::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; - -pub const UDP_CLIENT_LOG_TARGET: &str = "UDP CLIENT"; - -#[allow(clippy::module_name_repetitions)] -#[derive(Debug)] -pub struct UdpClient { - /// The socket to connect to - pub socket: Arc, - - /// Timeout for sending and receiving packets - pub timeout: Duration, -} - -impl UdpClient { - /// Creates a new `UdpClient` bound to the default port and ipv6 address - /// - /// # Errors - /// - /// Will return error if unable to bind to any port or ip address. - /// - async fn bound_to_default_ipv4(timeout: Duration) -> Result { - let addr = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0); - - Self::bound(addr, timeout).await - } - - /// Creates a new `UdpClient` bound to the default port and ipv6 address - /// - /// # Errors - /// - /// Will return error if unable to bind to any port or ip address. - /// - async fn bound_to_default_ipv6(timeout: Duration) -> Result { - let addr = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0); - - Self::bound(addr, timeout).await - } - - /// Creates a new `UdpClient` connected to a Udp server - /// - /// # Errors - /// - /// Will return any errors present in the call stack - /// - pub async fn connected(remote_addr: SocketAddr, timeout: Duration) -> Result { - let client = if remote_addr.is_ipv4() { - Self::bound_to_default_ipv4(timeout).await? - } else { - Self::bound_to_default_ipv6(timeout).await? - }; - - client.connect(remote_addr).await?; - Ok(client) - } - - /// Creates a `[UdpClient]` bound to a Socket. - /// - /// # Panics - /// - /// Panics if unable to get the `local_addr` of the bound socket. - /// - /// # Errors - /// - /// This function will return an error if the binding takes to long - /// or if there is an underlying OS error. - pub async fn bound(addr: SocketAddr, timeout: Duration) -> Result { - tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "binding to socket: {addr:?} ..."); - - let socket = time::timeout(timeout, UdpSocket::bind(addr)) - .await - .map_err(|_| Error::TimeoutWhileBindingToSocket { addr })? - .map_err(|e| Error::UnableToBindToSocket { err: e.into(), addr })?; - - let addr = socket.local_addr().expect("it should get the local address"); - - tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "bound to socket: {addr:?}."); - - let udp_client = Self { - socket: Arc::new(socket), - timeout, - }; - - Ok(udp_client) - } - - /// # Errors - /// - /// Will return error if can't connect to the socket. - pub async fn connect(&self, remote_addr: SocketAddr) -> Result<(), Error> { - tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "connecting to remote: {remote_addr:?} ..."); - - let () = time::timeout(self.timeout, self.socket.connect(remote_addr)) - .await - .map_err(|_| Error::TimeoutWhileConnectingToRemote { remote_addr })? - .map_err(|e| Error::UnableToConnectToRemote { - err: e.into(), - remote_addr, - })?; - - tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "connected to remote: {remote_addr:?}."); - - Ok(()) - } - - /// # Errors - /// - /// Will return error if: - /// - /// - Can't write to the socket. - /// - Can't send data. - pub async fn send(&self, bytes: &[u8]) -> Result { - tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "sending {bytes:?} ..."); - - let () = time::timeout(self.timeout, self.socket.writable()) - .await - .map_err(|_| Error::TimeoutWaitForWriteableSocket)? - .map_err(|e| Error::UnableToGetWritableSocket { err: e.into() })?; - - let sent_bytes = time::timeout(self.timeout, self.socket.send(bytes)) - .await - .map_err(|_| Error::TimeoutWhileSendingData { data: bytes.to_vec() })? - .map_err(|e| Error::UnableToSendData { - err: e.into(), - data: bytes.to_vec(), - })?; - - tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "sent {sent_bytes} bytes to remote."); - - Ok(sent_bytes) - } - - /// # Errors - /// - /// Will return error if: - /// - /// - Can't read from the socket. - /// - Can't receive data. - /// - /// # Panics - /// - pub async fn receive(&self) -> Result, Error> { - tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "receiving ..."); - - let mut buffer = [0u8; MAX_PACKET_SIZE]; - - let () = time::timeout(self.timeout, self.socket.readable()) - .await - .map_err(|_| Error::TimeoutWaitForReadableSocket)? - .map_err(|e| Error::UnableToGetReadableSocket { err: e.into() })?; - - let received_bytes = time::timeout(self.timeout, self.socket.recv(&mut buffer)) - .await - .map_err(|_| Error::TimeoutWhileReceivingData)? - .map_err(|e| Error::UnableToReceivingData { err: e.into() })?; - - let mut received: Vec = buffer.to_vec(); - Vec::truncate(&mut received, received_bytes); - - tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "received {received_bytes} bytes: {received:?}"); - - Ok(received) - } -} - -#[allow(clippy::module_name_repetitions)] -#[derive(Debug)] -pub struct UdpTrackerClient { - pub client: UdpClient, -} - -impl UdpTrackerClient { - /// Creates a new `UdpTrackerClient` connected to a Udp Tracker server - /// - /// # Errors - /// - /// If unable to connect to the remote address. - /// - pub async fn new(remote_addr: SocketAddr, timeout: Duration) -> Result { - let client = UdpClient::connected(remote_addr, timeout).await?; - Ok(UdpTrackerClient { client }) - } - - /// # Errors - /// - /// Will return error if can't write request to bytes. - pub async fn send(&self, request: Request) -> Result { - tracing::trace!(target: UDP_CLIENT_LOG_TARGET, "sending request {request:?} ..."); - - // Write request into a buffer - // todo: optimize the pre-allocated amount based upon request type. - let mut writer = Cursor::new(Vec::with_capacity(200)); - let () = request - .write_bytes(&mut writer) - .map_err(|e| Error::UnableToWriteDataFromRequest { err: e.into(), request })?; - - self.client.send(writer.get_ref()).await - } - - /// # Errors - /// - /// Will return error if can't create response from the received payload (bytes buffer). - pub async fn receive(&self) -> Result { - let response = self.client.receive().await?; - - tracing::debug!(target: UDP_CLIENT_LOG_TARGET, "received {} bytes: {response:?}", response.len()); - - Response::parse_bytes(&response, true).map_err(|e| Error::UnableToParseResponse { err: e.into(), response }) - } -} - -/// Helper Function to Check if a UDP Service is Connectable -/// -/// # Panics -/// -/// It will return an error if unable to connect to the UDP service. -/// -/// # Errors -/// -pub async fn check(remote_addr: &SocketAddr) -> Result { - tracing::debug!("Checking Service (detail): {remote_addr:?}."); - - match UdpTrackerClient::new(*remote_addr, DEFAULT_TIMEOUT).await { - Ok(client) => { - let connect_request = ConnectRequest { - transaction_id: TransactionId(I32::new(123)), - }; - - // client.send() return usize, but doesn't use here - 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)) { - Ok("Connected".to_string()) - } else { - Err("Did not Connect".to_string()) - } - }; - - let sleep = time::sleep(Duration::from_millis(2000)); - tokio::pin!(sleep); - - tokio::select! { - () = &mut sleep => { - Err("Timed Out".to_string()) - } - response = client.receive() => { - process(response.unwrap()) - } - } - } - Err(e) => Err(format!("{e:?}")), - } -} diff --git a/src/shared/bit_torrent/tracker/udp/mod.rs b/src/shared/bit_torrent/tracker/udp/mod.rs index b9d5f34f6..1ceb8a08b 100644 --- a/src/shared/bit_torrent/tracker/udp/mod.rs +++ b/src/shared/bit_torrent/tracker/udp/mod.rs @@ -1,68 +1,6 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use aquatic_udp_protocol::Request; -use thiserror::Error; -use torrust_tracker_located_error::DynError; - -pub mod client; - /// The maximum number of bytes in a UDP packet. pub const MAX_PACKET_SIZE: usize = 1496; + /// A magic 64-bit integer constant defined in the protocol that is used to /// identify the protocol. pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; - -#[derive(Debug, Clone, Error)] -pub enum Error { - #[error("Timeout while waiting for socket to bind: {addr:?}")] - TimeoutWhileBindingToSocket { addr: SocketAddr }, - - #[error("Failed to bind to socket: {addr:?}, with error: {err:?}")] - UnableToBindToSocket { err: Arc, addr: SocketAddr }, - - #[error("Timeout while waiting for connection to remote: {remote_addr:?}")] - TimeoutWhileConnectingToRemote { remote_addr: SocketAddr }, - - #[error("Failed to connect to remote: {remote_addr:?}, with error: {err:?}")] - UnableToConnectToRemote { - err: Arc, - remote_addr: SocketAddr, - }, - - #[error("Timeout while waiting for the socket to become writable.")] - TimeoutWaitForWriteableSocket, - - #[error("Failed to get writable socket: {err:?}")] - UnableToGetWritableSocket { err: Arc }, - - #[error("Timeout while trying to send data: {data:?}")] - TimeoutWhileSendingData { data: Vec }, - - #[error("Failed to send data: {data:?}, with error: {err:?}")] - UnableToSendData { err: Arc, data: Vec }, - - #[error("Timeout while waiting for the socket to become readable.")] - TimeoutWaitForReadableSocket, - - #[error("Failed to get readable socket: {err:?}")] - UnableToGetReadableSocket { err: Arc }, - - #[error("Timeout while trying to receive data.")] - TimeoutWhileReceivingData, - - #[error("Failed to receive data: {err:?}")] - UnableToReceivingData { err: Arc }, - - #[error("Failed to get data from request: {request:?}, with error: {err:?}")] - UnableToWriteDataFromRequest { err: Arc, request: Request }, - - #[error("Failed to parse response: {response:?}, with error: {err:?}")] - UnableToParseResponse { err: Arc, response: Vec }, -} - -impl From for DynError { - fn from(e: Error) -> Self { - Arc::new(Box::new(e)) - } -} diff --git a/tests/servers/udp/contract.rs b/tests/servers/udp/contract.rs index 1f9b71b62..73f7ce368 100644 --- a/tests/servers/udp/contract.rs +++ b/tests/servers/udp/contract.rs @@ -6,7 +6,7 @@ use core::panic; use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; -use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; +use bittorrent_tracker_client::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; @@ -71,7 +71,7 @@ async fn should_return_a_bad_request_response_when_the_client_sends_an_empty_req mod receiving_a_connection_request { use aquatic_udp_protocol::{ConnectRequest, TransactionId}; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; use torrust_tracker_configuration::DEFAULT_TIMEOUT; use torrust_tracker_test_helpers::configuration; use tracing::level_filters::LevelFilter; @@ -120,7 +120,7 @@ mod receiving_an_announce_request { AnnounceActionPlaceholder, AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, TransactionId, }; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; use torrust_tracker_configuration::DEFAULT_TIMEOUT; use torrust_tracker_test_helpers::configuration; use tracing::level_filters::LevelFilter; @@ -214,7 +214,7 @@ mod receiving_an_announce_request { mod receiving_an_scrape_request { use aquatic_udp_protocol::{ConnectionId, InfoHash, ScrapeRequest, TransactionId}; - use torrust_tracker::shared::bit_torrent::tracker::udp::client::UdpTrackerClient; + use bittorrent_tracker_client::udp::client::UdpTrackerClient; use torrust_tracker_configuration::DEFAULT_TIMEOUT; use torrust_tracker_test_helpers::configuration; use tracing::level_filters::LevelFilter; From a5822cd63124a6617ee92676d176310918d822f4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 1 Nov 2024 16:58:56 +0000 Subject: [PATCH 3/4] fix: cargo machete errors --- Cargo.lock | 2 -- Cargo.toml | 1 - packages/tracker-client/Cargo.toml | 4 +++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00d83fddb..0bf1ad572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -626,7 +626,6 @@ dependencies = [ "clap", "derive_more", "futures", - "futures-util", "hex-literal", "hyper", "percent-encoding", @@ -3859,7 +3858,6 @@ dependencies = [ "figment", "futures", "futures-util", - "hex-literal", "http-body", "hyper", "hyper-util", diff --git a/Cargo.toml b/Cargo.toml index 574881a94..a3d88be92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,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"] } diff --git a/packages/tracker-client/Cargo.toml b/packages/tracker-client/Cargo.toml index 85e10c03e..3334e7b47 100644 --- a/packages/tracker-client/Cargo.toml +++ b/packages/tracker-client/Cargo.toml @@ -21,7 +21,6 @@ bittorrent-primitives = "0.1.0" clap = { version = "4", features = ["derive", "env"] } derive_more = { version = "1", features = ["as_ref", "constructor", "from"] } futures = "0" -futures-util = "0" hex-literal = "0" hyper = "1" percent-encoding = "2" @@ -40,3 +39,6 @@ tracing = "0" tracing-subscriber = { version = "0", features = ["json"] } url = { version = "2", features = ["serde"] } zerocopy = "0.7" + +[package.metadata.cargo-machete] +ignored = ["serde_bytes"] From e01995cf1bc9c08a99f9e3b59b6aa7d2a6620908 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 1 Nov 2024 17:34:50 +0000 Subject: [PATCH 4/4] fix: tracker checker execution in CI It was extractted in toa new package. --- src/console/ci/e2e/tracker_checker.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/console/ci/e2e/tracker_checker.rs b/src/console/ci/e2e/tracker_checker.rs index 192795e61..b4c2544ee 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 bittorrent-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", "bittorrent-tracker-client", "--bin", "tracker_checker"]) .status()?; if status.success() {