From f4e9bdad94c0c42849cbfa0201cf4c7ab578628c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 29 Jan 2024 16:03:33 +0000 Subject: [PATCH] feat: [#654] UDP tracker client: scrape ```text cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq ``` Scrape response: ```json { "transaction_id": -888840697, "torrent_stats": [ { "completed": 0, "leechers": 0, "seeders": 0 }, { "completed": 0, "leechers": 0, "seeders": 0 } ] } ``` --- cSpell.json | 1 + src/bin/udp_tracker_client.rs | 189 ++++++++++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/cSpell.json b/cSpell.json index 0a3f78fad..aaa3229c2 100644 --- a/cSpell.json +++ b/cSpell.json @@ -1,5 +1,6 @@ { "words": [ + "Addrs", "adduser", "alekitto", "appuser", diff --git a/src/bin/udp_tracker_client.rs b/src/bin/udp_tracker_client.rs index 8d30ee0d4..2c8e63cd0 100644 --- a/src/bin/udp_tracker_client.rs +++ b/src/bin/udp_tracker_client.rs @@ -5,7 +5,7 @@ //! Announce request: //! //! ```text -//! cargo run --bin udp_tracker_client 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq //! ``` //! //! Announce response: @@ -20,22 +20,58 @@ //! "123.123.123.123:51289" //! ], //! } -/// ```` -use std::net::{Ipv4Addr, SocketAddr}; +//! ``` +//! +//! Scrape request: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! Scrape response: +//! +//! ```json +//! { +//! "transaction_id": -888840697, +//! "torrent_stats": [ +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! }, +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! } +//! ] +//! } +//! ``` +//! +//! You can use an URL with instead of the socket address. For example: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! The protocol (`udp://`) in the URL is mandatory. The path (`\scrape`) is optional. It always uses `\scrape`. +use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs}; use std::str::FromStr; use anyhow::Context; use aquatic_udp_protocol::common::InfoHash; -use aquatic_udp_protocol::Response::{AnnounceIpv4, AnnounceIpv6}; +use aquatic_udp_protocol::Response::{AnnounceIpv4, AnnounceIpv6, Scrape}; use aquatic_udp_protocol::{ AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, Response, - TransactionId, + ScrapeRequest, TransactionId, }; use clap::{Parser, Subcommand}; use log::{debug, LevelFilter}; use serde_json::json; use torrust_tracker::shared::bit_torrent::info_hash::InfoHash as TorrustInfoHash; use torrust_tracker::shared::bit_torrent::tracker::udp::client::{UdpClient, UdpTrackerClient}; +use url::Url; const ASSIGNED_BY_OS: i32 = 0; const RANDOM_TRANSACTION_ID: i32 = -888_840_697; @@ -55,6 +91,12 @@ enum Command { #[arg(value_parser = parse_info_hash)] info_hash: TorrustInfoHash, }, + Scrape { + #[arg(value_parser = parse_socket_addr)] + tracker_socket_addr: SocketAddr, + #[arg(value_parser = parse_info_hash, num_args = 1..=74, value_delimiter = ' ')] + info_hashes: Vec, + }, } #[tokio::main] @@ -65,29 +107,23 @@ async fn main() -> anyhow::Result<()> { // Configuration let local_port = ASSIGNED_BY_OS; + let local_bind_to = format!("0.0.0.0:{local_port}"); let transaction_id = RANDOM_TRANSACTION_ID; - let bind_to = format!("0.0.0.0:{local_port}"); // Bind to local port - debug!("Binding to: {bind_to}"); - let udp_client = UdpClient::bind(&bind_to).await; + debug!("Binding to: {local_bind_to}"); + let udp_client = UdpClient::bind(&local_bind_to).await; let bound_to = udp_client.socket.local_addr().unwrap(); debug!("Bound to: {bound_to}"); + let transaction_id = TransactionId(transaction_id); + let response = match args.command { Command::Announce { tracker_socket_addr, info_hash, } => { - debug!("Connecting to remote: udp://{tracker_socket_addr}"); - - udp_client.connect(&tracker_socket_addr.to_string()).await; - - let udp_tracker_client = UdpTrackerClient { udp_client }; - - let transaction_id = TransactionId(transaction_id); - - let connection_id = send_connection_request(transaction_id, &udp_tracker_client).await; + let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await; send_announce_request( connection_id, @@ -98,6 +134,13 @@ async fn main() -> anyhow::Result<()> { ) .await } + Command::Scrape { + tracker_socket_addr, + info_hashes, + } => { + let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await; + send_scrape_request(connection_id, transaction_id, info_hashes, &udp_tracker_client).await + } }; match response { @@ -123,7 +166,19 @@ async fn main() -> anyhow::Result<()> { let pretty_json = serde_json::to_string_pretty(&json).unwrap(); println!("{pretty_json}"); } - _ => println!("{response:#?}"), + Scrape(scrape) => { + let json = json!({ + "transaction_id": scrape.transaction_id.0, + "torrent_stats": scrape.torrent_stats.iter().map(|torrent_scrape_statistics| json!({ + "seeders": torrent_scrape_statistics.seeders.0, + "completed": torrent_scrape_statistics.completed.0, + "leechers": torrent_scrape_statistics.leechers.0, + })).collect::>(), + }); + let pretty_json = serde_json::to_string_pretty(&json).unwrap(); + println!("{pretty_json}"); + } + _ => println!("{response:#?}"), // todo: serialize to JSON all responses. } Ok(()) @@ -150,12 +205,76 @@ fn setup_logging(level: LevelFilter) { debug!("logging initialized."); } -fn parse_socket_addr(s: &str) -> anyhow::Result { - s.parse().with_context(|| format!("failed to parse socket address: `{s}`")) +fn parse_socket_addr(tracker_socket_addr_str: &str) -> anyhow::Result { + debug!("Tracker socket address: {tracker_socket_addr_str:#?}"); + + // Check if the address is a valid URL. If so, extract the host and port. + let resolved_addr = if let Ok(url) = Url::parse(tracker_socket_addr_str) { + debug!("Tracker socket address URL: {url:?}"); + + let host = url + .host_str() + .with_context(|| format!("invalid host in URL: `{tracker_socket_addr_str}`"))? + .to_owned(); + + let port = url + .port() + .with_context(|| format!("port not found in URL: `{tracker_socket_addr_str}`"))? + .to_owned(); + + (host, port) + } else { + // If not a URL, assume it's a host:port pair. + + let parts: Vec<&str> = tracker_socket_addr_str.split(':').collect(); + + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "invalid address format: `{}`. Expected format is host:port", + tracker_socket_addr_str + )); + } + + let host = parts[0].to_owned(); + + let port = parts[1] + .parse::() + .with_context(|| format!("invalid port: `{}`", parts[1]))? + .to_owned(); + + (host, port) + }; + + debug!("Resolved address: {resolved_addr:#?}"); + + // Perform DNS resolution. + let socket_addrs: Vec<_> = resolved_addr.to_socket_addrs()?.collect(); + if socket_addrs.is_empty() { + Err(anyhow::anyhow!("DNS resolution failed for `{}`", tracker_socket_addr_str)) + } else { + Ok(socket_addrs[0]) + } +} + +fn parse_info_hash(info_hash_str: &str) -> anyhow::Result { + TorrustInfoHash::from_str(info_hash_str) + .map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{info_hash_str}`: {e:?}"))) } -fn parse_info_hash(s: &str) -> anyhow::Result { - TorrustInfoHash::from_str(s).map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{s}`: {e:?}"))) +async fn connect( + tracker_socket_addr: &SocketAddr, + udp_client: UdpClient, + transaction_id: TransactionId, +) -> (ConnectionId, UdpTrackerClient) { + debug!("Connecting to tracker: udp://{tracker_socket_addr}"); + + udp_client.connect(&tracker_socket_addr.to_string()).await; + + let udp_tracker_client = UdpTrackerClient { udp_client }; + + let connection_id = send_connection_request(transaction_id, &udp_tracker_client).await; + + (connection_id, udp_tracker_client) } async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrackerClient) -> ConnectionId { @@ -207,3 +326,29 @@ async fn send_announce_request( response } + +async fn send_scrape_request( + connection_id: ConnectionId, + transaction_id: TransactionId, + info_hashes: Vec, + client: &UdpTrackerClient, +) -> Response { + debug!("Sending scrape request with transaction id: {transaction_id:#?}"); + + let scrape_request = ScrapeRequest { + connection_id, + transaction_id, + info_hashes: info_hashes + .iter() + .map(|torrust_info_hash| InfoHash(torrust_info_hash.bytes())) + .collect(), + }; + + client.send(scrape_request.into()).await; + + let response = client.receive().await; + + debug!("scrape request response:\n{response:#?}"); + + response +}