Skip to content

Commit f4e9bda

Browse files
committed
feat: [torrust#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 } ] } ```
1 parent 1b34d93 commit f4e9bda

File tree

2 files changed

+168
-22
lines changed

2 files changed

+168
-22
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

+167-22
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! Announce request:
66
//!
77
//! ```text
8-
//! cargo run --bin udp_tracker_client 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq
8+
//! cargo run --bin udp_tracker_client announce 127.0.0.1:6969 9c38422213e30bff212b30c360d26f9a02136422 | jq
99
//! ```
1010
//!
1111
//! Announce response:
@@ -20,22 +20,58 @@
2020
//! "123.123.123.123:51289"
2121
//! ],
2222
//! }
23-
/// ````
24-
use std::net::{Ipv4Addr, SocketAddr};
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};
2560
use std::str::FromStr;
2661

2762
use anyhow::Context;
2863
use aquatic_udp_protocol::common::InfoHash;
29-
use aquatic_udp_protocol::Response::{AnnounceIpv4, AnnounceIpv6};
64+
use aquatic_udp_protocol::Response::{AnnounceIpv4, AnnounceIpv6, Scrape};
3065
use aquatic_udp_protocol::{
3166
AnnounceEvent, AnnounceRequest, ConnectRequest, ConnectionId, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, Response,
32-
TransactionId,
67+
ScrapeRequest, TransactionId,
3368
};
3469
use clap::{Parser, Subcommand};
3570
use log::{debug, LevelFilter};
3671
use serde_json::json;
3772
use torrust_tracker::shared::bit_torrent::info_hash::InfoHash as TorrustInfoHash;
3873
use torrust_tracker::shared::bit_torrent::tracker::udp::client::{UdpClient, UdpTrackerClient};
74+
use url::Url;
3975

4076
const ASSIGNED_BY_OS: i32 = 0;
4177
const RANDOM_TRANSACTION_ID: i32 = -888_840_697;
@@ -55,6 +91,12 @@ enum Command {
5591
#[arg(value_parser = parse_info_hash)]
5692
info_hash: TorrustInfoHash,
5793
},
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+
},
58100
}
59101

60102
#[tokio::main]
@@ -65,29 +107,23 @@ async fn main() -> anyhow::Result<()> {
65107

66108
// Configuration
67109
let local_port = ASSIGNED_BY_OS;
110+
let local_bind_to = format!("0.0.0.0:{local_port}");
68111
let transaction_id = RANDOM_TRANSACTION_ID;
69-
let bind_to = format!("0.0.0.0:{local_port}");
70112

71113
// Bind to local port
72-
debug!("Binding to: {bind_to}");
73-
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;
74116
let bound_to = udp_client.socket.local_addr().unwrap();
75117
debug!("Bound to: {bound_to}");
76118

119+
let transaction_id = TransactionId(transaction_id);
120+
77121
let response = match args.command {
78122
Command::Announce {
79123
tracker_socket_addr,
80124
info_hash,
81125
} => {
82-
debug!("Connecting to remote: udp://{tracker_socket_addr}");
83-
84-
udp_client.connect(&tracker_socket_addr.to_string()).await;
85-
86-
let udp_tracker_client = UdpTrackerClient { udp_client };
87-
88-
let transaction_id = TransactionId(transaction_id);
89-
90-
let connection_id = send_connection_request(transaction_id, &udp_tracker_client).await;
126+
let (connection_id, udp_tracker_client) = connect(&tracker_socket_addr, udp_client, transaction_id).await;
91127

92128
send_announce_request(
93129
connection_id,
@@ -98,6 +134,13 @@ async fn main() -> anyhow::Result<()> {
98134
)
99135
.await
100136
}
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+
}
101144
};
102145

103146
match response {
@@ -123,7 +166,19 @@ async fn main() -> anyhow::Result<()> {
123166
let pretty_json = serde_json::to_string_pretty(&json).unwrap();
124167
println!("{pretty_json}");
125168
}
126-
_ => println!("{response:#?}"),
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.
127182
}
128183

129184
Ok(())
@@ -150,12 +205,76 @@ fn setup_logging(level: LevelFilter) {
150205
debug!("logging initialized.");
151206
}
152207

153-
fn parse_socket_addr(s: &str) -> anyhow::Result<SocketAddr> {
154-
s.parse().with_context(|| format!("failed to parse socket address: `{s}`"))
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:#?}");
249+
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])
256+
}
257+
}
258+
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:?}")))
155262
}
156263

157-
fn parse_info_hash(s: &str) -> anyhow::Result<TorrustInfoHash> {
158-
TorrustInfoHash::from_str(s).map_err(|e| anyhow::Error::msg(format!("failed to parse info-hash `{s}`: {e:?}")))
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)
159278
}
160279

161280
async fn send_connection_request(transaction_id: TransactionId, client: &UdpTrackerClient) -> ConnectionId {
@@ -207,3 +326,29 @@ async fn send_announce_request(
207326

208327
response
209328
}
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)