1#![allow(
2 clippy::cast_possible_truncation,
3 clippy::cast_possible_wrap,
4 clippy::cast_sign_loss,
5 reason = "M175: HTTP tracker — BEP 7/12 wire format uses signed-i64 for counters; field widths fixed by spec"
6)]
7
8use std::collections::HashMap;
9use std::fmt::Write;
10
11use serde::Deserialize;
12
13use irontide_core::Id20;
14
15use crate::compact::{parse_compact_peers, parse_compact_peers6};
16use crate::error::{Error, Result};
17use crate::{AnnounceEvent, AnnounceRequest, AnnounceResponse, ScrapeInfo};
18
19#[derive(Clone)]
21pub struct HttpTracker {
22 client: reqwest::Client,
23}
24
25#[derive(Debug, Clone)]
27pub struct HttpAnnounceResponse {
28 pub response: AnnounceResponse,
30 pub tracker_id: Option<String>,
32 pub warning: Option<String>,
34 pub min_interval: Option<u32>,
36}
37
38#[derive(Deserialize)]
40struct RawHttpResponse {
41 interval: u32,
42 #[serde(default)]
43 complete: Option<u32>,
44 #[serde(default)]
45 incomplete: Option<u32>,
46 #[serde(with = "serde_bytes")]
47 peers: Vec<u8>,
48 #[serde(with = "serde_bytes", default)]
50 peers6: Vec<u8>,
51 #[serde(default, rename = "failure reason")]
52 failure_reason: Option<String>,
53 #[serde(default, rename = "warning message")]
54 warning_message: Option<String>,
55 #[serde(default, rename = "tracker id")]
56 tracker_id: Option<String>,
57 #[serde(default, rename = "min interval")]
59 min_interval: Option<u32>,
60 #[serde(default, rename = "retry in")]
62 retry_in: Option<u32>,
63}
64
65impl HttpTracker {
66 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 client: reqwest::Client::builder()
71 .user_agent("Torrent/0.60.0")
72 .build()
73 .expect("failed to build HTTP client"),
74 }
75 }
76
77 #[must_use]
81 pub fn with_anonymous() -> Self {
82 Self {
83 client: reqwest::Client::builder()
84 .user_agent("")
85 .build()
86 .expect("failed to build HTTP client"),
87 }
88 }
89
90 #[must_use]
95 pub fn with_proxy(proxy_url: Option<&str>) -> Self {
96 let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
97 if let Some(url) = proxy_url
98 && let Ok(proxy) = reqwest::Proxy::all(url)
99 {
100 builder = builder.proxy(proxy);
101 }
102 Self {
103 client: builder.build().expect("failed to build HTTP client"),
104 }
105 }
106
107 #[must_use]
113 pub fn with_security(
114 proxy_url: Option<&str>,
115 validate_tls: bool,
116 ssrf_mitigation: bool,
117 ) -> Self {
118 let mut builder = reqwest::Client::builder().user_agent("Torrent/0.60.0");
119
120 if ssrf_mitigation {
121 let policy = reqwest::redirect::Policy::custom(|attempt| {
122 if attempt.previous().len() >= 10 {
123 return attempt.error(std::io::Error::other("too many redirects"));
124 }
125
126 let original = &attempt.previous()[0];
127 let redirect = attempt.url();
128
129 let orig_local = match original.host() {
131 Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
132 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
135 Some(url::Host::Domain(d)) => d == "localhost",
136 None => false,
137 };
138
139 if !orig_local {
140 let redirect_local = match redirect.host() {
141 Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
142 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
143 Some(url::Host::Domain(d)) => d == "localhost",
144 None => false,
145 };
146
147 if redirect_local {
148 return attempt.error(std::io::Error::other(
149 "redirect from public to private IP blocked (SSRF)",
150 ));
151 }
152 }
153
154 attempt.follow()
155 });
156 builder = builder.redirect(policy);
157 }
158
159 if !validate_tls {
160 builder = builder.danger_accept_invalid_certs(true);
161 }
162
163 if let Some(url) = proxy_url
164 && let Ok(proxy) = reqwest::Proxy::all(url)
165 {
166 builder = builder.proxy(proxy);
167 }
168
169 Self {
170 client: builder.build().expect("failed to build HTTP client"),
171 }
172 }
173
174 pub fn build_announce_url(base_url: &str, req: &AnnounceRequest) -> Result<String> {
180 let mut url = base_url.to_string();
181
182 let info_hash_encoded = url_encode_bytes(req.info_hash.as_bytes());
184 let peer_id_encoded = url_encode_bytes(req.peer_id.as_bytes());
185
186 let separator = if url.contains('?') { '&' } else { '?' };
187
188 url.push(separator);
189 let _ = write!(
190 url,
191 "info_hash={info_hash_encoded}&peer_id={peer_id_encoded}&port={}&uploaded={}&downloaded={}&left={}&compact=1",
192 req.port, req.uploaded, req.downloaded, req.left
193 );
194
195 match req.event {
196 AnnounceEvent::None => {}
197 AnnounceEvent::Started => url.push_str("&event=started"),
198 AnnounceEvent::Completed => url.push_str("&event=completed"),
199 AnnounceEvent::Stopped => url.push_str("&event=stopped"),
200 }
201
202 if let Some(n) = req.num_want {
203 let _ = write!(url, "&numwant={n}");
204 }
205
206 if let Some(ref dest) = req.i2p_destination {
207 url.push_str("&i2p=");
208 url.push_str(dest.trim_end_matches('='));
209 }
210
211 Ok(url)
212 }
213
214 pub async fn announce(
220 &self,
221 base_url: &str,
222 req: &AnnounceRequest,
223 ) -> Result<HttpAnnounceResponse> {
224 let url = Self::build_announce_url(base_url, req)?;
225
226 let response = self.client.get(&url).send().await?.bytes().await?;
227
228 let raw: RawHttpResponse = irontide_bencode::from_bytes(&response)?;
229
230 if let Some(failure) = raw.failure_reason {
231 return Err(Error::TrackerError {
232 message: failure,
233 retry_in: raw.retry_in,
234 });
235 }
236
237 let interval = raw.interval.max(raw.min_interval.unwrap_or(0));
238
239 let mut peers = parse_compact_peers(&raw.peers)?;
240
241 if let Ok(peers6) = parse_compact_peers6(&raw.peers6) {
243 peers.extend(peers6);
244 }
245
246 Ok(HttpAnnounceResponse {
247 response: AnnounceResponse {
248 interval,
249 seeders: raw.complete,
250 leechers: raw.incomplete,
251 peers,
252 },
253 tracker_id: raw.tracker_id,
254 warning: raw.warning_message,
255 min_interval: raw.min_interval,
256 })
257 }
258}
259
260#[derive(Debug, Clone)]
262pub struct HttpScrapeResponse {
263 pub files: HashMap<Id20, ScrapeInfo>,
265}
266
267impl HttpTracker {
268 pub fn build_scrape_url(announce_url: &str, info_hashes: &[Id20]) -> Result<String> {
274 let base = crate::announce_url_to_scrape(announce_url)
275 .ok_or_else(|| Error::InvalidUrl("no 'announce' in URL to convert to scrape".into()))?;
276 let mut url = base;
277 for (i, hash) in info_hashes.iter().enumerate() {
278 let encoded = url_encode_bytes(hash.as_bytes());
279 url.push(if i == 0 { '?' } else { '&' });
280 url.push_str("info_hash=");
281 url.push_str(&encoded);
282 }
283 Ok(url)
284 }
285
286 pub async fn scrape(
292 &self,
293 announce_url: &str,
294 info_hashes: &[Id20],
295 ) -> Result<HttpScrapeResponse> {
296 let url = Self::build_scrape_url(announce_url, info_hashes)?;
297
298 let response = self.client.get(&url).send().await?.bytes().await?;
299
300 let value: irontide_bencode::BencodeValue = irontide_bencode::from_bytes(&response)?;
302 let root = value
303 .as_dict()
304 .ok_or_else(|| Error::InvalidResponse("scrape response is not a dict".into()))?;
305
306 let files_val = root
307 .get(b"files".as_slice())
308 .and_then(|v| v.as_dict())
309 .ok_or_else(|| Error::InvalidResponse("scrape response missing 'files' dict".into()))?;
310
311 let mut files = HashMap::new();
312 for (key, val) in files_val {
313 if key.len() != 20 {
314 continue;
315 }
316 let hash = Id20::from_bytes(key).map_err(|_| {
317 Error::InvalidResponse("invalid info_hash in scrape response".into())
318 })?;
319 let entry = val
320 .as_dict()
321 .ok_or_else(|| Error::InvalidResponse("scrape file entry is not a dict".into()))?;
322
323 let complete = entry
324 .get(b"complete".as_slice())
325 .and_then(irontide_bencode::BencodeValue::as_int)
326 .unwrap_or(0) as u32;
327 let incomplete = entry
328 .get(b"incomplete".as_slice())
329 .and_then(irontide_bencode::BencodeValue::as_int)
330 .unwrap_or(0) as u32;
331 let downloaded = entry
332 .get(b"downloaded".as_slice())
333 .and_then(irontide_bencode::BencodeValue::as_int)
334 .unwrap_or(0) as u32;
335
336 files.insert(
337 hash,
338 ScrapeInfo {
339 complete,
340 incomplete,
341 downloaded,
342 },
343 );
344 }
345
346 Ok(HttpScrapeResponse { files })
347 }
348}
349
350impl Default for HttpTracker {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
358 ip.is_loopback() || ip.is_private() || ip.is_link_local()
359}
360
361fn url_encode_bytes(bytes: &[u8]) -> String {
363 let mut encoded = String::with_capacity(bytes.len() * 3);
364 for &b in bytes {
365 match b {
366 b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'-' | b'_' | b'~' => {
367 encoded.push(b as char);
368 }
369 _ => {
370 let _ = write!(encoded, "%{b:02X}");
371 }
372 }
373 }
374 encoded
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use irontide_core::Id20;
381
382 #[test]
383 fn build_announce_url_basic() {
384 let req = AnnounceRequest {
385 info_hash: Id20::ZERO,
386 peer_id: Id20::ZERO,
387 port: 6881,
388 uploaded: 0,
389 downloaded: 0,
390 left: 1000,
391 event: AnnounceEvent::Started,
392 num_want: Some(50),
393 compact: true,
394 i2p_destination: None,
395 };
396
397 let url =
398 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
399
400 assert!(url.starts_with("http://tracker.example.com/announce?"));
401 assert!(url.contains("info_hash="));
402 assert!(url.contains("port=6881"));
403 assert!(url.contains("event=started"));
404 assert!(url.contains("numwant=50"));
405 assert!(url.contains("compact=1"));
406 }
407
408 #[test]
409 fn build_scrape_url_basic() {
410 let hash = Id20::ZERO;
411 let url =
412 HttpTracker::build_scrape_url("http://tracker.example.com/announce", &[hash]).unwrap();
413 assert!(url.starts_with("http://tracker.example.com/scrape?info_hash="));
414 }
415
416 #[test]
417 fn build_scrape_url_no_announce_in_url() {
418 let hash = Id20::ZERO;
419 let result = HttpTracker::build_scrape_url("http://tracker.example.com/track", &[hash]);
420 assert!(result.is_err());
421 }
422
423 #[test]
424 fn url_encode_bytes_simple() {
425 assert_eq!(url_encode_bytes(b"abc"), "abc");
426 assert_eq!(url_encode_bytes(&[0xFF, 0x00]), "%FF%00");
427 }
428
429 #[test]
430 fn url_encode_preserves_unreserved() {
431 let unreserved = b"abcXYZ019.-_~";
432 let encoded = url_encode_bytes(unreserved);
433 assert_eq!(encoded, "abcXYZ019.-_~");
434 }
435
436 #[test]
437 fn parse_response_with_peers6() {
438 use std::net::Ipv6Addr;
439
440 let mut peers = Vec::new();
442 peers.extend_from_slice(&[192, 168, 1, 1, 0x1A, 0xE1]); let ip6: Ipv6Addr = "2001:db8::1".parse().unwrap();
445 let mut peers6 = Vec::new();
446 peers6.extend_from_slice(&ip6.octets());
447 peers6.extend_from_slice(&8080u16.to_be_bytes());
448
449 let raw = RawHttpResponse {
450 interval: 1800,
451 complete: Some(10),
452 incomplete: Some(5),
453 peers,
454 peers6,
455 failure_reason: None,
456 warning_message: None,
457 tracker_id: None,
458 min_interval: None,
459 retry_in: None,
460 };
461
462 let mut result = parse_compact_peers(&raw.peers).unwrap();
464 if !raw.peers6.is_empty()
465 && let Ok(v6) = parse_compact_peers6(&raw.peers6)
466 {
467 result.extend(v6);
468 }
469
470 assert_eq!(result.len(), 2);
471 assert_eq!(result[0].to_string(), "192.168.1.1:6881");
472 assert_eq!(
473 result[1],
474 "[2001:db8::1]:8080"
475 .parse::<std::net::SocketAddr>()
476 .unwrap()
477 );
478 }
479
480 #[test]
481 fn http_tracker_anonymous_builds() {
482 let tracker = HttpTracker::with_anonymous();
483 drop(tracker);
484 }
485
486 #[test]
487 fn http_tracker_with_security_builds() {
488 let tracker = HttpTracker::with_security(None, true, true);
490 drop(tracker);
491 }
492
493 #[test]
494 fn http_tracker_with_security_no_tls_validation() {
495 let tracker = HttpTracker::with_security(None, false, false);
497 drop(tracker);
498 }
499
500 #[test]
501 fn build_announce_url_includes_i2p_destination() {
502 let req = AnnounceRequest {
504 info_hash: Id20::ZERO,
505 peer_id: Id20::ZERO,
506 port: 6881,
507 uploaded: 0,
508 downloaded: 0,
509 left: 1000,
510 event: AnnounceEvent::None,
511 num_want: None,
512 compact: true,
513 i2p_destination: Some("AAAA-BBB~CCC==".into()),
514 };
515
516 let url =
517 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
518
519 assert!(
520 url.contains("&i2p=AAAA-BBB~CCC"),
521 "URL should contain I2P destination with padding stripped: {url}"
522 );
523 let i2p_start = url.find("&i2p=").unwrap() + 5;
525 let i2p_value = &url[i2p_start..];
526 assert!(
527 !i2p_value.contains('='),
528 "I2P destination should not contain '=' padding in URL: {i2p_value}"
529 );
530 }
531
532 #[test]
533 fn build_announce_url_omits_i2p_when_none() {
534 let req = AnnounceRequest {
535 info_hash: Id20::ZERO,
536 peer_id: Id20::ZERO,
537 port: 6881,
538 uploaded: 0,
539 downloaded: 0,
540 left: 1000,
541 event: AnnounceEvent::None,
542 num_want: None,
543 compact: true,
544 i2p_destination: None,
545 };
546
547 let url =
548 HttpTracker::build_announce_url("http://tracker.example.com/announce", &req).unwrap();
549
550 assert!(
551 !url.contains("&i2p="),
552 "URL should not contain &i2p= when None: {url}"
553 );
554 }
555
556 #[test]
557 fn deserialize_response_with_min_interval() {
558 let raw = RawHttpResponse {
559 interval: 900,
560 complete: Some(10),
561 incomplete: Some(5),
562 peers: vec![192, 168, 1, 1, 0x1A, 0xE1],
563 peers6: Vec::new(),
564 failure_reason: None,
565 warning_message: None,
566 tracker_id: None,
567 min_interval: Some(1800),
568 retry_in: None,
569 };
570 assert_eq!(raw.min_interval, Some(1800));
571 assert_eq!(raw.interval.max(raw.min_interval.unwrap_or(0)), 1800);
572 }
573
574 #[test]
575 fn deserialize_response_with_retry_in() {
576 let raw = RawHttpResponse {
577 interval: 900,
578 complete: None,
579 incomplete: None,
580 peers: Vec::new(),
581 peers6: Vec::new(),
582 failure_reason: Some("rate limited".into()),
583 warning_message: None,
584 tracker_id: None,
585 min_interval: None,
586 retry_in: Some(120),
587 };
588 assert_eq!(raw.retry_in, Some(120));
589 assert_eq!(raw.failure_reason.as_deref(), Some("rate limited"));
590 }
591
592 #[test]
593 fn tracker_error_carries_retry_in() {
594 let err = Error::TrackerError {
595 message: "rate limited".into(),
596 retry_in: Some(60),
597 };
598 assert_eq!(err.to_string(), "tracker returned error: rate limited");
599 if let Error::TrackerError { retry_in, .. } = &err {
600 assert_eq!(*retry_in, Some(60));
601 } else {
602 panic!("wrong variant");
603 }
604 }
605}