Skip to content

Commit 75a502f

Browse files
committed
Merge #655: UDP Tracker Client: add scrape request
f4e9bda feat: [#654] UDP tracker client: scrape (Jose Celano) 1b34d93 refactor: [#654] UDP tracker client: use clap and anyhow (Jose Celano) Pull request description: Command: ```console $ cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq Finished dev [optimized + debuginfo] target(s) in 0.07s Running `target/debug/udp_tracker_client scrape 'udp://localhost:6969/scrape' 9c38422213e30bff212b30c360d26f9a02136422` ``` Output: ```json { "torrent_stats": [ { "completed": 0, "leechers": 0, "seeders": 0 } ], "transaction_id": -888840697 } ``` ### Subtasks - [x] Print JSON instead of debug struct. - [x] Use `anyhow` and `clap`. See [HTTP Tracker client](https://github.com/torrust/torrust-tracker/blob/develop/src/bin/http_tracker_client.rs). - [x] `scrape` command. - [x] Allow using a domain. Currently, it only allows using an IP. ACKs for top commit: josecelano: ACK f4e9bda Tree-SHA512: b3fef279d1d258af67023637de1e50741ff301aeb6b87707304b35b5a6995e8244d03e169bfa5e3923ed7d2a5371cd5d8dcc8f74294e7dca1951676eca8ff5eb
2 parents c526cc1 + f4e9bda commit 75a502f

File tree

2 files changed

+249
-48
lines changed

2 files changed

+249
-48
lines changed

cSpell.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"words": [
3+
"Addrs",
34
"adduser",
45
"alekitto",
56
"appuser",

src/bin/udp_tracker_client.rs

+248-48
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,187 @@
1-
use std::env;
2-
use std::net::{Ipv4Addr, SocketAddr};
1+
//! UDP Tracker client:
2+
//!
3+
//! Examples:
4+
//!
5+
//! Announce request:
6+
//!
7+
//! ```text
8+
//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq
9+
//! ```
10+
//!
11+
//! Announce response:
12+
//!
13+
//! ```json
14+
//! {
15+
//! "transaction_id": -888840697
16+
//! "announce_interval": 120,
17+
//! "leechers": 0,
18+
//! "seeders": 1,
19+
//! "peers": [
20+
//! "123.123.123.123:51289"
21+
//! ],
22+
//! }
23+
//! ```
24+
//!
25+
//! Scrape request:
26+
//!
27+
//! ```text
28+
//! cargo run --bin udp_tracker_client scrape 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq
29+
//! ```
30+
//!
31+
//! Scrape response:
32+
//!
33+
//! ```json
34+
//! {
35+
//! "transaction_id": -888840697,
36+
//! "torrent_stats": [
37+
//! {
38+
//! "completed": 0,
39+
//! "leechers": 0,
40+
//! "seeders": 0
41+
//! },
42+
//! {
43+
//! "completed": 0,
44+
//! "leechers": 0,
45+
//! "seeders": 0
46+
//! }
47+
//! ]
48+
//! }
49+
//! ```
50+
//!
51+
//! You can use an URL with instead of the socket address. For example:
52+
//!
53+
//! ```text
54+
//! cargo run --bin udp_tracker_client scrape udp://localhost:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq
55+
//! cargo run --bin udp_tracker_client scrape udp://localhost:6969/scrape 9c38422213e30bff212b30c360d26f9a02136422 | jq
56+
//! ```
57+
//!
58+
//! The protocol (`udp://`) in the URL is mandatory. The path (`\scrape`) is optional. It always uses `\scrape`.
59+
use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
360
use std::str::FromStr;
461

62+
use anyhow::Context;
563
use aquatic_udp_protocol::common::InfoHash;
64+
use aquatic_udp_protocol::Response::{AnnounceIpv4, AnnounceIpv6, Scrape};
665
use aquatic_udp_protocol::{
766
AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, Response,
8-
TransactionId,
67+
ScrapeRequest, TransactionId,
968
};
69+
use clap::{Parser, Subcommand};
1070
use log::{debug, LevelFilter};
71+
use serde_json::json;
1172
use torrust_tracker::shared::bit_torrent::info_hash::InfoHash as TorrustInfoHash;
1273
use torrust_tracker::shared::bit_torrent::tracker::udp::client::{UdpClient, UdpTrackerClient};
74+
use url::Url;
1375

1476
const ASSIGNED_BY_OS: i32 = 0;
1577
const RANDOM_TRANSACTION_ID: i32 = -888_840_697;
1678

79+
#[derive(Parser, Debug)]
80+
#[command(author, version, about, long_about = None)]
81+
struct Args {
82+
#[command(subcommand)]
83+
command: Command,
84+
}
85+
86+
#[derive(Subcommand, Debug)]
87+
enum Command {
88+
Announce {
89+
#[arg(value_parser = parse_socket_addr)]
90+
tracker_socket_addr: SocketAddr,
91+
#[arg(value_parser = parse_info_hash)]
92+
info_hash: TorrustInfoHash,
93+
},
94+
Scrape {
95+
#[arg(value_parser = parse_socket_addr)]
96+
tracker_socket_addr: SocketAddr,
97+
#[arg(value_parser = parse_info_hash, num_args = 1..=74, value_delimiter = ' ')]
98+
info_hashes: Vec<TorrustInfoHash>,
99+
},
100+
}
101+
17102
#[tokio::main]
18-
async fn main() {
103+
async fn main() -> anyhow::Result<()> {
19104
setup_logging(LevelFilter::Info);
20105

21-
let (remote_socket_addr, info_hash) = parse_arguments();
106+
let args = Args::parse();
22107

23108
// Configuration
24109
let local_port = ASSIGNED_BY_OS;
110+
let local_bind_to = format!("0.0.0.0:{local_port}");
25111
let transaction_id = RANDOM_TRANSACTION_ID;
26-
let bind_to = format!("0.0.0.0:{local_port}");
27112

28113
// Bind to local port
29-
30-
debug!("Binding to: {bind_to}");
31-
let udp_client = UdpClient::bind(&bind_to).await;
114+
debug!("Binding to: {local_bind_to}");
115+
let udp_client = UdpClient::bind(&local_bind_to).await;
32116
let bound_to = udp_client.socket.local_addr().unwrap();
33117
debug!("Bound to: {bound_to}");
34118

35-
// Connect to remote socket
36-
37-
debug!("Connecting to remote: udp://{remote_socket_addr}");
38-
udp_client.connect(&remote_socket_addr).await;
39-
40-
let udp_tracker_client = UdpTrackerClient { udp_client };
41-
42119
let transaction_id = TransactionId(transaction_id);
43120

44-
let connection_id = send_connection_request(transaction_id, &udp_tracker_client).await;
121+
let response = match args.command {
122+
Command::Announce {
123+
tracker_socket_addr,
124+
info_hash,
125+
} => {
126+
let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await;
127+
128+
send_announce_request(
129+
connection_id,
130+
transaction_id,
131+
info_hash,
132+
Port(bound_to.port()),
133+
&udp_tracker_client,
134+
)
135+
.await
136+
}
137+
Command::Scrape {
138+
tracker_socket_addr,
139+
info_hashes,
140+
} => {
141+
let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await;
142+
send_scrape_request(connection_id, transaction_id, info_hashes, &udp_tracker_client).await
143+
}
144+
};
45145

46-
let response = send_announce_request(
47-
connection_id,
48-
transaction_id,
49-
info_hash,
50-
Port(bound_to.port()),
51-
&udp_tracker_client,
52-
)
53-
.await;
146+
match response {
147+
AnnounceIpv4(announce) => {
148+
let json = json!({
149+
"transaction_id": announce.transaction_id.0,
150+
"announce_interval": announce.announce_interval.0,
151+
"leechers": announce.leechers.0,
152+
"seeders": announce.seeders.0,
153+
"peers": announce.peers.iter().map(|peer| format!("{}:{}", peer.ip_address, peer.port.0)).collect::<Vec<_>>(),
154+
});
155+
let pretty_json = serde_json::to_string_pretty(&json).unwrap();
156+
println!("{pretty_json}");
157+
}
158+
AnnounceIpv6(announce) => {
159+
let json = json!({
160+
"transaction_id": announce.transaction_id.0,
161+
"announce_interval": announce.announce_interval.0,
162+
"leechers": announce.leechers.0,
163+
"seeders": announce.seeders.0,
164+
"peers6": announce.peers.iter().map(|peer| format!("{}:{}", peer.ip_address, peer.port.0)).collect::<Vec<_>>(),
165+
});
166+
let pretty_json = serde_json::to_string_pretty(&json).unwrap();
167+
println!("{pretty_json}");
168+
}
169+
Scrape(scrape) => {
170+
let json = json!({
171+
"transaction_id": scrape.transaction_id.0,
172+
"torrent_stats": scrape.torrent_stats.iter().map(|torrent_scrape_statistics| json!({
173+
"seeders": torrent_scrape_statistics.seeders.0,
174+
"completed": torrent_scrape_statistics.completed.0,
175+
"leechers": torrent_scrape_statistics.leechers.0,
176+
})).collect::<Vec<_>>(),
177+
});
178+
let pretty_json = serde_json::to_string_pretty(&json).unwrap();
179+
println!("{pretty_json}");
180+
}
181+
_ => println!("{response:#?}"), // todo: serialize to JSON all responses.
182+
}
54183

55-
println!("{response:#?}");
184+
Ok(())
56185
}
57186

58187
fn setup_logging(level: LevelFilter) {
@@ -76,31 +205,76 @@ fn setup_logging(level: LevelFilter) {
76205
debug!("logging initialized.");
77206
}
78207

79-
fn parse_arguments() -> (String, TorrustInfoHash) {
80-
let args: Vec<String> = env::args().collect();
208+
fn parse_socket_addr(tracker_socket_addr_str: &str) -> anyhow::Result<SocketAddr> {
209+
debug!("Tracker socket address: {tracker_socket_addr_str:#?}");
210+
211+
// Check if the address is a valid URL. If so, extract the host and port.
212+
let resolved_addr = if let Ok(url) = Url::parse(tracker_socket_addr_str) {
213+
debug!("Tracker socket address URL: {url:?}");
214+
215+
let host = url
216+
.host_str()
217+
.with_context(|| format!("invalid host in URL: `{tracker_socket_addr_str}`"))?
218+
.to_owned();
219+
220+
let port = url
221+
.port()
222+
.with_context(|| format!("port not found in URL: `{tracker_socket_addr_str}`"))?
223+
.to_owned();
224+
225+
(host, port)
226+
} else {
227+
// If not a URL, assume it's a host:port pair.
228+
229+
let parts: Vec<&str> = tracker_socket_addr_str.split(':').collect();
230+
231+
if parts.len() != 2 {
232+
return Err(anyhow::anyhow!(
233+
"invalid address format: `{}`. Expected format is host:port",
234+
tracker_socket_addr_str
235+
));
236+
}
237+
238+
let host = parts[0].to_owned();
239+
240+
let port = parts[1]
241+
.parse::<u16>()
242+
.with_context(|| format!("invalid port: `{}`", parts[1]))?
243+
.to_owned();
244+
245+
(host, port)
246+
};
247+
248+
debug!("Resolved address: {resolved_addr:#?}");
81249

82-
if args.len() != 3 {
83-
eprintln!("Error: invalid number of arguments!");
84-
eprintln!("Usage: cargo run --bin udp_tracker_client <UDP_TRACKER_SOCKET_ADDRESS> <INFO_HASH>");
85-
eprintln!("Example: cargo run --bin udp_tracker_client 144.126.245.19:6969 9c38422213e30bff212b30c360d26f9a02136422");
86-
std::process::exit(1);
250+
// Perform DNS resolution.
251+
let socket_addrs: Vec<_> = resolved_addr.to_socket_addrs()?.collect();
252+
if socket_addrs.is_empty() {
253+
Err(anyhow::anyhow!("DNS resolution failed for `{}`", tracker_socket_addr_str))
254+
} else {
255+
Ok(socket_addrs[0])
87256
}
257+
}
88258

89-
let remote_socket_addr = &args[1];
90-
let _valid_socket_addr = remote_socket_addr.parse::<SocketAddr>().unwrap_or_else(|_| {
91-
panic!(
92-
"Invalid argument: `{}`. Argument 1 should be a valid socket address. For example: `144.126.245.19:6969`.",
93-
args[1]
94-
)
95-
});
96-
let info_hash = TorrustInfoHash::from_str(&args[2]).unwrap_or_else(|_| {
97-
panic!(
98-
"Invalid argument: `{}`. Argument 2 should be a valid infohash. For example: `9c38422213e30bff212b30c360d26f9a02136422`.",
99-
args[2]
100-
)
101-
});
102-
103-
(remote_socket_addr.to_string(), info_hash)
259+
fn parse_info_hash(info_hash_str: &str) -> anyhow::Result<TorrustInfoHash> {
260+
TorrustInfoHash::from_str(info_hash_str)
261+
.map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{info_hash_str}`: {e:?}")))
262+
}
263+
264+
async fn connect(
265+
tracker_socket_addr: &SocketAddr,
266+
udp_client: UdpClient,
267+
transaction_id: TransactionId,
268+
) -> (ConnectionId, UdpTrackerClient) {
269+
debug!("Connecting to tracker: udp://{tracker_socket_addr}");
270+
271+
udp_client.connect(&tracker_socket_addr.to_string()).await;
272+
273+
let udp_tracker_client = UdpTrackerClient { udp_client };
274+
275+
let connection_id = send_connection_request(transaction_id, &udp_tracker_client).await;
276+
277+
(connection_id, udp_tracker_client)
104278
}
105279

106280
async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrackerClient) -> ConnectionId {
@@ -152,3 +326,29 @@ async fn send_announce_request(
152326

153327
response
154328
}
329+
330+
async fn send_scrape_request(
331+
connection_id: ConnectionId,
332+
transaction_id: TransactionId,
333+
info_hashes: Vec<TorrustInfoHash>,
334+
client: &UdpTrackerClient,
335+
) -> Response {
336+
debug!("Sending scrape request with transaction id: {transaction_id:#?}");
337+
338+
let scrape_request = ScrapeRequest {
339+
connection_id,
340+
transaction_id,
341+
info_hashes: info_hashes
342+
.iter()
343+
.map(|torrust_info_hash| InfoHash(torrust_info_hash.bytes()))
344+
.collect(),
345+
};
346+
347+
client.send(scrape_request.into()).await;
348+
349+
let response = client.receive().await;
350+
351+
debug!("scrape request response:\n{response:#?}");
352+
353+
response
354+
}

0 commit comments

Comments
 (0)