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 41084127c..2c8e63cd0 100644 --- a/src/bin/udp_tracker_client.rs +++ b/src/bin/udp_tracker_client.rs @@ -1,58 +1,187 @@ -use std::env; -use std::net::{Ipv4Addr, SocketAddr}; +//! UDP Tracker client: +//! +//! Examples: +//! +//! Announce request: +//! +//! ```text +//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! Announce response: +//! +//! ```json +//! { +//! "transaction_id": -888840697 +//! "announce_interval": 120, +//! "leechers": 0, +//! "seeders": 1, +//! "peers": [ +//! "123.123.123.123:51289" +//! ], +//! } +//! ``` +//! +//! Scrape request: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! Scrape response: +//! +//! ```json +//! { +//! "transaction_id": -888840697, +//! "torrent_stats": [ +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! }, +//! { +//! "completed": 0, +//! "leechers": 0, +//! "seeders": 0 +//! } +//! ] +//! } +//! ``` +//! +//! You can use an URL with instead of the socket address. For example: +//! +//! ```text +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq +//! ``` +//! +//! The protocol (`udp://`) in the URL is mandatory. The path (`\scrape`) is optional. It always uses `\scrape`. +use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs}; use std::str::FromStr; +use anyhow::Context; use aquatic_udp_protocol::common::InfoHash; +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; +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + Announce { + #[arg(value_parser = parse_socket_addr)] + tracker_socket_addr: SocketAddr, + #[arg(value_parser = parse_info_hash)] + info_hash: TorrustInfoHash, + }, + Scrape { + #[arg(value_parser = parse_socket_addr)] + tracker_socket_addr: SocketAddr, + #[arg(value_parser = parse_info_hash, num_args = 1..=74, value_delimiter = ' ')] + info_hashes: Vec, + }, +} + #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { setup_logging(LevelFilter::Info); - let (remote_socket_addr, info_hash) = parse_arguments(); + let args = Args::parse(); // 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}"); - // Connect to remote socket - - debug!("Connecting to remote: udp://{remote_socket_addr}"); - udp_client.connect(&remote_socket_addr).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 response = match args.command { + Command::Announce { + tracker_socket_addr, + info_hash, + } => { + let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await; + + send_announce_request( + connection_id, + transaction_id, + info_hash, + Port(bound_to.port()), + &udp_tracker_client, + ) + .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 + } + }; - let response = send_announce_request( - connection_id, - transaction_id, - info_hash, - Port(bound_to.port()), - &udp_tracker_client, - ) - .await; + match response { + AnnounceIpv4(announce) => { + let json = json!({ + "transaction_id": announce.transaction_id.0, + "announce_interval": announce.announce_interval.0, + "leechers": announce.leechers.0, + "seeders": announce.seeders.0, + "peers": announce.peers.iter().map(|peer| format!("{}:{}", peer.ip_address, peer.port.0)).collect::>(), + }); + let pretty_json = serde_json::to_string_pretty(&json).unwrap(); + println!("{pretty_json}"); + } + AnnounceIpv6(announce) => { + let json = json!({ + "transaction_id": announce.transaction_id.0, + "announce_interval": announce.announce_interval.0, + "leechers": announce.leechers.0, + "seeders": announce.seeders.0, + "peers6": announce.peers.iter().map(|peer| format!("{}:{}", peer.ip_address, peer.port.0)).collect::>(), + }); + let pretty_json = serde_json::to_string_pretty(&json).unwrap(); + println!("{pretty_json}"); + } + 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. + } - println!("{response:#?}"); + Ok(()) } fn setup_logging(level: LevelFilter) { @@ -76,31 +205,76 @@ fn setup_logging(level: LevelFilter) { debug!("logging initialized."); } -fn parse_arguments() -> (String, TorrustInfoHash) { - let args: Vec = env::args().collect(); +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:#?}"); - if args.len() != 3 { - eprintln!("Error: invalid number of arguments!"); - eprintln!("Usage: cargo run --bin udp_tracker_client "); - eprintln!("Example: cargo run --bin udp_tracker_client 144.126.245.19:6969 9c38422213e30bff212b30c360d26f9a02136422"); - std::process::exit(1); + // 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]) } +} - let remote_socket_addr = &args[1]; - let _valid_socket_addr = remote_socket_addr.parse::().unwrap_or_else(|_| { - panic!( - "Invalid argument: `{}`. Argument 1 should be a valid socket address. For example: `144.126.245.19:6969`.", - args[1] - ) - }); - let info_hash = TorrustInfoHash::from_str(&args[2]).unwrap_or_else(|_| { - panic!( - "Invalid argument: `{}`. Argument 2 should be a valid infohash. For example: `9c38422213e30bff212b30c360d26f9a02136422`.", - args[2] - ) - }); - - (remote_socket_addr.to_string(), info_hash) +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:?}"))) +} + +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 { @@ -152,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 +}