1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use irontide_core::Id20;
6
7use crate::compact::{parse_compact_peers, parse_compact_peers6};
8use crate::error::{Error, Result};
9use crate::{AnnounceEvent, AnnounceRequest, AnnounceResponse, ScrapeInfo};
10
11#[derive(Clone)]
13pub struct HttpTracker {
14 client: reqwest::Client,
15}
16
17#[derive(Debug, Clone)]
19pub struct HttpAnnounceResponse {
20 pub response: AnnounceResponse,
22 pub tracker_id: Option<String>,
24 pub warning: Option<String>,
26}
27
28#[derive(Deserialize)]
30struct RawHttpResponse {
31 interval: u32,
32 #[serde(default)]
33 complete: Option<u32>,
34 #[serde(default)]
35 incomplete: Option<u32>,
36 #[serde(with = "serde_bytes")]
37 peers: Vec<u8>,
38 #[serde(with = "serde_bytes", default)]
40 peers6: Vec<u8>,
41 #[serde(default, rename = "failure reason")]
42 failure_reason: Option<String>,
43 #[serde(default, rename = "warning message")]
44 warning_message: Option<String>,
45 #[serde(default, rename = "tracker id")]
46 tracker_id: Option<String>,
47}
48
49impl HttpTracker {
50 pub fn new() -> Self {
52 HttpTracker {
53 client: reqwest::Client::builder()
54 .user_agent("Torrent/0.60.0")
55 .build()
56 .expect("failed to build HTTP client"),
57 }
58 }
59
60 pub fn with_anonymous() -> Self {
64 HttpTracker {
65 client: reqwest::Client::builder()
66 .user_agent("")
67 .build()
68 .expect("failed to build HTTP client"),
69 }
70 }
71
72 pub fn with_proxy(proxy_url: Option<&str>) -> Self {
77 let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
78 if let Some(url) = proxy_url
79 && let Ok(proxy) = reqwest::Proxy::all(url)
80 {
81 builder = builder.proxy(proxy);
82 }
83 HttpTracker {
84 client: builder.build().expect("failed to build HTTP client"),
85 }
86 }
87
88 pub fn with_security(
94 proxy_url: Option<&str>,
95 validate_tls: bool,
96 ssrf_mitigation: bool,
97 ) -> Self {
98 let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
99
100 if ssrf_mitigation {
101 let policy = reqwest::redirect::Policy::custom(|attempt| {
102 if attempt.previous().len() >= 10 {
103 return attempt.error(std::io::Error::other("too many redirects"));
104 }
105
106 let original = &attempt.previous()[0];
107 let redirect = attempt.url();
108
109 let orig_local = match original.host() {
111 Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
112 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
115 Some(url::Host::Domain(d)) => d == "localhost",
116 None => false,
117 };
118
119 if !orig_local {
120 let redirect_local = match redirect.host() {
121 Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
122 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
123 Some(url::Host::Domain(d)) => d == "localhost",
124 None => false,
125 };
126
127 if redirect_local {
128 return attempt.error(std::io::Error::other(
129 "redirect from public to private IP blocked (SSRF)",
130 ));
131 }
132 }
133
134 attempt.follow()
135 });
136 builder = builder.redirect(policy);
137 }
138
139 if !validate_tls {
140 builder = builder.danger_accept_invalid_certs(true);
141 }
142
143 if let Some(url) = proxy_url
144 && let Ok(proxy) = reqwest::Proxy::all(url)
145 {
146 builder = builder.proxy(proxy);
147 }
148
149 HttpTracker {
150 client: builder.build().expect("failed to build HTTP client"),
151 }
152 }
153
154 pub fn build_announce_url(base_url: &str, req: &AnnounceRequest) -> Result<String> {
156 let mut url = base_url.to_string();
157
158 let info_hash_encoded = url_encode_bytes(req.info_hash.as_bytes());
160 let peer_id_encoded = url_encode_bytes(req.peer_id.as_bytes());
161
162 let separator = if url.contains('?') { '&' } else { '?' };
163
164 url.push(separator);
165 url.push_str(&format!(
166 "info_hash={info_hash_encoded}&peer_id={peer_id_encoded}&port={}&uploaded={}&downloaded={}&left={}&compact=1",
167 req.port, req.uploaded, req.downloaded, req.left
168 ));
169
170 match req.event {
171 AnnounceEvent::None => {}
172 AnnounceEvent::Started => url.push_str("&event=started"),
173 AnnounceEvent::Completed => url.push_str("&event=completed"),
174 AnnounceEvent::Stopped => url.push_str("&event=stopped"),
175 }
176
177 if let Some(n) = req.num_want {
178 url.push_str(&format!("&numwant={n}"));
179 }
180
181 if let Some(ref dest) = req.i2p_destination {
182 url.push_str("&i2p=");
183 url.push_str(dest.trim_end_matches('='));
184 }
185
186 Ok(url)
187 }
188
189 pub async fn announce(
191 &self,
192 base_url: &str,
193 req: &AnnounceRequest,
194 ) -> Result<HttpAnnounceResponse> {
195 let url = Self::build_announce_url(base_url, req)?;
196
197 let response = self.client.get(&url).send().await?.bytes().await?;
198
199 let raw: RawHttpResponse = irontide_bencode::from_bytes(&response)?;
200
201 if let Some(failure) = raw.failure_reason {
202 return Err(Error::TrackerError(failure));
203 }
204
205 let mut peers = parse_compact_peers(&raw.peers)?;
206
207 if let Ok(peers6) = parse_compact_peers6(&raw.peers6) {
209 peers.extend(peers6);
210 }
211
212 Ok(HttpAnnounceResponse {
213 response: AnnounceResponse {
214 interval: raw.interval,
215 seeders: raw.complete,
216 leechers: raw.incomplete,
217 peers,
218 },
219 tracker_id: raw.tracker_id,
220 warning: raw.warning_message,
221 })
222 }
223}
224
225#[derive(Debug, Clone)]
227pub struct HttpScrapeResponse {
228 pub files: HashMap<Id20, ScrapeInfo>,
230}
231
232impl HttpTracker {
233 pub fn build_scrape_url(announce_url: &str, info_hashes: &[Id20]) -> Result<String> {
235 let base = crate::announce_url_to_scrape(announce_url)
236 .ok_or_else(|| Error::InvalidUrl("no 'announce' in URL to convert to scrape".into()))?;
237 let mut url = base;
238 for (i, hash) in info_hashes.iter().enumerate() {
239 let encoded = url_encode_bytes(hash.as_bytes());
240 url.push(if i == 0 { '?' } else { '&' });
241 url.push_str("info_hash=");
242 url.push_str(&encoded);
243 }
244 Ok(url)
245 }
246
247 pub async fn scrape(
249 &self,
250 announce_url: &str,
251 info_hashes: &[Id20],
252 ) -> Result<HttpScrapeResponse> {
253 let url = Self::build_scrape_url(announce_url, info_hashes)?;
254
255 let response = self.client.get(&url).send().await?.bytes().await?;
256
257 let value: irontide_bencode::BencodeValue = irontide_bencode::from_bytes(&response)?;
259 let root = value
260 .as_dict()
261 .ok_or_else(|| Error::InvalidResponse("scrape response is not a dict".into()))?;
262
263 let files_val = root
264 .get(b"files".as_slice())
265 .and_then(|v| v.as_dict())
266 .ok_or_else(|| Error::InvalidResponse("scrape response missing 'files' dict".into()))?;
267
268 let mut files = HashMap::new();
269 for (key, val) in files_val {
270 if key.len() != 20 {
271 continue;
272 }
273 let hash = Id20::from_bytes(key).map_err(|_| {
274 Error::InvalidResponse("invalid info_hash in scrape response".into())
275 })?;
276 let entry = val
277 .as_dict()
278 .ok_or_else(|| Error::InvalidResponse("scrape file entry is not a dict".into()))?;
279
280 let complete = entry
281 .get(b"complete".as_slice())
282 .and_then(|v| v.as_int())
283 .unwrap_or(0) as u32;
284 let incomplete = entry
285 .get(b"incomplete".as_slice())
286 .and_then(|v| v.as_int())
287 .unwrap_or(0) as u32;
288 let downloaded = entry
289 .get(b"downloaded".as_slice())
290 .and_then(|v| v.as_int())
291 .unwrap_or(0) as u32;
292
293 files.insert(
294 hash,
295 ScrapeInfo {
296 complete,
297 incomplete,
298 downloaded,
299 },
300 );
301 }
302
303 Ok(HttpScrapeResponse { files })
304 }
305}
306
307impl Default for HttpTracker {
308 fn default() -> Self {
309 Self::new()
310 }
311}
312
313fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
315 ip.is_loopback() || ip.is_private() || ip.is_link_local()
316}
317
318fn url_encode_bytes(bytes: &[u8]) -> String {
320 let mut encoded = String::with_capacity(bytes.len() * 3);
321 for &b in bytes {
322 match b {
323 b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'-' | b'_' | b'~' => {
324 encoded.push(b as char);
325 }
326 _ => {
327 encoded.push_str(&format!("%{b:02X}"));
328 }
329 }
330 }
331 encoded
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use irontide_core::Id20;
338
339 #[test]
340 fn build_announce_url_basic() {
341 let req = AnnounceRequest {
342 info_hash: Id20::ZERO,
343 peer_id: Id20::ZERO,
344 port: 6881,
345 uploaded: 0,
346 downloaded: 0,
347 left: 1000,
348 event: AnnounceEvent::Started,
349 num_want: Some(50),
350 compact: true,
351 i2p_destination: None,
352 };
353
354 let url =
355 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
356
357 assert!(url.starts_with("http://tracker.example.com/announce?"));
358 assert!(url.contains("info_hash="));
359 assert!(url.contains("port=6881"));
360 assert!(url.contains("event=started"));
361 assert!(url.contains("numwant=50"));
362 assert!(url.contains("compact=1"));
363 }
364
365 #[test]
366 fn build_scrape_url_basic() {
367 let hash = Id20::ZERO;
368 let url =
369 HttpTracker::build_scrape_url("http://tracker.example.com/announce", &[hash]).unwrap();
370 assert!(url.starts_with("http://tracker.example.com/scrape?info_hash="));
371 }
372
373 #[test]
374 fn build_scrape_url_no_announce_in_url() {
375 let hash = Id20::ZERO;
376 let result = HttpTracker::build_scrape_url("http://tracker.example.com/track", &[hash]);
377 assert!(result.is_err());
378 }
379
380 #[test]
381 fn url_encode_bytes_simple() {
382 assert_eq!(url_encode_bytes(b"abc"), "abc");
383 assert_eq!(url_encode_bytes(&[0xFF, 0x00]), "%FF%00");
384 }
385
386 #[test]
387 fn url_encode_preserves_unreserved() {
388 let unreserved = b"abcXYZ019.-_~";
389 let encoded = url_encode_bytes(unreserved);
390 assert_eq!(encoded, "abcXYZ019.-_~");
391 }
392
393 #[test]
394 fn parse_response_with_peers6() {
395 use std::net::Ipv6Addr;
396
397 let mut peers = Vec::new();
399 peers.extend_from_slice(&[192, 168, 1, 1, 0x1A, 0xE1]); let ip6: Ipv6Addr = "2001:db8::1".parse().unwrap();
402 let mut peers6 = Vec::new();
403 peers6.extend_from_slice(&ip6.octets());
404 peers6.extend_from_slice(&8080u16.to_be_bytes());
405
406 let raw = RawHttpResponse {
407 interval: 1800,
408 complete: Some(10),
409 incomplete: Some(5),
410 peers,
411 peers6,
412 failure_reason: None,
413 warning_message: None,
414 tracker_id: None,
415 };
416
417 let mut result = parse_compact_peers(&raw.peers).unwrap();
419 if !raw.peers6.is_empty() {
420 if let Ok(v6) = parse_compact_peers6(&raw.peers6) {
421 result.extend(v6);
422 }
423 }
424
425 assert_eq!(result.len(), 2);
426 assert_eq!(result[0].to_string(), "192.168.1.1:6881");
427 assert_eq!(
428 result[1],
429 "[2001:db8::1]:8080"
430 .parse::<std::net::SocketAddr>()
431 .unwrap()
432 );
433 }
434
435 #[test]
436 fn http_tracker_anonymous_builds() {
437 let tracker = HttpTracker::with_anonymous();
438 drop(tracker);
439 }
440
441 #[test]
442 fn http_tracker_with_security_builds() {
443 let tracker = HttpTracker::with_security(None, true, true);
445 drop(tracker);
446 }
447
448 #[test]
449 fn http_tracker_with_security_no_tls_validation() {
450 let tracker = HttpTracker::with_security(None, false, false);
452 drop(tracker);
453 }
454
455 #[test]
456 fn build_announce_url_includes_i2p_destination() {
457 let req = AnnounceRequest {
459 info_hash: Id20::ZERO,
460 peer_id: Id20::ZERO,
461 port: 6881,
462 uploaded: 0,
463 downloaded: 0,
464 left: 1000,
465 event: AnnounceEvent::None,
466 num_want: None,
467 compact: true,
468 i2p_destination: Some("AAAA-BBB~CCC==".into()),
469 };
470
471 let url =
472 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
473
474 assert!(
475 url.contains("&i2p=AAAA-BBB~CCC"),
476 "URL should contain I2P destination with padding stripped: {url}"
477 );
478 let i2p_start = url.find("&i2p=").unwrap() + 5;
480 let i2p_value = &url[i2p_start..];
481 assert!(
482 !i2p_value.contains('='),
483 "I2P destination should not contain '=' padding in URL: {i2p_value}"
484 );
485 }
486
487 #[test]
488 fn build_announce_url_omits_i2p_when_none() {
489 let req = AnnounceRequest {
490 info_hash: Id20::ZERO,
491 peer_id: Id20::ZERO,
492 port: 6881,
493 uploaded: 0,
494 downloaded: 0,
495 left: 1000,
496 event: AnnounceEvent::None,
497 num_want: None,
498 compact: true,
499 i2p_destination: None,
500 };
501
502 let url =
503 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
504
505 assert!(
506 !url.contains("&i2p="),
507 "URL should not contain &i2p= when None: {url}"
508 );
509 }
510}