diff --git a/src/api/resources/mod.rs b/src/api/resources/mod.rs index 4b4f2214c..d214d8a59 100644 --- a/src/api/resources/mod.rs +++ b/src/api/resources/mod.rs @@ -3,7 +3,9 @@ //! WIP. Not all endpoints have their resource structs. //! //! - [x] AuthKeys -//! - [ ] ... -//! - [ ] ... +//! - [ ] TorrentResource, TorrentListItemResource, TorrentPeerResource, PeerIdResource +//! - [ ] StatsResource //! - [ ] ... pub mod auth_key_resource; +pub mod stats_resource; +pub mod torrent_resource; diff --git a/src/api/resources/stats_resource.rs b/src/api/resources/stats_resource.rs new file mode 100644 index 000000000..2fbaf42c1 --- /dev/null +++ b/src/api/resources/stats_resource.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct StatsResource { + pub torrents: u32, + pub seeders: u32, + pub completed: u32, + pub leechers: u32, + pub tcp4_connections_handled: u32, + pub tcp4_announces_handled: u32, + pub tcp4_scrapes_handled: u32, + pub tcp6_connections_handled: u32, + pub tcp6_announces_handled: u32, + pub tcp6_scrapes_handled: u32, + pub udp4_connections_handled: u32, + pub udp4_announces_handled: u32, + pub udp4_scrapes_handled: u32, + pub udp6_connections_handled: u32, + pub udp6_announces_handled: u32, + pub udp6_scrapes_handled: u32, +} diff --git a/src/api/resources/torrent_resource.rs b/src/api/resources/torrent_resource.rs new file mode 100644 index 000000000..11e9d7196 --- /dev/null +++ b/src/api/resources/torrent_resource.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; + +use crate::peer::TorrentPeer; +use crate::PeerId; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct TorrentResource { + pub info_hash: String, + pub seeders: u32, + pub completed: u32, + pub leechers: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub peers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct TorrentListItemResource { + pub info_hash: String, + pub seeders: u32, + pub completed: u32, + pub leechers: u32, + // todo: this is always None. Remove field from endpoint? + pub peers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct TorrentPeerResource { + pub peer_id: PeerIdResource, + pub peer_addr: String, + #[deprecated(since = "2.0.0", note = "please use `updated_milliseconds_ago` instead")] + pub updated: u128, + pub updated_milliseconds_ago: u128, + pub uploaded: i64, + pub downloaded: i64, + pub left: i64, + pub event: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct PeerIdResource { + pub id: Option, + pub client: Option, +} + +impl From for PeerIdResource { + fn from(peer_id: PeerId) -> Self { + PeerIdResource { + id: peer_id.get_id(), + client: peer_id.get_client_name().map(|client_name| client_name.to_string()), + } + } +} + +impl From for TorrentPeerResource { + fn from(peer: TorrentPeer) -> Self { + TorrentPeerResource { + peer_id: PeerIdResource::from(peer.peer_id), + peer_addr: peer.peer_addr.to_string(), + updated: peer.updated.as_millis(), + updated_milliseconds_ago: peer.updated.as_millis(), + uploaded: peer.uploaded.0, + downloaded: peer.downloaded.0, + left: peer.left.0, + event: format!("{:?}", peer.event), + } + } +} diff --git a/src/api/server.rs b/src/api/server.rs index 9f215710e..41e6f7074 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use warp::{filters, reply, serve, Filter}; use super::resources::auth_key_resource::AuthKeyResource; -use crate::peer::TorrentPeer; +use super::resources::stats_resource::StatsResource; +use super::resources::torrent_resource::{TorrentListItemResource, TorrentPeerResource, TorrentResource}; use crate::protocol::common::*; use crate::tracker::TorrentTracker; @@ -18,36 +19,6 @@ struct TorrentInfoQuery { limit: Option, } -#[derive(Serialize)] -struct Torrent<'a> { - info_hash: &'a InfoHash, - seeders: u32, - completed: u32, - leechers: u32, - #[serde(skip_serializing_if = "Option::is_none")] - peers: Option>, -} - -#[derive(Serialize)] -struct Stats { - torrents: u32, - seeders: u32, - completed: u32, - leechers: u32, - tcp4_connections_handled: u32, - tcp4_announces_handled: u32, - tcp4_scrapes_handled: u32, - tcp6_connections_handled: u32, - tcp6_announces_handled: u32, - tcp6_scrapes_handled: u32, - udp4_connections_handled: u32, - udp4_announces_handled: u32, - udp4_scrapes_handled: u32, - udp6_connections_handled: u32, - udp6_announces_handled: u32, - udp6_scrapes_handled: u32, -} - #[derive(Serialize, Debug)] #[serde(tag = "status", rename_all = "snake_case")] enum ActionStatus<'a> { @@ -109,8 +80,8 @@ pub fn start(socket_addr: SocketAddr, tracker: Arc) -> impl warp .iter() .map(|(info_hash, torrent_entry)| { let (seeders, completed, leechers) = torrent_entry.get_stats(); - Torrent { - info_hash, + TorrentListItemResource { + info_hash: info_hash.to_string(), seeders, completed, leechers, @@ -132,7 +103,7 @@ pub fn start(socket_addr: SocketAddr, tracker: Arc) -> impl warp .and(filters::path::end()) .map(move || api_stats.clone()) .and_then(|tracker: Arc| async move { - let mut results = Stats { + let mut results = StatsResource { torrents: 0, seeders: 0, completed: 0, @@ -206,12 +177,14 @@ pub fn start(socket_addr: SocketAddr, tracker: Arc) -> impl warp let peers = torrent_entry.get_peers(None); - Ok(reply::json(&Torrent { - info_hash: &info_hash, + let peer_resources = peers.iter().map(|peer| TorrentPeerResource::from(**peer)).collect(); + + Ok(reply::json(&TorrentResource { + info_hash: info_hash.to_string(), seeders, completed, leechers, - peers: Some(peers), + peers: Some(peer_resources), })) }); diff --git a/src/protocol/common.rs b/src/protocol/common.rs index 431521764..ce1cbf253 100644 --- a/src/protocol/common.rs +++ b/src/protocol/common.rs @@ -217,7 +217,7 @@ impl<'v> serde::de::Visitor<'v> for InfoHashVisitor { } } -#[derive(PartialEq, Eq, Hash, Clone, Debug, PartialOrd, Ord)] +#[derive(PartialEq, Eq, Hash, Clone, Debug, PartialOrd, Ord, Copy)] pub struct PeerId(pub [u8; 20]); impl std::fmt::Display for PeerId { @@ -232,6 +232,14 @@ impl std::fmt::Display for PeerId { } impl PeerId { + pub fn get_id(&self) -> Option { + let buff_size = self.0.len() * 2; + let mut tmp: Vec = vec![0; buff_size]; + binascii::bin2hex(&self.0, &mut tmp).unwrap(); + + std::str::from_utf8(&tmp).ok().map(|id| id.to_string()) + } + pub fn get_client_name(&self) -> Option<&'static str> { if self.0[0] == b'M' { return Some("BitTorrent"); @@ -316,19 +324,14 @@ impl Serialize for PeerId { where S: serde::Serializer, { - let buff_size = self.0.len() * 2; - let mut tmp: Vec = vec![0; buff_size]; - binascii::bin2hex(&self.0, &mut tmp).unwrap(); - let id = std::str::from_utf8(&tmp).ok(); - #[derive(Serialize)] struct PeerIdInfo<'a> { - id: Option<&'a str>, + id: Option, client: Option<&'a str>, } let obj = PeerIdInfo { - id, + id: self.get_id(), client: self.get_client_name(), }; obj.serialize(serializer) diff --git a/src/tracker/peer.rs b/src/tracker/peer.rs index 7a2599f82..42ef6a60b 100644 --- a/src/tracker/peer.rs +++ b/src/tracker/peer.rs @@ -9,7 +9,7 @@ use crate::protocol::clock::{DefaultClock, DurationSinceUnixEpoch, Time}; use crate::protocol::common::{AnnounceEventDef, NumberOfBytesDef, PeerId}; use crate::protocol::utils::ser_unix_time_value; -#[derive(PartialEq, Eq, Debug, Clone, Serialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Copy)] pub struct TorrentPeer { pub peer_id: PeerId, pub peer_addr: SocketAddr, diff --git a/src/tracker/torrent.rs b/src/tracker/torrent.rs index 4e602d359..335554006 100644 --- a/src/tracker/torrent.rs +++ b/src/tracker/torrent.rs @@ -32,7 +32,7 @@ impl TorrentEntry { let _ = self.peers.remove(&peer.peer_id); } AnnounceEvent::Completed => { - let peer_old = self.peers.insert(peer.peer_id.clone(), peer.clone()); + let peer_old = self.peers.insert(peer.peer_id, *peer); // Don't count if peer was not previously known if peer_old.is_some() { self.completed += 1; @@ -40,7 +40,7 @@ impl TorrentEntry { } } _ => { - let _ = self.peers.insert(peer.peer_id.clone(), peer.clone()); + let _ = self.peers.insert(peer.peer_id, *peer); } } diff --git a/tests/api.rs b/tests/api.rs index 278f9d4fb..475da9a24 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -8,32 +8,36 @@ mod common; mod tracker_api { use core::panic; use std::env; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; + use aquatic_udp_protocol::{AnnounceEvent, NumberOfBytes}; + use reqwest::Response; use tokio::task::JoinHandle; use torrust_tracker::api::resources::auth_key_resource::AuthKeyResource; + use torrust_tracker::api::resources::stats_resource::StatsResource; + use torrust_tracker::api::resources::torrent_resource::{TorrentListItemResource, TorrentPeerResource, TorrentResource}; use torrust_tracker::jobs::tracker_api; + use torrust_tracker::peer::TorrentPeer; + use torrust_tracker::protocol::clock::DurationSinceUnixEpoch; use torrust_tracker::tracker::key::AuthKey; use torrust_tracker::tracker::statistics::StatsTracker; use torrust_tracker::tracker::TorrentTracker; - use torrust_tracker::{ephemeral_instance_keys, logging, static_time, Configuration, InfoHash}; + use torrust_tracker::{ephemeral_instance_keys, logging, static_time, Configuration, InfoHash, PeerId}; use crate::common::ephemeral_random_port; #[tokio::test] async fn should_allow_generating_a_new_auth_key() { - let configuration = tracker_configuration(); - let api_server = new_running_api_server(configuration.clone()).await; + let api_server = ApiServer::new_running_instance().await; - let bind_address = api_server.bind_address.unwrap().clone(); let seconds_valid = 60; - let api_token = configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(); - let url = format!("http://{}/api/key/{}?token={}", &bind_address, &seconds_valid, &api_token); - - let auth_key: AuthKeyResource = reqwest::Client::new().post(url).send().await.unwrap().json().await.unwrap(); + let auth_key = ApiClient::new(api_server.get_connection_info().unwrap()) + .generate_auth_key(seconds_valid) + .await; // Verify the key with the tracker assert!(api_server @@ -46,16 +50,13 @@ mod tracker_api { #[tokio::test] async fn should_allow_whitelisting_a_torrent() { - let configuration = tracker_configuration(); - let api_server = new_running_api_server(configuration.clone()).await; + let api_server = ApiServer::new_running_instance().await; - let bind_address = api_server.bind_address.unwrap().clone(); - let api_token = configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(); let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let url = format!("http://{}/api/whitelist/{}?token={}", &bind_address, &info_hash, &api_token); - - let res = reqwest::Client::new().post(url.clone()).send().await.unwrap(); + let res = ApiClient::new(api_server.get_connection_info().unwrap()) + .whitelist_a_torrent(&info_hash) + .await; assert_eq!(res.status(), 200); assert!( @@ -69,24 +70,137 @@ mod tracker_api { #[tokio::test] async fn should_allow_whitelisting_a_torrent_that_has_been_already_whitelisted() { - let configuration = tracker_configuration(); - let api_server = new_running_api_server(configuration.clone()).await; + let api_server = ApiServer::new_running_instance().await; - let bind_address = api_server.bind_address.unwrap().clone(); - let api_token = configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(); let info_hash = "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_owned(); - let url = format!("http://{}/api/whitelist/{}?token={}", &bind_address, &info_hash, &api_token); + let api_client = ApiClient::new(api_server.get_connection_info().unwrap()); - // First whitelist request - let res = reqwest::Client::new().post(url.clone()).send().await.unwrap(); + let res = api_client.whitelist_a_torrent(&info_hash).await; assert_eq!(res.status(), 200); - // Second whitelist request - let res = reqwest::Client::new().post(url.clone()).send().await.unwrap(); + let res = api_client.whitelist_a_torrent(&info_hash).await; assert_eq!(res.status(), 200); } + #[tokio::test] + async fn should_allow_getting_a_torrent_info() { + let api_server = ApiServer::new_running_instance().await; + let api_connection_info = api_server.get_connection_info().unwrap(); + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + let (peer, peer_resource) = sample_torrent_peer(); + + // Add a torrent to the tracker + api_server + .tracker + .unwrap() + .update_torrent_with_peer_and_get_stats(&info_hash, &peer) + .await; + + let torrent_resource = ApiClient::new(api_connection_info).get_torrent(&info_hash.to_string()).await; + + assert_eq!( + torrent_resource, + TorrentResource { + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: Some(vec![peer_resource]) + } + ); + } + + #[tokio::test] + async fn should_allow_getting_torrents() { + let api_server = ApiServer::new_running_instance().await; + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + let (peer, _peer_resource) = sample_torrent_peer(); + + let api_connection_info = api_server.get_connection_info().unwrap(); + + // Add a torrent to the tracker + api_server + .tracker + .unwrap() + .update_torrent_with_peer_and_get_stats(&info_hash, &peer) + .await; + + let torrent_resources = ApiClient::new(api_connection_info).get_torrents().await; + + assert_eq!( + torrent_resources, + vec![TorrentListItemResource { + info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), + seeders: 1, + completed: 0, + leechers: 0, + peers: None // Torrent list does not include peer list + }] + ); + } + + #[tokio::test] + async fn should_allow_getting_tracker_statistics() { + let api_server = ApiServer::new_running_instance().await; + + let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); + + let (peer, _peer_resource) = sample_torrent_peer(); + + let api_connection_info = api_server.get_connection_info().unwrap(); + + // Add a torrent to the tracker + api_server + .tracker + .unwrap() + .update_torrent_with_peer_and_get_stats(&info_hash, &peer) + .await; + + let stats_resource = ApiClient::new(api_connection_info).get_tracker_statistics().await; + + assert_eq!( + stats_resource, + StatsResource { + torrents: 1, + seeders: 1, + completed: 0, + leechers: 0, + tcp4_connections_handled: 0, + tcp4_announces_handled: 0, + tcp4_scrapes_handled: 0, + tcp6_connections_handled: 0, + tcp6_announces_handled: 0, + tcp6_scrapes_handled: 0, + udp4_connections_handled: 0, + udp4_announces_handled: 0, + udp4_scrapes_handled: 0, + udp6_connections_handled: 0, + udp6_announces_handled: 0, + udp6_scrapes_handled: 0, + } + ); + } + + fn sample_torrent_peer() -> (TorrentPeer, TorrentPeerResource) { + let torrent_peer = TorrentPeer { + peer_id: PeerId(*b"-qB00000000000000000"), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + updated: DurationSinceUnixEpoch::new(1669397478934, 0), + uploaded: NumberOfBytes(0), + downloaded: NumberOfBytes(0), + left: NumberOfBytes(0), + event: AnnounceEvent::Started, + }; + let torrent_peer_resource = TorrentPeerResource::from(torrent_peer); + + (torrent_peer, torrent_peer_resource) + } + fn tracker_configuration() -> Arc { let mut config = Configuration::default(); config.log_level = Some("off".to_owned()); @@ -103,17 +217,26 @@ mod tracker_api { Arc::new(config) } - async fn new_running_api_server(configuration: Arc) -> ApiServer { - let mut api_server = ApiServer::new(); - api_server.start(configuration).await; - api_server + #[derive(Clone)] + struct ApiConnectionInfo { + pub bind_address: String, + pub api_token: String, } - pub struct ApiServer { + impl ApiConnectionInfo { + pub fn new(bind_address: &str, api_token: &str) -> Self { + Self { + bind_address: bind_address.to_string(), + api_token: api_token.to_string(), + } + } + } + + struct ApiServer { pub started: AtomicBool, pub job: Option>, - pub bind_address: Option, pub tracker: Option>, + pub connection_info: Option, } impl ApiServer { @@ -121,14 +244,28 @@ mod tracker_api { Self { started: AtomicBool::new(false), job: None, - bind_address: None, tracker: None, + connection_info: None, } } + pub async fn new_running_instance() -> ApiServer { + let configuration = tracker_configuration(); + ApiServer::new_running_custom_instance(configuration.clone()).await + } + + async fn new_running_custom_instance(configuration: Arc) -> ApiServer { + let mut api_server = ApiServer::new(); + api_server.start(configuration).await; + api_server + } + pub async fn start(&mut self, configuration: Arc) { if !self.started.load(Ordering::Relaxed) { - self.bind_address = Some(configuration.http_api.bind_address.clone()); + self.connection_info = Some(ApiConnectionInfo::new( + &configuration.http_api.bind_address.clone(), + &configuration.http_api.access_tokens.get_key_value("admin").unwrap().1.clone(), + )); // Set the time of Torrust app starting lazy_static::initialize(&static_time::TIME_AT_APP_START); @@ -157,5 +294,86 @@ mod tracker_api { self.started.store(true, Ordering::Relaxed); } } + + pub fn get_connection_info(&self) -> Option { + self.connection_info.clone() + } + } + + struct ApiClient { + connection_info: ApiConnectionInfo, + } + + impl ApiClient { + pub fn new(connection_info: ApiConnectionInfo) -> Self { + Self { connection_info } + } + + pub async fn generate_auth_key(&self, seconds_valid: i32) -> AuthKeyResource { + let url = format!( + "http://{}/api/key/{}?token={}", + &self.connection_info.bind_address, &seconds_valid, &self.connection_info.api_token + ); + reqwest::Client::new().post(url).send().await.unwrap().json().await.unwrap() + } + + pub async fn whitelist_a_torrent(&self, info_hash: &str) -> Response { + let url = format!( + "http://{}/api/whitelist/{}?token={}", + &self.connection_info.bind_address, &info_hash, &self.connection_info.api_token + ); + reqwest::Client::new().post(url.clone()).send().await.unwrap() + } + + pub async fn get_torrent(&self, info_hash: &str) -> TorrentResource { + let url = format!( + "http://{}/api/torrent/{}?token={}", + &self.connection_info.bind_address, &info_hash, &self.connection_info.api_token + ); + reqwest::Client::builder() + .build() + .unwrap() + .get(url) + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + } + + pub async fn get_torrents(&self) -> Vec { + let url = format!( + "http://{}/api/torrents?token={}", + &self.connection_info.bind_address, &self.connection_info.api_token + ); + reqwest::Client::builder() + .build() + .unwrap() + .get(url) + .send() + .await + .unwrap() + .json::>() + .await + .unwrap() + } + + pub async fn get_tracker_statistics(&self) -> StatsResource { + let url = format!( + "http://{}/api/stats?token={}", + &self.connection_info.bind_address, &self.connection_info.api_token + ); + reqwest::Client::builder() + .build() + .unwrap() + .get(url) + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + } } }