From 470e6083c507ab76fc4c6dd1a98b3cf2991ff4d3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 16 Jan 2024 12:53:31 +0000 Subject: [PATCH 1/2] feat: a simple HTTP tracker client command You can execute it with: ``` cargo run --bin http_tracker_client https://tracker.torrust-demo.com 9c38422213e30bff212b30c360d26f9a02136422" ``` and the output should be something like: ```json{ "complete": 1, "incomplete": 1, "interval": 300, "min interval": 300, "peers": [ { "ip": "90.XX.XX.167", "peer id": [ 45, 66, 76, 50, 52, 54, 51, 54, 51, 45, 51, 70, 41, 46, 114, 46, 68, 100, 74, 69 ], "port": 59568 } ] } ``` --- Cargo.toml | 4 +- .../config/tracker.development.sqlite3.toml | 10 +- src/bin/http_tracker_client.rs | 35 +++ src/shared/bit_torrent/mod.rs | 1 + .../bit_torrent/tracker/http/client/mod.rs | 125 ++++++++ .../tracker/http/client/requests/announce.rs | 275 ++++++++++++++++++ .../tracker/http/client/requests/mod.rs | 2 + .../tracker/http/client/requests/scrape.rs | 124 ++++++++ .../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 | 203 +++++++++++++ src/shared/bit_torrent/tracker/http/mod.rs | 26 ++ src/shared/bit_torrent/tracker/mod.rs | 1 + 14 files changed, 935 insertions(+), 7 deletions(-) create mode 100644 src/bin/http_tracker_client.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/mod.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/requests/announce.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/requests/mod.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/requests/scrape.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/responses/announce.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/responses/error.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/responses/mod.rs create mode 100644 src/shared/bit_torrent/tracker/http/client/responses/scrape.rs create mode 100644 src/shared/bit_torrent/tracker/http/mod.rs create mode 100644 src/shared/bit_torrent/tracker/mod.rs diff --git a/Cargo.toml b/Cargo.toml index daf3c0259..671d66e98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,8 +54,10 @@ rand = "0" reqwest = "0" serde = { version = "1", features = ["derive"] } serde_bencode = "0" +serde_bytes = "0" serde_json = "1" serde_with = "3" +serde_repr = "0" tdyne-peer-id = "1" tdyne-peer-id-registry = "0" thiserror = "1" @@ -73,8 +75,6 @@ local-ip-address = "0" mockall = "0" once_cell = "1.18.0" reqwest = { version = "0", features = ["json"] } -serde_bytes = "0" -serde_repr = "0" serde_urlencoded = "0" torrust-tracker-test-helpers = { version = "3.0.0-alpha.12-develop", path = "packages/test-helpers" } diff --git a/share/default/config/tracker.development.sqlite3.toml b/share/default/config/tracker.development.sqlite3.toml index 04934dd8a..e26aa6c6c 100644 --- a/share/default/config/tracker.development.sqlite3.toml +++ b/share/default/config/tracker.development.sqlite3.toml @@ -13,18 +13,18 @@ remove_peerless_torrents = true tracker_usage_statistics = true [[udp_trackers]] -bind_address = "0.0.0.0:6969" -enabled = false +bind_address = "0.0.0.0:0" +enabled = true [[http_trackers]] -bind_address = "0.0.0.0:7070" -enabled = false +bind_address = "0.0.0.0:0" +enabled = true ssl_cert_path = "" ssl_enabled = false ssl_key_path = "" [http_api] -bind_address = "127.0.0.1:1212" +bind_address = "127.0.0.1:0" enabled = true ssl_cert_path = "" ssl_enabled = false diff --git a/src/bin/http_tracker_client.rs b/src/bin/http_tracker_client.rs new file mode 100644 index 000000000..1f1154fa5 --- /dev/null +++ b/src/bin/http_tracker_client.rs @@ -0,0 +1,35 @@ +use std::env; +use std::str::FromStr; + +use reqwest::Url; +use torrust_tracker::shared::bit_torrent::info_hash::InfoHash; +use torrust_tracker::shared::bit_torrent::tracker::http::client::requests::announce::QueryBuilder; +use torrust_tracker::shared::bit_torrent::tracker::http::client::responses::announce::Announce; +use torrust_tracker::shared::bit_torrent::tracker::http::client::Client; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() != 3 { + eprintln!("Error: invalid number of arguments!"); + eprintln!("Usage: cargo run --bin http_tracker_client "); + eprintln!("Example: cargo run --bin http_tracker_client https://tracker.torrust-demo.com 9c38422213e30bff212b30c360d26f9a02136422"); + std::process::exit(1); + } + + let base_url = Url::parse(&args[1]).expect("arg 1 should be a valid HTTP tracker base URL"); + let info_hash = InfoHash::from_str(&args[2]).expect("arg 2 should be a valid infohash"); + + let response = Client::new(base_url) + .announce(&QueryBuilder::with_default_values().with_info_hash(&info_hash).query()) + .await; + + let body = response.bytes().await.unwrap(); + + 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).expect("announce response should be a valid JSON"); + + print!("{json}"); +} diff --git a/src/shared/bit_torrent/mod.rs b/src/shared/bit_torrent/mod.rs index 872203a1f..3dcf705e4 100644 --- a/src/shared/bit_torrent/mod.rs +++ b/src/shared/bit_torrent/mod.rs @@ -69,4 +69,5 @@ //!Bencode & bdecode in your browser | pub mod common; pub mod info_hash; +pub mod tracker; pub mod udp; diff --git a/src/shared/bit_torrent/tracker/http/client/mod.rs b/src/shared/bit_torrent/tracker/http/client/mod.rs new file mode 100644 index 000000000..a75b0fec3 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/mod.rs @@ -0,0 +1,125 @@ +pub mod requests; +pub mod responses; + +use std::net::IpAddr; + +use requests::announce::{self, Query}; +use requests::scrape; +use reqwest::{Client as ReqwestClient, Response, Url}; + +use crate::core::auth::Key; + +/// HTTP Tracker Client +pub struct Client { + base_url: Url, + reqwest: ReqwestClient, + 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 { + /// # Panics + /// + /// This method fails if the client builder fails. + #[must_use] + pub fn new(base_url: Url) -> Self { + Self { + base_url, + reqwest: reqwest::Client::builder().build().unwrap(), + key: None, + } + } + + /// Creates the new client binding it to an specific local address. + /// + /// # Panics + /// + /// This method fails if the client builder fails. + #[must_use] + pub fn bind(base_url: Url, local_address: IpAddr) -> Self { + Self { + base_url, + reqwest: reqwest::Client::builder().local_address(local_address).build().unwrap(), + key: None, + } + } + + /// # Panics + /// + /// This method fails if the client builder fails. + #[must_use] + pub fn authenticated(base_url: Url, key: Key) -> Self { + Self { + base_url, + reqwest: reqwest::Client::builder().build().unwrap(), + key: Some(key), + } + } + + pub async fn announce(&self, query: &announce::Query) -> Response { + self.get(&self.build_announce_path_and_query(query)).await + } + + pub async fn scrape(&self, query: &scrape::Query) -> Response { + self.get(&self.build_scrape_path_and_query(query)).await + } + + pub async fn announce_with_header(&self, query: &Query, key: &str, value: &str) -> Response { + self.get_with_header(&self.build_announce_path_and_query(query), key, value) + .await + } + + pub async fn health_check(&self) -> Response { + self.get(&self.build_path("health_check")).await + } + + /// # Panics + /// + /// This method fails if there was an error while sending request. + pub async fn get(&self, path: &str) -> Response { + self.reqwest.get(self.build_url(path)).send().await.unwrap() + } + + /// # Panics + /// + /// This method fails if there was an error while sending request. + pub async fn get_with_header(&self, path: &str, key: &str, value: &str) -> Response { + self.reqwest + .get(self.build_url(path)) + .header(key, value) + .send() + .await + .unwrap() + } + + 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 new file mode 100644 index 000000000..6cae79888 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/requests/announce.rs @@ -0,0 +1,275 @@ +use std::fmt; +use std::net::{IpAddr, Ipv4Addr}; +use std::str::FromStr; + +use serde_repr::Serialize_repr; + +use crate::core::peer::Id; +use crate::shared::bit_torrent::info_hash::InfoHash; +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: Id(*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: &Id) -> 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 new file mode 100644 index 000000000..776d2dfbf --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/requests/mod.rs @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000..e2563b8ed --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/requests/scrape.rs @@ -0,0 +1,124 @@ +use std::fmt; +use std::str::FromStr; + +use crate::shared::bit_torrent::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()) + } +} + +/// 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 new file mode 100644 index 000000000..f68c54482 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/responses/announce.rs @@ -0,0 +1,126 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{self, Deserialize, Serialize}; + +use crate::core::peer::Peer; + +#[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) -> Self { + DictionaryPeer { + peer_id: peer.peer_id.to_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 new file mode 100644 index 000000000..12c53a0cf --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/responses/error.rs @@ -0,0 +1,7 @@ +use serde::{self, 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 new file mode 100644 index 000000000..bdc689056 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/responses/mod.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..ae06841e4 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/client/responses/scrape.rs @@ -0,0 +1,203 @@ +use std::collections::HashMap; +use std::str; + +use serde::{self, Deserialize, Serialize}; +use serde_bencode::value::Value; + +use crate::shared::bit_torrent::tracker::http::{ByteArray20, InfoHash}; + +#[derive(Debug, PartialEq, Default)] +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, +} + +#[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 new file mode 100644 index 000000000..15723c1b7 --- /dev/null +++ b/src/shared/bit_torrent/tracker/http/mod.rs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000..3883215fc --- /dev/null +++ b/src/shared/bit_torrent/tracker/mod.rs @@ -0,0 +1 @@ +pub mod http; From 129fd2f26549fec74f0bf76b4b11b2c2a3a3c9f7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 17 Jan 2024 11:08:43 +0000 Subject: [PATCH 2/2] refactor: move upd tracker client to follow same folder strucutre as the HTTP tracker client. --- src/servers/health_check_api/handlers.rs | 2 +- src/servers/udp/server.rs | 2 +- src/shared/bit_torrent/mod.rs | 1 - src/shared/bit_torrent/tracker/mod.rs | 1 + src/shared/bit_torrent/{ => tracker}/udp/client.rs | 2 +- src/shared/bit_torrent/{ => tracker}/udp/mod.rs | 0 tests/servers/udp/contract.rs | 10 +++++----- 7 files changed, 9 insertions(+), 9 deletions(-) rename src/shared/bit_torrent/{ => tracker}/udp/client.rs (97%) rename src/shared/bit_torrent/{ => tracker}/udp/mod.rs (100%) diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs index 2f47c8607..4403676af 100644 --- a/src/servers/health_check_api/handlers.rs +++ b/src/servers/health_check_api/handlers.rs @@ -8,7 +8,7 @@ use torrust_tracker_configuration::{Configuration, HttpApi, HttpTracker, UdpTrac use super::resources::Report; use super::responses; -use crate::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; +use crate::shared::bit_torrent::tracker::udp::client::new_udp_tracker_client_connected; /// If port 0 is specified in the configuration the OS will automatically /// assign a free port. But we do now know in from the configuration. diff --git a/src/servers/udp/server.rs b/src/servers/udp/server.rs index a15226bd2..001603b08 100644 --- a/src/servers/udp/server.rs +++ b/src/servers/udp/server.rs @@ -34,7 +34,7 @@ use crate::bootstrap::jobs::Started; use crate::core::Tracker; use crate::servers::signals::{shutdown_signal_with_message, Halted}; use crate::servers::udp::handlers::handle_packet; -use crate::shared::bit_torrent::udp::MAX_PACKET_SIZE; +use crate::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; /// Error that can occur when starting or stopping the UDP server. /// diff --git a/src/shared/bit_torrent/mod.rs b/src/shared/bit_torrent/mod.rs index 3dcf705e4..8074661be 100644 --- a/src/shared/bit_torrent/mod.rs +++ b/src/shared/bit_torrent/mod.rs @@ -70,4 +70,3 @@ pub mod common; pub mod info_hash; pub mod tracker; -pub mod udp; diff --git a/src/shared/bit_torrent/tracker/mod.rs b/src/shared/bit_torrent/tracker/mod.rs index 3883215fc..b08eaa622 100644 --- a/src/shared/bit_torrent/tracker/mod.rs +++ b/src/shared/bit_torrent/tracker/mod.rs @@ -1 +1,2 @@ pub mod http; +pub mod udp; diff --git a/src/shared/bit_torrent/udp/client.rs b/src/shared/bit_torrent/tracker/udp/client.rs similarity index 97% rename from src/shared/bit_torrent/udp/client.rs rename to src/shared/bit_torrent/tracker/udp/client.rs index d5c4c9adf..5ea982663 100644 --- a/src/shared/bit_torrent/udp/client.rs +++ b/src/shared/bit_torrent/tracker/udp/client.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use aquatic_udp_protocol::{Request, Response}; use tokio::net::UdpSocket; -use crate::shared::bit_torrent::udp::{source_address, MAX_PACKET_SIZE}; +use crate::shared::bit_torrent::tracker::udp::{source_address, MAX_PACKET_SIZE}; #[allow(clippy::module_name_repetitions)] pub struct UdpClient { diff --git a/src/shared/bit_torrent/udp/mod.rs b/src/shared/bit_torrent/tracker/udp/mod.rs similarity index 100% rename from src/shared/bit_torrent/udp/mod.rs rename to src/shared/bit_torrent/tracker/udp/mod.rs diff --git a/tests/servers/udp/contract.rs b/tests/servers/udp/contract.rs index 72124fc3f..b16a47cd3 100644 --- a/tests/servers/udp/contract.rs +++ b/tests/servers/udp/contract.rs @@ -6,8 +6,8 @@ use core::panic; use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; -use torrust_tracker::shared::bit_torrent::udp::client::{new_udp_client_connected, UdpTrackerClient}; -use torrust_tracker::shared::bit_torrent::udp::MAX_PACKET_SIZE; +use torrust_tracker::shared::bit_torrent::tracker::udp::client::{new_udp_client_connected, UdpTrackerClient}; +use torrust_tracker::shared::bit_torrent::tracker::udp::MAX_PACKET_SIZE; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_error_response; @@ -51,7 +51,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::udp::client::new_udp_tracker_client_connected; + use torrust_tracker::shared::bit_torrent::tracker::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_connect_response; @@ -82,7 +82,7 @@ mod receiving_an_announce_request { AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, TransactionId, }; - use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; + use torrust_tracker::shared::bit_torrent::tracker::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_ipv4_announce_response; @@ -124,7 +124,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::udp::client::new_udp_tracker_client_connected; + use torrust_tracker::shared::bit_torrent::tracker::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_scrape_response;