diff --git a/Cargo.lock b/Cargo.lock index 8347362ab..6f9d9231b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-client-ip" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d719fabd6813392bbc10e1fe67f2977fad52791a836e51236f7e02f2482e017" +dependencies = [ + "axum", + "forwarded-header-value", + "serde", +] + [[package]] name = "axum-core" version = "0.3.0" @@ -706,6 +717,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror", +] + [[package]] name = "fragile" version = "2.0.0" @@ -1550,6 +1571,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -2832,6 +2859,7 @@ dependencies = [ "aquatic_udp_protocol", "async-trait", "axum", + "axum-client-ip", "axum-server", "binascii", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cf90da8f1..75ffa7935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ aquatic_udp_protocol = "0.2" uuid = { version = "1", features = ["v4"] } axum = "0.6.1" axum-server = { version = "0.4.4", features = ["tls-rustls"] } +axum-client-ip = "0.4.0" [dev-dependencies] diff --git a/src/http/axum_implementation/extractors.rs b/src/http/axum_implementation/extractors.rs deleted file mode 100644 index a1f3fad1e..000000000 --- a/src/http/axum_implementation/extractors.rs +++ /dev/null @@ -1,159 +0,0 @@ -use std::panic::Location; -use std::str::FromStr; - -use axum::async_trait; -use axum::extract::FromRequestParts; -use axum::http::request::Parts; -use axum::http::StatusCode; -use thiserror::Error; - -use super::query::Query; -use crate::http::percent_encoding::{percent_decode_info_hash, percent_decode_peer_id}; -use crate::protocol::info_hash::{ConversionError, InfoHash}; -use crate::tracker::peer::{self, IdConversionError}; - -pub struct ExtractAnnounceParams(pub AnnounceParams); - -#[derive(Debug, PartialEq)] -pub struct AnnounceParams { - pub info_hash: InfoHash, - pub peer_id: peer::Id, - pub port: u16, -} - -#[derive(Error, Debug)] -pub enum ParseAnnounceQueryError { - #[error("missing infohash {location}")] - MissingInfoHash { location: &'static Location<'static> }, - #[error("invalid infohash {location}")] - InvalidInfoHash { location: &'static Location<'static> }, - #[error("missing peer id {location}")] - MissingPeerId { location: &'static Location<'static> }, - #[error("invalid peer id {location}")] - InvalidPeerId { location: &'static Location<'static> }, - #[error("missing port {location}")] - MissingPort { location: &'static Location<'static> }, - #[error("invalid port {location}")] - InvalidPort { location: &'static Location<'static> }, -} - -impl From for ParseAnnounceQueryError { - #[track_caller] - fn from(_err: IdConversionError) -> Self { - Self::InvalidPeerId { - location: Location::caller(), - } - } -} - -impl From for ParseAnnounceQueryError { - #[track_caller] - fn from(_err: ConversionError) -> Self { - Self::InvalidPeerId { - location: Location::caller(), - } - } -} - -impl TryFrom for AnnounceParams { - type Error = ParseAnnounceQueryError; - - fn try_from(query: Query) -> Result { - Ok(Self { - info_hash: extract_info_hash(&query)?, - peer_id: extract_peer_id(&query)?, - port: extract_port(&query)?, - }) - } -} - -fn extract_info_hash(query: &Query) -> Result { - match query.get_param("info_hash") { - Some(raw_info_hash) => Ok(percent_decode_info_hash(&raw_info_hash)?), - None => { - return Err(ParseAnnounceQueryError::MissingInfoHash { - location: Location::caller(), - }) - } - } -} - -fn extract_peer_id(query: &Query) -> Result { - match query.get_param("peer_id") { - Some(raw_peer_id) => Ok(percent_decode_peer_id(&raw_peer_id)?), - None => { - return Err(ParseAnnounceQueryError::MissingPeerId { - location: Location::caller(), - }) - } - } -} - -fn extract_port(query: &Query) -> Result { - match query.get_param("port") { - Some(raw_port) => Ok(u16::from_str(&raw_port).map_err(|_e| ParseAnnounceQueryError::InvalidPort { - location: Location::caller(), - })?), - None => { - return Err(ParseAnnounceQueryError::MissingPort { - location: Location::caller(), - }) - } - } -} - -#[async_trait] -impl FromRequestParts for ExtractAnnounceParams -where - S: Send + Sync, -{ - type Rejection = (StatusCode, &'static str); - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let raw_query = parts.uri.query(); - - if raw_query.is_none() { - return Err((StatusCode::BAD_REQUEST, "missing query params")); - } - - let query = raw_query.unwrap().parse::(); - - if query.is_err() { - return Err((StatusCode::BAD_REQUEST, "can't parse query params")); - } - - let announce_params = AnnounceParams::try_from(query.unwrap()); - - if announce_params.is_err() { - return Err((StatusCode::BAD_REQUEST, "can't parse query params for announce request")); - } - - Ok(ExtractAnnounceParams(announce_params.unwrap())) - } -} - -#[cfg(test)] -mod tests { - use super::AnnounceParams; - use crate::http::axum_implementation::query::Query; - use crate::protocol::info_hash::InfoHash; - use crate::tracker::peer; - - #[test] - fn announce_request_params_should_be_extracted_from_url_query_params() { - let raw_query = "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001&port=17548"; - - let query = raw_query.parse::().unwrap(); - - let announce_params = AnnounceParams::try_from(query).unwrap(); - - assert_eq!( - announce_params, - AnnounceParams { - info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), - peer_id: "-qB00000000000000001".parse::().unwrap(), - port: 17548, - } - ); - } -} diff --git a/src/http/axum_implementation/extractors/announce_request.rs b/src/http/axum_implementation/extractors/announce_request.rs new file mode 100644 index 000000000..0371be9a4 --- /dev/null +++ b/src/http/axum_implementation/extractors/announce_request.rs @@ -0,0 +1,45 @@ +use std::panic::Location; + +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; + +use crate::http::axum_implementation::query::Query; +use crate::http::axum_implementation::requests::announce::{Announce, ParseAnnounceQueryError}; +use crate::http::axum_implementation::responses; + +pub struct ExtractRequest(pub Announce); + +#[async_trait] +impl FromRequestParts for ExtractRequest +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let raw_query = parts.uri.query(); + + if raw_query.is_none() { + return Err(responses::error::Error::from(ParseAnnounceQueryError::MissingParams { + location: Location::caller(), + }) + .into_response()); + } + + let query = raw_query.unwrap().parse::(); + + if let Err(error) = query { + return Err(responses::error::Error::from(error).into_response()); + } + + let announce_request = Announce::try_from(query.unwrap()); + + if let Err(error) = announce_request { + return Err(responses::error::Error::from(error).into_response()); + } + + Ok(ExtractRequest(announce_request.unwrap())) + } +} diff --git a/src/http/axum_implementation/extractors/mod.rs b/src/http/axum_implementation/extractors/mod.rs new file mode 100644 index 000000000..65b2775a9 --- /dev/null +++ b/src/http/axum_implementation/extractors/mod.rs @@ -0,0 +1,3 @@ +pub mod announce_request; +pub mod peer_ip; +pub mod remote_client_ip; diff --git a/src/http/axum_implementation/extractors/peer_ip.rs b/src/http/axum_implementation/extractors/peer_ip.rs new file mode 100644 index 000000000..7d615d0dc --- /dev/null +++ b/src/http/axum_implementation/extractors/peer_ip.rs @@ -0,0 +1,52 @@ +use std::net::IpAddr; +use std::panic::Location; + +use axum::response::{IntoResponse, Response}; +use thiserror::Error; + +use super::remote_client_ip::RemoteClientIp; +use crate::http::axum_implementation::responses; + +#[derive(Error, Debug)] +pub enum ResolutionError { + #[error("missing the right most X-Forwarded-For IP (mandatory on reverse proxy tracker configuration) in {location}")] + MissingRightMostXForwardedForIp { location: &'static Location<'static> }, + #[error("cannot get the client IP from the connection info in {location}")] + MissingClientIp { location: &'static Location<'static> }, +} + +impl From for responses::error::Error { + fn from(err: ResolutionError) -> Self { + responses::error::Error { + failure_reason: format!("{err}"), + } + } +} + +/// It resolves the peer IP. +/// +/// # Errors +/// +/// Will return an error if the peer IP cannot be obtained according to the configuration. +/// For example, if the IP is extracted from an HTTP header which is missing in the request. +pub fn assign_ip_address_to_peer(on_reverse_proxy: bool, remote_client_ip: &RemoteClientIp) -> Result { + if on_reverse_proxy { + if let Some(ip) = remote_client_ip.right_most_x_forwarded_for { + Ok(ip) + } else { + Err( + responses::error::Error::from(ResolutionError::MissingRightMostXForwardedForIp { + location: Location::caller(), + }) + .into_response(), + ) + } + } else if let Some(ip) = remote_client_ip.connection_info_ip { + Ok(ip) + } else { + Err(responses::error::Error::from(ResolutionError::MissingClientIp { + location: Location::caller(), + }) + .into_response()) + } +} diff --git a/src/http/axum_implementation/extractors/remote_client_ip.rs b/src/http/axum_implementation/extractors/remote_client_ip.rs new file mode 100644 index 000000000..e852a1b6f --- /dev/null +++ b/src/http/axum_implementation/extractors/remote_client_ip.rs @@ -0,0 +1,51 @@ +use std::net::{IpAddr, SocketAddr}; + +use axum::async_trait; +use axum::extract::{ConnectInfo, FromRequestParts}; +use axum::http::request::Parts; +use axum::response::Response; +use axum_client_ip::RightmostXForwardedFor; +use serde::{Deserialize, Serialize}; + +/// Given this request chain: +/// +/// client <-> http proxy 1 <-> http proxy 2 <-> server +/// ip: 126.0.0.1 ip: 126.0.0.2 ip: 126.0.0.3 ip: 126.0.0.4 +/// X-Forwarded-For: 126.0.0.1 X-Forwarded-For: 126.0.0.1,126.0.0.2 +/// +/// This extractor extracts these values from the HTTP headers and connection info. +/// +/// `right_most_x_forwarded_for` = 126.0.0.2 +/// `connection_info_ip` = 126.0.0.3 +/// +/// More info about inner extractors : +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub struct RemoteClientIp { + pub right_most_x_forwarded_for: Option, + pub connection_info_ip: Option, +} + +#[async_trait] +impl FromRequestParts for RemoteClientIp +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let right_most_x_forwarded_for = match RightmostXForwardedFor::from_request_parts(parts, state).await { + Ok(right_most_x_forwarded_for) => Some(right_most_x_forwarded_for.0), + Err(_) => None, + }; + + let connection_info_ip = match ConnectInfo::::from_request_parts(parts, state).await { + Ok(connection_info_socket_addr) => Some(connection_info_socket_addr.0.ip()), + Err(_) => None, + }; + + Ok(RemoteClientIp { + right_most_x_forwarded_for, + connection_info_ip, + }) + } +} diff --git a/src/http/axum_implementation/handlers.rs b/src/http/axum_implementation/handlers.rs deleted file mode 100644 index 050fa8e69..000000000 --- a/src/http/axum_implementation/handlers.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::response::Json; - -use super::extractors::ExtractAnnounceParams; -use super::resources::ok::Ok; -use super::responses::ok_response; -use crate::tracker::Tracker; - -#[allow(clippy::unused_async)] -pub async fn get_status_handler() -> Json { - ok_response() -} - -/// # Panics -/// -/// todo -#[allow(clippy::unused_async)] -pub async fn announce_handler( - State(_tracker): State>, - ExtractAnnounceParams(_announce_params): ExtractAnnounceParams, -) -> Json { - todo!() -} diff --git a/src/http/axum_implementation/handlers/announce.rs b/src/http/axum_implementation/handlers/announce.rs new file mode 100644 index 000000000..0960510ba --- /dev/null +++ b/src/http/axum_implementation/handlers/announce.rs @@ -0,0 +1,61 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use log::debug; + +use crate::http::axum_implementation::extractors::announce_request::ExtractRequest; +use crate::http::axum_implementation::extractors::peer_ip::assign_ip_address_to_peer; +use crate::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; +use crate::http::axum_implementation::requests::announce::{Announce, Event}; +use crate::http::axum_implementation::{responses, services}; +use crate::protocol::clock::{Current, Time}; +use crate::tracker::peer::Peer; +use crate::tracker::Tracker; + +#[allow(clippy::unused_async)] +pub async fn handle( + State(tracker): State>, + ExtractRequest(announce_request): ExtractRequest, + remote_client_ip: RemoteClientIp, +) -> Response { + debug!("http announce request: {:#?}", announce_request); + + let peer_ip = match assign_ip_address_to_peer(tracker.config.on_reverse_proxy, &remote_client_ip) { + Ok(peer_ip) => peer_ip, + Err(err) => return err, + }; + + let mut peer = peer_from_request(&announce_request, &peer_ip); + + let response = services::announce::invoke(tracker.clone(), announce_request.info_hash, &mut peer).await; + + responses::announce::Announce::from(response).into_response() +} + +/// It ignores the peer address in the announce request params. +#[must_use] +fn peer_from_request(announce_request: &Announce, peer_ip: &IpAddr) -> Peer { + Peer { + peer_id: announce_request.peer_id, + peer_addr: SocketAddr::new(*peer_ip, announce_request.port), + updated: Current::now(), + uploaded: NumberOfBytes(announce_request.uploaded.unwrap_or(0)), + downloaded: NumberOfBytes(announce_request.downloaded.unwrap_or(0)), + left: NumberOfBytes(announce_request.left.unwrap_or(0)), + event: map_to_aquatic_event(&announce_request.event), + } +} + +fn map_to_aquatic_event(event: &Option) -> AnnounceEvent { + match event { + Some(event) => match &event { + Event::Started => aquatic_udp_protocol::AnnounceEvent::Started, + Event::Stopped => aquatic_udp_protocol::AnnounceEvent::Stopped, + Event::Completed => aquatic_udp_protocol::AnnounceEvent::Completed, + }, + None => aquatic_udp_protocol::AnnounceEvent::None, + } +} diff --git a/src/http/axum_implementation/handlers/mod.rs b/src/http/axum_implementation/handlers/mod.rs new file mode 100644 index 000000000..bff05984c --- /dev/null +++ b/src/http/axum_implementation/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod announce; +pub mod status; diff --git a/src/http/axum_implementation/handlers/status.rs b/src/http/axum_implementation/handlers/status.rs new file mode 100644 index 000000000..d4031aef5 --- /dev/null +++ b/src/http/axum_implementation/handlers/status.rs @@ -0,0 +1,12 @@ +/// Temporary handler for testing and debugging the new Axum implementation +/// It should be removed once the migration to Axum is finished. +use axum::response::Json; + +use crate::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; +use crate::http::axum_implementation::resources::ok::Ok; +use crate::http::axum_implementation::responses::ok; + +#[allow(clippy::unused_async)] +pub async fn get_status_handler(remote_client_ip: RemoteClientIp) -> Json { + ok::response(&remote_client_ip) +} diff --git a/src/http/axum_implementation/mod.rs b/src/http/axum_implementation/mod.rs index 9d96362df..d8431457a 100644 --- a/src/http/axum_implementation/mod.rs +++ b/src/http/axum_implementation/mod.rs @@ -1,7 +1,9 @@ pub mod extractors; pub mod handlers; pub mod query; +pub mod requests; pub mod resources; pub mod responses; pub mod routes; pub mod server; +pub mod services; diff --git a/src/http/axum_implementation/query.rs b/src/http/axum_implementation/query.rs index c7c20b22d..cad58c17b 100644 --- a/src/http/axum_implementation/query.rs +++ b/src/http/axum_implementation/query.rs @@ -3,7 +3,16 @@ use std::panic::Location; use std::str::FromStr; use thiserror::Error; + +/// Represent a URL query component with some restrictions. +/// It does not allow duplicate param names like this: `param1=value1¶m1=value2` +/// It would take the second value for `param1`. pub struct Query { + /* code-review: + - Consider using `HashMap`, because it does not allow you to add a second value for the same param name. + - Consider using a third-party crate. + - Conversion from/to string is not deterministic. Params can be in a different order in the query string. + */ params: HashMap, } @@ -33,6 +42,38 @@ impl FromStr for Query { } } +impl From> for Query { + fn from(raw_params: Vec<(&str, &str)>) -> Self { + let mut params: HashMap = HashMap::new(); + + for raw_param in raw_params { + params.insert(raw_param.0.to_owned(), raw_param.1.to_owned()); + } + + Self { params } + } +} + +impl std::fmt::Display for Query { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let query = self + .params + .iter() + .map(|param| format!("{}", Param::new(param.0, param.1))) + .collect::>() + .join("&"); + + write!(f, "{query}") + } +} + +impl Query { + #[must_use] + pub fn get_param(&self, name: &str) -> Option { + self.params.get(name).map(std::string::ToString::to_string) + } +} + #[derive(Debug, PartialEq)] struct Param { name: String, @@ -45,7 +86,7 @@ impl FromStr for Param { fn from_str(raw_param: &str) -> Result { let pair = raw_param.split('=').collect::>(); - if pair.len() > 2 { + if pair.len() != 2 { return Err(ParseQueryError::InvalidParam { location: Location::caller(), raw_param: raw_param.to_owned(), @@ -59,80 +100,121 @@ impl FromStr for Param { } } -impl Query { - #[must_use] - pub fn get_param(&self, name: &str) -> Option { - self.params.get(name).map(std::string::ToString::to_string) +impl std::fmt::Display for Param { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}={}", self.name, self.value) + } +} + +impl Param { + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_owned(), + value: value.to_owned(), + } } } #[cfg(test)] mod tests { - use super::Query; - use crate::http::axum_implementation::query::Param; - #[test] - fn it_should_parse_the_query_params_from_an_url_query_string() { - let raw_query = "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001&port=17548"; + mod url_query { + use crate::http::axum_implementation::query::Query; - let query = raw_query.parse::().unwrap(); + #[test] + fn should_parse_the_query_params_from_an_url_query_string() { + let raw_query = + "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001&port=17548"; - assert_eq!( - query.get_param("info_hash").unwrap(), - "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0" - ); - assert_eq!(query.get_param("peer_id").unwrap(), "-qB00000000000000001"); - assert_eq!(query.get_param("port").unwrap(), "17548"); - } + let query = raw_query.parse::().unwrap(); - #[test] - fn it_should_fail_parsing_an_invalid_query_string() { - let invalid_raw_query = "name=value=value"; + assert_eq!( + query.get_param("info_hash").unwrap(), + "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0" + ); + assert_eq!(query.get_param("peer_id").unwrap(), "-qB00000000000000001"); + assert_eq!(query.get_param("port").unwrap(), "17548"); + } - let query = invalid_raw_query.parse::(); + #[test] + fn should_fail_parsing_an_invalid_query_string() { + let invalid_raw_query = "name=value=value"; - assert!(query.is_err()); - } + let query = invalid_raw_query.parse::(); - #[test] - fn it_should_ignore_the_preceding_question_mark_if_it_exists() { - let raw_query = "?name=value"; + assert!(query.is_err()); + } - let query = raw_query.parse::().unwrap(); + #[test] + fn should_ignore_the_preceding_question_mark_if_it_exists() { + let raw_query = "?name=value"; - assert_eq!(query.get_param("name").unwrap(), "value"); - } + let query = raw_query.parse::().unwrap(); - #[test] - fn it_should_trim_whitespaces() { - let raw_query = " name=value "; + assert_eq!(query.get_param("name").unwrap(), "value"); + } - let query = raw_query.parse::().unwrap(); + #[test] + fn should_trim_whitespaces() { + let raw_query = " name=value "; - assert_eq!(query.get_param("name").unwrap(), "value"); - } + let query = raw_query.parse::().unwrap(); + + assert_eq!(query.get_param("name").unwrap(), "value"); + } + + #[test] + fn should_be_instantiated_from_a_string_pair_vector() { + let query = Query::from(vec![("param1", "value1"), ("param2", "value2")]).to_string(); - #[test] - fn it_should_parse_a_single_query_param() { - let raw_param = "name=value"; + assert!(query == "param1=value1¶m2=value2" || query == "param2=value2¶m1=value1"); + } - let param = raw_param.parse::().unwrap(); + #[test] + fn should_not_allow_more_than_one_value_for_the_same_param() { + let query = Query::from(vec![("param1", "value1"), ("param1", "value2"), ("param1", "value3")]).to_string(); - assert_eq!( - param, - Param { - name: "name".to_string(), - value: "value".to_string(), - } - ); + assert_eq!(query, "param1=value3"); + } + + #[test] + fn should_be_displayed() { + let query = "param1=value1¶m2=value2".parse::().unwrap().to_string(); + + assert!(query == "param1=value1¶m2=value2" || query == "param2=value2¶m1=value1"); + } } - #[test] - fn it_should_fail_parsing_an_invalid_query_param() { - let invalid_raw_param = "name=value=value"; + mod url_query_param { + use crate::http::axum_implementation::query::Param; + + #[test] + fn should_parse_a_single_query_param() { + let raw_param = "name=value"; + + let param = raw_param.parse::().unwrap(); - let query = invalid_raw_param.parse::(); + assert_eq!( + param, + Param { + name: "name".to_string(), + value: "value".to_string(), + } + ); + } + + #[test] + fn should_fail_parsing_an_invalid_query_param() { + let invalid_raw_param = "name=value=value"; + + let query = invalid_raw_param.parse::(); - assert!(query.is_err()); + assert!(query.is_err()); + } + + #[test] + fn should_be_displayed() { + assert_eq!("name=value".parse::().unwrap().to_string(), "name=value"); + } } } diff --git a/src/http/axum_implementation/requests/announce.rs b/src/http/axum_implementation/requests/announce.rs new file mode 100644 index 000000000..0f9a6fbfe --- /dev/null +++ b/src/http/axum_implementation/requests/announce.rs @@ -0,0 +1,476 @@ +use std::fmt; +use std::panic::Location; +use std::str::FromStr; + +use thiserror::Error; + +use crate::http::axum_implementation::query::{ParseQueryError, Query}; +use crate::http::axum_implementation::responses; +use crate::http::percent_encoding::{percent_decode_info_hash, percent_decode_peer_id}; +use crate::located_error::{Located, LocatedError}; +use crate::protocol::info_hash::{ConversionError, InfoHash}; +use crate::tracker::peer::{self, IdConversionError}; + +pub type NumberOfBytes = i64; + +// Query param names +const INFO_HASH: &str = "info_hash"; +const PEER_ID: &str = "peer_id"; +const PORT: &str = "port"; +const DOWNLOADED: &str = "downloaded"; +const UPLOADED: &str = "uploaded"; +const LEFT: &str = "left"; +const EVENT: &str = "event"; +const COMPACT: &str = "compact"; + +#[derive(Debug, PartialEq)] +pub struct Announce { + // Mandatory params + pub info_hash: InfoHash, + pub peer_id: peer::Id, + pub port: u16, + // Optional params + pub downloaded: Option, + pub uploaded: Option, + pub left: Option, + pub event: Option, + pub compact: Option, +} + +#[derive(Error, Debug)] +pub enum ParseAnnounceQueryError { + #[error("missing query params for announce request in {location}")] + MissingParams { location: &'static Location<'static> }, + #[error("missing param {param_name} in {location}")] + MissingParam { + location: &'static Location<'static>, + param_name: String, + }, + #[error("invalid param value {param_value} for {param_name} in {location}")] + InvalidParam { + param_name: String, + param_value: String, + location: &'static Location<'static>, + }, + #[error("param value overflow {param_value} for {param_name} in {location}")] + NumberOfBytesOverflow { + param_name: String, + param_value: String, + location: &'static Location<'static>, + }, + #[error("invalid param value {param_value} for {param_name} in {source}")] + InvalidInfoHashParam { + param_name: String, + param_value: String, + source: LocatedError<'static, ConversionError>, + }, + #[error("invalid param value {param_value} for {param_name} in {source}")] + InvalidPeerIdParam { + param_name: String, + param_value: String, + source: LocatedError<'static, IdConversionError>, + }, +} + +#[derive(PartialEq, Debug)] +pub enum Event { + Started, + Stopped, + Completed, +} + +impl FromStr for Event { + type Err = ParseAnnounceQueryError; + + fn from_str(raw_param: &str) -> Result { + match raw_param { + "started" => Ok(Self::Started), + "stopped" => Ok(Self::Stopped), + "completed" => Ok(Self::Completed), + _ => Err(ParseAnnounceQueryError::InvalidParam { + param_name: EVENT.to_owned(), + param_value: raw_param.to_owned(), + location: Location::caller(), + }), + } + } +} + +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(PartialEq, Debug)] +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"), + } + } +} + +impl FromStr for Compact { + type Err = ParseAnnounceQueryError; + + fn from_str(raw_param: &str) -> Result { + match raw_param { + "1" => Ok(Self::Accepted), + "0" => Ok(Self::NotAccepted), + _ => Err(ParseAnnounceQueryError::InvalidParam { + param_name: COMPACT.to_owned(), + param_value: raw_param.to_owned(), + location: Location::caller(), + }), + } + } +} + +impl From for responses::error::Error { + fn from(err: ParseQueryError) -> Self { + responses::error::Error { + failure_reason: format!("Cannot parse query params: {err}"), + } + } +} + +impl From for responses::error::Error { + fn from(err: ParseAnnounceQueryError) -> Self { + responses::error::Error { + failure_reason: format!("Cannot parse query params for announce request: {err}"), + } + } +} + +impl TryFrom for Announce { + type Error = ParseAnnounceQueryError; + + fn try_from(query: Query) -> Result { + Ok(Self { + info_hash: extract_info_hash(&query)?, + peer_id: extract_peer_id(&query)?, + port: extract_port(&query)?, + downloaded: extract_downloaded(&query)?, + uploaded: extract_uploaded(&query)?, + left: extract_left(&query)?, + event: extract_event(&query)?, + compact: extract_compact(&query)?, + }) + } +} + +// Mandatory params + +fn extract_info_hash(query: &Query) -> Result { + match query.get_param(INFO_HASH) { + Some(raw_param) => { + Ok( + percent_decode_info_hash(&raw_param).map_err(|err| ParseAnnounceQueryError::InvalidInfoHashParam { + param_name: INFO_HASH.to_owned(), + param_value: raw_param.clone(), + source: Located(err).into(), + })?, + ) + } + None => { + return Err(ParseAnnounceQueryError::MissingParam { + location: Location::caller(), + param_name: INFO_HASH.to_owned(), + }) + } + } +} + +fn extract_peer_id(query: &Query) -> Result { + match query.get_param(PEER_ID) { + Some(raw_param) => Ok( + percent_decode_peer_id(&raw_param).map_err(|err| ParseAnnounceQueryError::InvalidPeerIdParam { + param_name: PEER_ID.to_owned(), + param_value: raw_param.clone(), + source: Located(err).into(), + })?, + ), + None => { + return Err(ParseAnnounceQueryError::MissingParam { + location: Location::caller(), + param_name: PEER_ID.to_owned(), + }) + } + } +} + +fn extract_port(query: &Query) -> Result { + match query.get_param(PORT) { + Some(raw_param) => Ok(u16::from_str(&raw_param).map_err(|_e| ParseAnnounceQueryError::InvalidParam { + param_name: PORT.to_owned(), + param_value: raw_param.clone(), + location: Location::caller(), + })?), + None => { + return Err(ParseAnnounceQueryError::MissingParam { + location: Location::caller(), + param_name: PORT.to_owned(), + }) + } + } +} + +// Optional params + +fn extract_downloaded(query: &Query) -> Result, ParseAnnounceQueryError> { + extract_number_of_bytes_from_param(DOWNLOADED, query) +} + +fn extract_uploaded(query: &Query) -> Result, ParseAnnounceQueryError> { + extract_number_of_bytes_from_param(UPLOADED, query) +} + +fn extract_left(query: &Query) -> Result, ParseAnnounceQueryError> { + extract_number_of_bytes_from_param(LEFT, query) +} + +fn extract_number_of_bytes_from_param(param_name: &str, query: &Query) -> Result, ParseAnnounceQueryError> { + match query.get_param(param_name) { + Some(raw_param) => { + let number_of_bytes = u64::from_str(&raw_param).map_err(|_e| ParseAnnounceQueryError::InvalidParam { + param_name: param_name.to_owned(), + param_value: raw_param.clone(), + location: Location::caller(), + })?; + + Ok(Some(i64::try_from(number_of_bytes).map_err(|_e| { + ParseAnnounceQueryError::NumberOfBytesOverflow { + param_name: param_name.to_owned(), + param_value: raw_param.clone(), + location: Location::caller(), + } + })?)) + } + None => Ok(None), + } +} + +fn extract_event(query: &Query) -> Result, ParseAnnounceQueryError> { + match query.get_param(EVENT) { + Some(raw_param) => Ok(Some(Event::from_str(&raw_param)?)), + None => Ok(None), + } +} + +fn extract_compact(query: &Query) -> Result, ParseAnnounceQueryError> { + match query.get_param(COMPACT) { + Some(raw_param) => Ok(Some(Compact::from_str(&raw_param)?)), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + + mod announce_request { + + use crate::http::axum_implementation::query::Query; + use crate::http::axum_implementation::requests::announce::{ + Announce, Compact, Event, COMPACT, DOWNLOADED, EVENT, INFO_HASH, LEFT, PEER_ID, PORT, UPLOADED, + }; + use crate::protocol::info_hash::InfoHash; + use crate::tracker::peer; + + #[test] + fn should_be_instantiated_from_the_url_query_with_only_the_mandatory_params() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + ]) + .to_string(); + + let query = raw_query.parse::().unwrap(); + + let announce_request = Announce::try_from(query).unwrap(); + + assert_eq!( + announce_request, + Announce { + info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), + peer_id: "-qB00000000000000001".parse::().unwrap(), + port: 17548, + downloaded: None, + uploaded: None, + left: None, + event: None, + compact: None, + } + ); + } + + #[test] + fn should_be_instantiated_from_the_url_query_params() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (DOWNLOADED, "1"), + (UPLOADED, "2"), + (LEFT, "3"), + (EVENT, "started"), + (COMPACT, "0"), + ]) + .to_string(); + + let query = raw_query.parse::().unwrap(); + + let announce_request = Announce::try_from(query).unwrap(); + + assert_eq!( + announce_request, + Announce { + info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), + peer_id: "-qB00000000000000001".parse::().unwrap(), + port: 17548, + downloaded: Some(1), + uploaded: Some(2), + left: Some(3), + event: Some(Event::Started), + compact: Some(Compact::NotAccepted), + } + ); + } + + mod when_it_is_instantiated_from_the_url_query_params { + + use crate::http::axum_implementation::query::Query; + use crate::http::axum_implementation::requests::announce::{ + Announce, COMPACT, DOWNLOADED, EVENT, INFO_HASH, LEFT, PEER_ID, PORT, UPLOADED, + }; + + #[test] + fn it_should_fail_if_the_query_does_not_include_all_the_mandatory_params() { + let raw_query_without_info_hash = "peer_id=-qB00000000000000001&port=17548"; + + assert!(Announce::try_from(raw_query_without_info_hash.parse::().unwrap()).is_err()); + + let raw_query_without_peer_id = "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&port=17548"; + + assert!(Announce::try_from(raw_query_without_peer_id.parse::().unwrap()).is_err()); + + let raw_query_without_port = + "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_id=-qB00000000000000001"; + + assert!(Announce::try_from(raw_query_without_port.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_info_hash_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "INVALID_INFO_HASH_VALUE"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_peer_id_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "INVALID_PEER_ID_VALUE"), + (PORT, "17548"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_port_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "INVALID_PORT_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_downloaded_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (DOWNLOADED, "INVALID_DOWNLOADED_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_uploaded_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (UPLOADED, "INVALID_UPLOADED_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_left_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (LEFT, "INVALID_LEFT_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_event_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (EVENT, "INVALID_EVENT_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + + #[test] + fn it_should_fail_if_the_compact_param_is_invalid() { + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-qB00000000000000001"), + (PORT, "17548"), + (COMPACT, "INVALID_COMPACT_VALUE"), + ]) + .to_string(); + + assert!(Announce::try_from(raw_query.parse::().unwrap()).is_err()); + } + } + } +} diff --git a/src/http/axum_implementation/requests/mod.rs b/src/http/axum_implementation/requests/mod.rs new file mode 100644 index 000000000..74894de33 --- /dev/null +++ b/src/http/axum_implementation/requests/mod.rs @@ -0,0 +1 @@ +pub mod announce; diff --git a/src/http/axum_implementation/resources/ok.rs b/src/http/axum_implementation/resources/ok.rs index adc56e6ea..f941b9fb3 100644 --- a/src/http/axum_implementation/resources/ok.rs +++ b/src/http/axum_implementation/resources/ok.rs @@ -1,4 +1,8 @@ use serde::{Deserialize, Serialize}; +use crate::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct Ok {} +pub struct Ok { + pub remote_client_ip: RemoteClientIp, +} diff --git a/src/http/axum_implementation/responses.rs b/src/http/axum_implementation/responses.rs deleted file mode 100644 index 9c5896b35..000000000 --- a/src/http/axum_implementation/responses.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Resource responses - -use axum::Json; - -use super::resources::ok::Ok; - -#[must_use] -pub fn ok_response() -> Json { - Json(Ok {}) -} diff --git a/src/http/axum_implementation/responses/announce.rs b/src/http/axum_implementation/responses/announce.rs new file mode 100644 index 000000000..63ec74ac2 --- /dev/null +++ b/src/http/axum_implementation/responses/announce.rs @@ -0,0 +1,91 @@ +use std::net::IpAddr; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{self, Deserialize, Serialize}; + +use crate::tracker::{self, AnnounceResponse}; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Announce { + pub interval: u32, + #[serde(rename = "min interval")] + pub interval_min: u32, + pub complete: u32, + pub incomplete: u32, + pub peers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct Peer { + pub peer_id: String, + pub ip: IpAddr, + pub port: u16, +} + +impl From for Peer { + fn from(peer: tracker::peer::Peer) -> Self { + Peer { + peer_id: peer.peer_id.to_string(), + ip: peer.peer_addr.ip(), + port: peer.peer_addr.port(), + } + } +} + +impl Announce { + /// # Panics + /// + /// It would panic if the `Announce` struct contained an inappropriate type. + #[must_use] + pub fn write(&self) -> String { + serde_bencode::to_string(&self).unwrap() + } +} + +impl IntoResponse for Announce { + fn into_response(self) -> Response { + (StatusCode::OK, self.write()).into_response() + } +} + +impl From for Announce { + fn from(domain_announce_response: AnnounceResponse) -> Self { + let peers: Vec = domain_announce_response.peers.iter().map(|peer| Peer::from(*peer)).collect(); + + Self { + interval: domain_announce_response.interval, + interval_min: domain_announce_response.interval_min, + complete: domain_announce_response.swam_stats.seeders, + incomplete: domain_announce_response.swam_stats.leechers, + peers, + } + } +} + +#[cfg(test)] +mod tests { + + use std::net::IpAddr; + use std::str::FromStr; + + use super::{Announce, Peer}; + + #[test] + fn announce_response_can_be_bencoded() { + let response = Announce { + interval: 1, + interval_min: 2, + complete: 3, + incomplete: 4, + peers: vec![Peer { + peer_id: "-qB00000000000000001".to_string(), + ip: IpAddr::from_str("127.0.0.1").unwrap(), + port: 8080, + }], + }; + + // cspell:disable-next-line + assert_eq!(response.write(), "d8:completei3e10:incompletei4e8:intervali1e12:min intervali2e5:peersld2:ip9:127.0.0.17:peer_id20:-qB000000000000000014:porti8080eeee"); + } +} diff --git a/src/http/axum_implementation/responses/error.rs b/src/http/axum_implementation/responses/error.rs new file mode 100644 index 000000000..bcf2aaa57 --- /dev/null +++ b/src/http/axum_implementation/responses/error.rs @@ -0,0 +1,40 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{self, Serialize}; + +#[derive(Serialize)] +pub struct Error { + #[serde(rename = "failure reason")] + pub failure_reason: String, +} + +impl Error { + /// # Panics + /// + /// It would panic if the `Error` struct contained an inappropriate type. + #[must_use] + pub fn write(&self) -> String { + serde_bencode::to_string(&self).unwrap() + } +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + (StatusCode::OK, self.write()).into_response() + } +} + +#[cfg(test)] +mod tests { + + use super::Error; + + #[test] + fn http_tracker_errors_can_be_bencoded() { + let err = Error { + failure_reason: "error message".to_owned(), + }; + + assert_eq!(err.write(), "d14:failure reason13:error messagee"); // cspell:disable-line + } +} diff --git a/src/http/axum_implementation/responses/mod.rs b/src/http/axum_implementation/responses/mod.rs new file mode 100644 index 000000000..ad7d0a78c --- /dev/null +++ b/src/http/axum_implementation/responses/mod.rs @@ -0,0 +1,3 @@ +pub mod announce; +pub mod error; +pub mod ok; diff --git a/src/http/axum_implementation/responses/ok.rs b/src/http/axum_implementation/responses/ok.rs new file mode 100644 index 000000000..dfd062b51 --- /dev/null +++ b/src/http/axum_implementation/responses/ok.rs @@ -0,0 +1,11 @@ +use axum::Json; + +use crate::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; +use crate::http::axum_implementation::resources::ok::Ok; + +#[must_use] +pub fn response(remote_client_ip: &RemoteClientIp) -> Json { + Json(Ok { + remote_client_ip: remote_client_ip.clone(), + }) +} diff --git a/src/http/axum_implementation/routes.rs b/src/http/axum_implementation/routes.rs index 8e4980682..6138f5acf 100644 --- a/src/http/axum_implementation/routes.rs +++ b/src/http/axum_implementation/routes.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use axum::routing::get; use axum::Router; +use axum_client_ip::SecureClientIpSource; -use super::handlers::{announce_handler, get_status_handler}; +use super::handlers::announce::handle; +use super::handlers::status::get_status_handler; use crate::tracker::Tracker; pub fn router(tracker: &Arc) -> Router { @@ -11,5 +13,6 @@ pub fn router(tracker: &Arc) -> Router { // Status .route("/status", get(get_status_handler)) // Announce request - .route("/announce", get(announce_handler).with_state(tracker.clone())) + .route("/announce", get(handle).with_state(tracker.clone())) + .layer(SecureClientIpSource::ConnectInfo.into_extension()) } diff --git a/src/http/axum_implementation/server.rs b/src/http/axum_implementation/server.rs index 541dda33e..30c580af6 100644 --- a/src/http/axum_implementation/server.rs +++ b/src/http/axum_implementation/server.rs @@ -13,7 +13,7 @@ use crate::tracker::Tracker; pub fn start(socket_addr: SocketAddr, tracker: &Arc) -> impl Future> { let app = router(tracker); - let server = axum::Server::bind(&socket_addr).serve(app.into_make_service()); + let server = axum::Server::bind(&socket_addr).serve(app.into_make_service_with_connect_info::()); server.with_graceful_shutdown(async move { tokio::signal::ctrl_c().await.expect("Failed to listen to shutdown signal."); @@ -39,5 +39,5 @@ pub fn start_tls( axum_server::bind_rustls(socket_addr, ssl_config) .handle(handle) - .serve(app.into_make_service()) + .serve(app.into_make_service_with_connect_info::()) } diff --git a/src/http/axum_implementation/services/announce.rs b/src/http/axum_implementation/services/announce.rs new file mode 100644 index 000000000..9481354ba --- /dev/null +++ b/src/http/axum_implementation/services/announce.rs @@ -0,0 +1,24 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use crate::protocol::info_hash::InfoHash; +use crate::tracker::peer::Peer; +use crate::tracker::{statistics, AnnounceResponse, Tracker}; + +pub async fn invoke(tracker: Arc, info_hash: InfoHash, peer: &mut Peer) -> AnnounceResponse { + let original_peer_ip = peer.peer_addr.ip(); + + // The tracker could change the original peer ip + let response = tracker.announce(&info_hash, peer, &original_peer_ip).await; + + match original_peer_ip { + IpAddr::V4(_) => { + tracker.send_stats_event(statistics::Event::Tcp4Announce).await; + } + IpAddr::V6(_) => { + tracker.send_stats_event(statistics::Event::Tcp6Announce).await; + } + } + + response +} diff --git a/src/http/axum_implementation/services/mod.rs b/src/http/axum_implementation/services/mod.rs new file mode 100644 index 000000000..74894de33 --- /dev/null +++ b/src/http/axum_implementation/services/mod.rs @@ -0,0 +1 @@ +pub mod announce; diff --git a/src/http/warp_implementation/filter_helpers.rs b/src/http/warp_implementation/filter_helpers.rs new file mode 100644 index 000000000..89188d868 --- /dev/null +++ b/src/http/warp_implementation/filter_helpers.rs @@ -0,0 +1,86 @@ +use std::net::{AddrParseError, IpAddr}; +use std::panic::Location; +use std::str::FromStr; + +use thiserror::Error; + +use crate::located_error::{Located, LocatedError}; + +#[derive(Error, Debug)] +pub enum XForwardedForParseError { + #[error("Empty X-Forwarded-For header value, {location}")] + EmptyValue { location: &'static Location<'static> }, + + #[error("Invalid IP in X-Forwarded-For header: {source}")] + InvalidIp { source: LocatedError<'static, AddrParseError> }, +} + +impl From for XForwardedForParseError { + #[track_caller] + fn from(err: AddrParseError) -> Self { + Self::InvalidIp { + source: Located(err).into(), + } + } +} + +/// It extracts the last IP address from the `X-Forwarded-For` http header value. +/// +/// # Errors +/// +/// Will return and error if the last IP in the `X-Forwarded-For` header is not a valid IP +pub fn maybe_rightmost_forwarded_ip(x_forwarded_for_value: &str) -> Result { + let mut x_forwarded_for_raw = x_forwarded_for_value.to_string(); + + // Remove whitespace chars + x_forwarded_for_raw.retain(|c| !c.is_whitespace()); + + // Get all forwarded IP's in a vec + let x_forwarded_ips: Vec<&str> = x_forwarded_for_raw.split(',').collect(); + + match x_forwarded_ips.last() { + Some(last_ip) => match IpAddr::from_str(last_ip) { + Ok(ip) => Ok(ip), + Err(err) => Err(err.into()), + }, + None => Err(XForwardedForParseError::EmptyValue { + location: Location::caller(), + }), + } +} + +#[cfg(test)] +mod tests { + + use std::net::IpAddr; + use std::str::FromStr; + + use super::maybe_rightmost_forwarded_ip; + + #[test] + fn the_last_forwarded_ip_can_be_parsed_from_the_the_corresponding_http_header() { + assert!(maybe_rightmost_forwarded_ip("").is_err()); + + assert!(maybe_rightmost_forwarded_ip("INVALID IP").is_err()); + + assert_eq!( + maybe_rightmost_forwarded_ip("2001:db8:85a3:8d3:1319:8a2e:370:7348").unwrap(), + IpAddr::from_str("2001:db8:85a3:8d3:1319:8a2e:370:7348").unwrap() + ); + + assert_eq!( + maybe_rightmost_forwarded_ip("203.0.113.195").unwrap(), + IpAddr::from_str("203.0.113.195").unwrap() + ); + + assert_eq!( + maybe_rightmost_forwarded_ip("203.0.113.195, 2001:db8:85a3:8d3:1319:8a2e:370:7348").unwrap(), + IpAddr::from_str("2001:db8:85a3:8d3:1319:8a2e:370:7348").unwrap() + ); + + assert_eq!( + maybe_rightmost_forwarded_ip("203.0.113.195,2001:db8:85a3:8d3:1319:8a2e:370:7348,150.172.238.178").unwrap(), + IpAddr::from_str("150.172.238.178").unwrap() + ); + } +} diff --git a/src/http/warp_implementation/filters.rs b/src/http/warp_implementation/filters.rs index 176170330..fc8ef20bc 100644 --- a/src/http/warp_implementation/filters.rs +++ b/src/http/warp_implementation/filters.rs @@ -1,12 +1,12 @@ use std::convert::Infallible; use std::net::{IpAddr, SocketAddr}; use std::panic::Location; -use std::str::FromStr; use std::sync::Arc; use warp::{reject, Filter, Rejection}; use super::error::Error; +use super::filter_helpers::maybe_rightmost_forwarded_ip; use super::{request, WebResult}; use crate::http::percent_encoding::{percent_decode_info_hash, percent_decode_peer_id}; use crate::protocol::common::MAX_SCRAPE_TORRENTS; @@ -138,41 +138,33 @@ fn peer_id(raw_query: &String) -> WebResult { } } -/// Get `PeerAddress` from `RemoteAddress` or Forwarded -fn peer_addr((on_reverse_proxy, remote_addr, x_forwarded_for): (bool, Option, Option)) -> WebResult { - if !on_reverse_proxy && remote_addr.is_none() { - return Err(reject::custom(Error::AddressNotFound { - location: Location::caller(), - message: "neither on have remote address or on a reverse proxy".to_string(), - })); - } +/// Get peer IP from HTTP client IP or X-Forwarded-For HTTP header +fn peer_addr( + (on_reverse_proxy, remote_client_ip, maybe_x_forwarded_for): (bool, Option, Option), +) -> WebResult { + if on_reverse_proxy { + if maybe_x_forwarded_for.is_none() { + return Err(reject::custom(Error::AddressNotFound { + location: Location::caller(), + message: "must have a x-forwarded-for when using a reverse proxy".to_string(), + })); + } - if on_reverse_proxy && x_forwarded_for.is_none() { - return Err(reject::custom(Error::AddressNotFound { - location: Location::caller(), - message: "must have a x-forwarded-for when using a reverse proxy".to_string(), - })); - } + let x_forwarded_for = maybe_x_forwarded_for.unwrap(); - if on_reverse_proxy { - let mut x_forwarded_for_raw = x_forwarded_for.unwrap(); - // remove whitespace chars - x_forwarded_for_raw.retain(|c| !c.is_whitespace()); - // get all forwarded ip's in a vec - let x_forwarded_ips: Vec<&str> = x_forwarded_for_raw.split(',').collect(); - // set client ip to last forwarded ip - let x_forwarded_ip = *x_forwarded_ips.last().unwrap(); - - IpAddr::from_str(x_forwarded_ip).map_err(|e| { + maybe_rightmost_forwarded_ip(&x_forwarded_for).map_err(|e| { reject::custom(Error::AddressNotFound { location: Location::caller(), - message: format!( - "on remote proxy and unable to parse the last x-forwarded-ip: `{e}`, from `{x_forwarded_for_raw}`" - ), + message: format!("on remote proxy and unable to parse the last x-forwarded-ip: `{e}`, from `{x_forwarded_for}`"), }) }) + } else if remote_client_ip.is_none() { + return Err(reject::custom(Error::AddressNotFound { + location: Location::caller(), + message: "neither on have remote address or on a reverse proxy".to_string(), + })); } else { - Ok(remote_addr.unwrap().ip()) + return Ok(remote_client_ip.unwrap().ip()); } } diff --git a/src/http/warp_implementation/handlers.rs b/src/http/warp_implementation/handlers.rs index fd927150f..400cc5762 100644 --- a/src/http/warp_implementation/handlers.rs +++ b/src/http/warp_implementation/handlers.rs @@ -49,6 +49,9 @@ pub async fn handle_announce( let mut peer = peer_builder::from_request(&announce_request, &remote_client_ip); + // todo: we should be use the http::axum_implementation::services::announce::announce service, + // but this Warp implementation is going to be removed. + let response = tracker.announce(&info_hash, &mut peer, &remote_client_ip).await; match remote_client_ip { diff --git a/src/http/warp_implementation/mod.rs b/src/http/warp_implementation/mod.rs index 1dec73b29..2ceda2e68 100644 --- a/src/http/warp_implementation/mod.rs +++ b/src/http/warp_implementation/mod.rs @@ -1,6 +1,5 @@ -use warp::Rejection; - pub mod error; +pub mod filter_helpers; pub mod filters; pub mod handlers; pub mod peer_builder; @@ -9,5 +8,7 @@ pub mod response; pub mod routes; pub mod server; +use warp::Rejection; + pub type Bytes = u64; pub type WebResult = std::result::Result; diff --git a/src/tracker/mod.rs b/src/tracker/mod.rs index 48bd76128..cb3bd0e96 100644 --- a/src/tracker/mod.rs +++ b/src/tracker/mod.rs @@ -46,6 +46,8 @@ pub struct TorrentsMetrics { pub struct AnnounceResponse { pub peers: Vec, pub swam_stats: SwamStats, + pub interval: u32, + pub interval_min: u32, } impl Tracker { @@ -92,7 +94,12 @@ impl Tracker { // todo: remove peer by using its `Id` instead of its socket address: `get_peers_excluding_peer(peer_id: peer::Id)` let peers = self.get_peers_excluding_peers_with_address(info_hash, &peer.peer_addr).await; - AnnounceResponse { peers, swam_stats } + AnnounceResponse { + peers, + swam_stats, + interval: self.config.announce_interval, + interval_min: self.config.min_announce_interval, + } } /// # Errors diff --git a/src/tracker/peer.rs b/src/tracker/peer.rs index 7559463db..735754529 100644 --- a/src/tracker/peer.rs +++ b/src/tracker/peer.rs @@ -22,6 +22,8 @@ pub struct Peer { pub downloaded: NumberOfBytes, #[serde(with = "NumberOfBytesDef")] pub left: NumberOfBytes, // The number of bytes this peer still has to download + // code-review: aquatic_udp_protocol::request::AnnounceEvent is used also for the HTTP tracker. + // Maybe we should use our own enum and use theĀ”is one only for the UDP tracker. #[serde(with = "AnnounceEventDef")] pub event: AnnounceEvent, } diff --git a/tests/http/asserts.rs b/tests/http/asserts.rs index 211a7bb33..e146f252d 100644 --- a/tests/http/asserts.rs +++ b/tests/http/asserts.rs @@ -6,7 +6,7 @@ use super::responses::announce::{Announce, Compact, DeserializedCompact}; use super::responses::scrape; use crate::http::responses::error::Error; -pub fn assert_error_bencoded(response_text: &String, expected_failure_reason: &str, location: &'static Location<'static>) { +pub fn assert_bencoded_error(response_text: &String, expected_failure_reason: &str, location: &'static Location<'static>) { let error_failure_reason = serde_bencode::from_str::(response_text) .unwrap_or_else(|_| panic!( "response body should be a valid bencoded string for the '{expected_failure_reason}' error, got \"{response_text}\"" @@ -18,7 +18,7 @@ pub fn assert_error_bencoded(response_text: &String, expected_failure_reason: &s error_failure_reason.contains(expected_failure_reason), r#": response: `"{error_failure_reason}"` - dose not contain: `"{expected_failure_reason}"`, {location}"# + does not contain: `"{expected_failure_reason}"`, {location}"# ); } @@ -83,13 +83,13 @@ pub async fn assert_is_announce_response(response: Response) { pub async fn assert_internal_server_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded(&response.text().await.unwrap(), "internal server", Location::caller()); + assert_bencoded_error(&response.text().await.unwrap(), "internal server", Location::caller()); } pub async fn assert_invalid_info_hash_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded( + assert_bencoded_error( &response.text().await.unwrap(), "no valid infohashes found", Location::caller(), @@ -99,7 +99,7 @@ pub async fn assert_invalid_info_hash_error_response(response: Response) { pub async fn assert_invalid_peer_id_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded( + assert_bencoded_error( &response.text().await.unwrap(), "peer_id is either missing or invalid", Location::caller(), @@ -109,13 +109,13 @@ pub async fn assert_invalid_peer_id_error_response(response: Response) { pub async fn assert_torrent_not_in_whitelist_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded(&response.text().await.unwrap(), "is not whitelisted", Location::caller()); + assert_bencoded_error(&response.text().await.unwrap(), "is not whitelisted", Location::caller()); } pub async fn assert_peer_not_authenticated_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded( + assert_bencoded_error( &response.text().await.unwrap(), "The peer is not authenticated", Location::caller(), @@ -125,5 +125,55 @@ pub async fn assert_peer_not_authenticated_error_response(response: Response) { pub async fn assert_invalid_authentication_key_error_response(response: Response) { assert_eq!(response.status(), 200); - assert_error_bencoded(&response.text().await.unwrap(), "is not valid", Location::caller()); + assert_bencoded_error(&response.text().await.unwrap(), "is not valid", Location::caller()); +} + +pub async fn assert_could_not_find_remote_address_on_xff_header_error_response(response: Response) { + assert_eq!(response.status(), 200); + + assert_bencoded_error( + &response.text().await.unwrap(), + "could not find remote address: must have a x-forwarded-for when using a reverse proxy", + Location::caller(), + ); +} + +pub async fn assert_invalid_remote_address_on_xff_header_error_response(response: Response) { + assert_eq!(response.status(), 200); + + assert_bencoded_error( + &response.text().await.unwrap(), + "could not find remote address: on remote proxy and unable to parse the last x-forwarded-ip", + Location::caller(), + ); +} + +// Specific errors for announce request + +pub async fn assert_missing_query_params_for_announce_request_error_response(response: Response) { + assert_eq!(response.status(), 200); + + assert_bencoded_error( + &response.text().await.unwrap(), + "missing query params for announce request", + Location::caller(), + ); +} + +pub async fn assert_bad_announce_request_error_response(response: Response, failure: &str) { + assert_cannot_parse_query_params_error_response(response, &format!(" for announce request: {failure}")).await; +} + +pub async fn assert_cannot_parse_query_param_error_response(response: Response, failure: &str) { + assert_cannot_parse_query_params_error_response(response, &format!(": {failure}")).await; +} + +pub async fn assert_cannot_parse_query_params_error_response(response: Response, failure: &str) { + assert_eq!(response.status(), 200); + + assert_bencoded_error( + &response.text().await.unwrap(), + &format!("Cannot parse query params{failure}"), + Location::caller(), + ); } diff --git a/tests/http_tracker.rs b/tests/http_tracker.rs index 60219d9fe..ded30a0b4 100644 --- a/tests/http_tracker.rs +++ b/tests/http_tracker.rs @@ -16,6 +16,47 @@ mod warp_http_tracker_server { mod for_all_config_modes { + mod running_on_reverse_proxy { + use torrust_tracker::http::Version; + + use crate::http::asserts::{ + assert_could_not_find_remote_address_on_xff_header_error_response, + assert_invalid_remote_address_on_xff_header_error_response, + }; + use crate::http::client::Client; + use crate::http::requests::announce::QueryBuilder; + use crate::http::server::start_http_tracker_on_reverse_proxy; + + #[tokio::test] + async fn should_fail_when_the_http_request_does_not_include_the_xff_http_request_header() { + // If the tracker is running behind a reverse proxy, the peer IP is the + // last IP in the `X-Forwarded-For` HTTP header, which is the IP of the proxy client. + + let http_tracker_server = start_http_tracker_on_reverse_proxy(Version::Warp).await; + + let params = QueryBuilder::default().query().params(); + + let response = Client::new(http_tracker_server.get_connection_info()) + .get(&format!("announce?{params}")) + .await; + + assert_could_not_find_remote_address_on_xff_header_error_response(response).await; + } + + #[tokio::test] + async fn should_fail_when_the_xff_http_request_header_contains_an_invalid_ip() { + let http_tracker_server = start_http_tracker_on_reverse_proxy(Version::Warp).await; + + let params = QueryBuilder::default().query().params(); + + let response = Client::new(http_tracker_server.get_connection_info()) + .get_with_header(&format!("announce?{params}"), "X-Forwarded-For", "INVALID IP") + .await; + + assert_invalid_remote_address_on_xff_header_error_response(response).await; + } + } + mod receiving_an_announce_request { // Announce request documentation: @@ -69,7 +110,7 @@ mod warp_http_tracker_server { } #[tokio::test] - async fn should_fail_when_the_request_is_empty() { + async fn should_fail_when_the_url_query_component_is_empty() { let http_tracker_server = start_default_http_tracker(Version::Warp).await; let response = Client::new(http_tracker_server.get_connection_info()).get("announce").await; @@ -667,9 +708,6 @@ mod warp_http_tracker_server { let announce_query = QueryBuilder::default().with_info_hash(&info_hash).query(); - // todo: shouldn't be the the leftmost IP address? - // THe application is taken the the rightmost IP address. See function http::filters::peer_addr - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For client .announce_with_header( &announce_query, @@ -1225,6 +1263,9 @@ mod axum_http_tracker_server { // WIP: migration HTTP from Warp to Axum + use local_ip_address::local_ip; + use torrust_tracker::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; + use torrust_tracker::http::axum_implementation::resources::ok::Ok; use torrust_tracker::http::Version; use crate::http::client::Client; @@ -1233,16 +1274,177 @@ mod axum_http_tracker_server { #[tokio::test] async fn should_return_the_status() { // This is a temporary test to test the new Axum HTTP tracker server scaffolding + let http_tracker_server = start_default_http_tracker(Version::Axum).await; - let response = Client::new(http_tracker_server.get_connection_info()).get("status").await; + let client_ip = local_ip().unwrap(); + + let response = Client::bind(http_tracker_server.get_connection_info(), client_ip) + .get("status") + .await; + + let ok: Ok = serde_json::from_str(&response.text().await.unwrap()).unwrap(); + + assert_eq!( + ok, + Ok { + remote_client_ip: RemoteClientIp { + right_most_x_forwarded_for: None, + connection_info_ip: Some(client_ip) + } + } + ); + } + + mod should_get_the_remote_client_ip_from_the_http_request { + + // Temporary tests to test that the new Axum HTTP tracker gets the right remote client IP. + // Once the implementation is finished, test for announce request will cover these cases. + + use std::net::IpAddr; + use std::str::FromStr; + + use local_ip_address::local_ip; + use torrust_tracker::http::axum_implementation::extractors::remote_client_ip::RemoteClientIp; + use torrust_tracker::http::axum_implementation::resources::ok::Ok; + use torrust_tracker::http::Version; + + use crate::http::client::Client; + use crate::http::server::{start_http_tracker_on_reverse_proxy, start_public_http_tracker}; + + #[tokio::test] + async fn when_the_client_ip_is_a_local_ip_it_should_assign_that_ip() { + let http_tracker_server = start_public_http_tracker(Version::Axum).await; + + let client_ip = local_ip().unwrap(); + + let client = Client::bind(http_tracker_server.get_connection_info(), client_ip); + + let response = client.get("status").await; + + let ok: Ok = serde_json::from_str(&response.text().await.unwrap()).unwrap(); + + assert_eq!( + ok, + Ok { + remote_client_ip: RemoteClientIp { + right_most_x_forwarded_for: None, + connection_info_ip: Some(client_ip) + } + } + ); + } + + #[tokio::test] + async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_that_ip() { + let http_tracker_server = start_public_http_tracker(Version::Axum).await; + + let loopback_ip = IpAddr::from_str("127.0.0.1").unwrap(); + let client_ip = loopback_ip; + + let client = Client::bind(http_tracker_server.get_connection_info(), client_ip); + + let response = client.get("status").await; - assert_eq!(response.status(), 200); - assert_eq!(response.text().await.unwrap(), "{}"); + let ok: Ok = serde_json::from_str(&response.text().await.unwrap()).unwrap(); + + assert_eq!( + ok, + Ok { + remote_client_ip: RemoteClientIp { + right_most_x_forwarded_for: None, + connection_info_ip: Some(client_ip) + } + } + ); + } + + #[tokio::test] + async fn when_the_tracker_is_behind_a_reverse_proxy_it_should_assign_as_secure_ip_the_right_most_ip_in_the_x_forwarded_for_http_header( + ) { + /* + client <-> http proxy <-> tracker <-> Internet + ip: header: config: remote client ip: + 145.254.214.256 X-Forwarded-For = 145.254.214.256 on_reverse_proxy = true 145.254.214.256 + */ + + let http_tracker_server = start_http_tracker_on_reverse_proxy(Version::Axum).await; + + let loopback_ip = IpAddr::from_str("127.0.0.1").unwrap(); + let client_ip = loopback_ip; + + let client = Client::bind(http_tracker_server.get_connection_info(), client_ip); + + let left_most_ip = IpAddr::from_str("203.0.113.195").unwrap(); + let right_most_ip = IpAddr::from_str("150.172.238.178").unwrap(); + + let response = client + .get_with_header( + "status", + "X-Forwarded-For", + &format!("{left_most_ip},2001:db8:85a3:8d3:1319:8a2e:370:7348,{right_most_ip}"), + ) + .await; + + let ok: Ok = serde_json::from_str(&response.text().await.unwrap()).unwrap(); + + assert_eq!( + ok, + Ok { + remote_client_ip: RemoteClientIp { + right_most_x_forwarded_for: Some(right_most_ip), + connection_info_ip: Some(client_ip) + } + } + ); + } } mod for_all_config_modes { + mod and_running_on_reverse_proxy { + use torrust_tracker::http::Version; + + use crate::http::asserts::{ + assert_could_not_find_remote_address_on_xff_header_error_response, + assert_invalid_remote_address_on_xff_header_error_response, + }; + use crate::http::client::Client; + use crate::http::requests::announce::QueryBuilder; + use crate::http::server::start_http_tracker_on_reverse_proxy; + + //#[tokio::test] + #[allow(dead_code)] + async fn should_fail_when_the_http_request_does_not_include_the_xff_http_request_header() { + // If the tracker is running behind a reverse proxy, the peer IP is the + // last IP in the `X-Forwarded-For` HTTP header, which is the IP of the proxy client. + + let http_tracker_server = start_http_tracker_on_reverse_proxy(Version::Axum).await; + + let params = QueryBuilder::default().query().params(); + + let response = Client::new(http_tracker_server.get_connection_info()) + .get(&format!("announce?{params}")) + .await; + + assert_could_not_find_remote_address_on_xff_header_error_response(response).await; + } + + //#[tokio::test] + #[allow(dead_code)] + async fn should_fail_when_the_xff_http_request_header_contains_an_invalid_ip() { + let http_tracker_server = start_http_tracker_on_reverse_proxy(Version::Axum).await; + + let params = QueryBuilder::default().query().params(); + + let response = Client::new(http_tracker_server.get_connection_info()) + .get_with_header(&format!("announce?{params}"), "X-Forwarded-For", "INVALID IP") + .await; + + assert_invalid_remote_address_on_xff_header_error_response(response).await; + } + } + mod receiving_an_announce_request { // Announce request documentation: @@ -1267,9 +1469,10 @@ mod axum_http_tracker_server { use crate::common::fixtures::{invalid_info_hashes, PeerBuilder}; use crate::http::asserts::{ - assert_announce_response, assert_compact_announce_response, assert_empty_announce_response, - assert_internal_server_error_response, assert_invalid_info_hash_error_response, - assert_invalid_peer_id_error_response, assert_is_announce_response, + assert_announce_response, assert_bad_announce_request_error_response, + assert_cannot_parse_query_param_error_response, assert_cannot_parse_query_params_error_response, + assert_compact_announce_response, assert_empty_announce_response, assert_is_announce_response, + assert_missing_query_params_for_announce_request_error_response, }; use crate::http::client::Client; use crate::http::requests::announce::{Compact, QueryBuilder}; @@ -1280,8 +1483,7 @@ mod axum_http_tracker_server { start_ipv6_http_tracker, start_public_http_tracker, }; - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_respond_if_only_the_mandatory_fields_are_provided() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1296,18 +1498,29 @@ mod axum_http_tracker_server { assert_is_announce_response(response).await; } - //#[tokio::test] - #[allow(dead_code)] - async fn should_fail_when_the_request_is_empty() { + #[tokio::test] + async fn should_fail_when_the_url_query_component_is_empty() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; let response = Client::new(http_tracker_server.get_connection_info()).get("announce").await; - assert_internal_server_error_response(response).await; + assert_missing_query_params_for_announce_request_error_response(response).await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] + async fn should_fail_when_url_query_parameters_are_invalid() { + let http_tracker_server = start_default_http_tracker(Version::Axum).await; + + let invalid_query_param = "a=b=c"; + + let response = Client::new(http_tracker_server.get_connection_info()) + .get(&format!("announce?{invalid_query_param}")) + .await; + + assert_cannot_parse_query_param_error_response(response, "invalid param a=b=c").await; + } + + #[tokio::test] async fn should_fail_when_a_mandatory_field_is_missing() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1321,7 +1534,7 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_invalid_info_hash_error_response(response).await; + assert_bad_announce_request_error_response(response, "missing param info_hash").await; // Without `peer_id` param @@ -1333,7 +1546,7 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_invalid_peer_id_error_response(response).await; + assert_bad_announce_request_error_response(response, "missing param peer_id").await; // Without `port` param @@ -1345,11 +1558,10 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "missing param port").await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_info_hash_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1362,17 +1574,16 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_invalid_info_hash_error_response(response).await; + assert_cannot_parse_query_params_error_response(response, "").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_not_fail_when_the_peer_address_param_is_invalid() { // AnnounceQuery does not even contain the `peer_addr` // The peer IP is obtained in two ways: - // 1. If tracker is NOT running `on_reverse_proxy` from the remote client IP if there. - // 2. If tracker is running `on_reverse_proxy` from `X-Forwarded-For` request header is tracker is running `on_reverse_proxy`. + // 1. If tracker is NOT running `on_reverse_proxy` from the remote client IP. + // 2. If tracker is running `on_reverse_proxy` from `X-Forwarded-For` request HTTP header. let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1387,8 +1598,7 @@ mod axum_http_tracker_server { assert_is_announce_response(response).await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_downloaded_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1403,12 +1613,11 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_uploaded_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1423,12 +1632,11 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_peer_id_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1450,12 +1658,11 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_invalid_peer_id_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_port_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1470,12 +1677,11 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_fail_when_the_left_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; @@ -1490,15 +1696,12 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] - async fn should_not_fail_when_the_event_param_is_invalid() { - // All invalid values are ignored as if the `event` param were empty - + #[tokio::test] + async fn should_fail_when_the_event_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; let mut params = QueryBuilder::default().query().params(); @@ -1508,9 +1711,9 @@ mod axum_http_tracker_server { "-1", "1.1", "a", - "Started", // It should be lowercase - "Stopped", // It should be lowercase - "Completed", // It should be lowercase + "Started", // It should be lowercase to be valid: `started` + "Stopped", // It should be lowercase to be valid: `stopped` + "Completed", // It should be lowercase to be valid: `completed` ]; for invalid_value in invalid_values { @@ -1520,13 +1723,12 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_is_announce_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] - async fn should_not_fail_when_the_compact_param_is_invalid() { + #[tokio::test] + async fn should_fail_when_the_compact_param_is_invalid() { let http_tracker_server = start_default_http_tracker(Version::Axum).await; let mut params = QueryBuilder::default().query().params(); @@ -1540,12 +1742,11 @@ mod axum_http_tracker_server { .get(&format!("announce?{params}")) .await; - assert_internal_server_error_response(response).await; + assert_bad_announce_request_error_response(response, "invalid param value").await; } } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_return_no_peers_if_the_announced_peer_is_the_first_one() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1570,8 +1771,7 @@ mod axum_http_tracker_server { .await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_return_the_list_of_previously_announced_peers() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1595,7 +1795,7 @@ mod axum_http_tracker_server { ) .await; - // It should only contain teh previously announced peer + // It should only contain the previously announced peer assert_announce_response( response, &Announce { @@ -1609,8 +1809,7 @@ mod axum_http_tracker_server { .await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_consider_two_peers_to_be_the_same_when_they_have_the_same_peer_id_even_if_the_ip_is_different() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1674,8 +1873,7 @@ mod axum_http_tracker_server { assert_compact_announce_response(response, &expected_response).await; } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_not_return_the_compact_response_by_default() { // code-review: the HTTP tracker does not return the compact response by default if the "compact" // param is not provided in the announce URL. The BEP 23 suggest to do so. @@ -1714,8 +1912,7 @@ mod axum_http_tracker_server { compact_announce.is_ok() } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_increase_the_number_of_tcp4_connections_handled_in_statistics() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1728,8 +1925,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp4_connections_handled, 1); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_increase_the_number_of_tcp6_connections_handled_in_statistics() { let http_tracker_server = start_ipv6_http_tracker(Version::Axum).await; @@ -1742,8 +1938,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp6_connections_handled, 1); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_not_increase_the_number_of_tcp6_connections_handled_if_the_client_is_not_using_an_ipv6_ip() { // The tracker ignores the peer address in the request param. It uses the client remote ip address. @@ -1762,8 +1957,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp6_connections_handled, 0); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_increase_the_number_of_tcp4_announce_requests_handled_in_statistics() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1776,8 +1970,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp4_announces_handled, 1); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_increase_the_number_of_tcp6_announce_requests_handled_in_statistics() { let http_tracker_server = start_ipv6_http_tracker(Version::Axum).await; @@ -1790,8 +1983,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp6_announces_handled, 1); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_not_increase_the_number_of_tcp6_announce_requests_handled_if_the_client_is_not_using_an_ipv6_ip() { // The tracker ignores the peer address in the request param. It uses the client remote ip address. @@ -1810,8 +2002,7 @@ mod axum_http_tracker_server { assert_eq!(stats.tcp6_announces_handled, 0); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn should_assign_to_the_peer_ip_the_remote_client_ip_instead_of_the_peer_address_in_the_request_param() { let http_tracker_server = start_public_http_tracker(Version::Axum).await; @@ -1834,8 +2025,7 @@ mod axum_http_tracker_server { assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_to_the_peer_ip_the_external_ip_in_the_tracker_configuration( ) { /* We assume that both the client and tracker share the same public IP. @@ -1867,8 +2057,7 @@ mod axum_http_tracker_server { assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn when_the_client_ip_is_a_loopback_ipv6_it_should_assign_to_the_peer_ip_the_external_ip_in_the_tracker_configuration( ) { /* We assume that both the client and tracker share the same public IP. @@ -1903,8 +2092,7 @@ mod axum_http_tracker_server { assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); } - //#[tokio::test] - #[allow(dead_code)] + #[tokio::test] async fn when_the_tracker_is_behind_a_reverse_proxy_it_should_assign_to_the_peer_ip_the_ip_in_the_x_forwarded_for_http_header( ) { /* @@ -1921,9 +2109,6 @@ mod axum_http_tracker_server { let announce_query = QueryBuilder::default().with_info_hash(&info_hash).query(); - // todo: shouldn't be the the leftmost IP address? - // THe application is taken the the rightmost IP address. See function http::filters::peer_addr - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For client .announce_with_header( &announce_query,