Skip to main content

irontide_tracker/
http.rs

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/// HTTP tracker client (BEP 3).
20#[derive(Clone)]
21pub struct HttpTracker {
22    client: reqwest::Client,
23}
24
25/// Raw HTTP announce response (bencode).
26#[derive(Debug, Clone)]
27pub struct HttpAnnounceResponse {
28    /// Common announce response data (interval, peers, etc.).
29    pub response: AnnounceResponse,
30    /// Tracker ID (some trackers return this for subsequent requests).
31    pub tracker_id: Option<String>,
32    /// Warning message from tracker.
33    pub warning: Option<String>,
34    /// BEP 31: minimum re-announce interval (floor on `interval`).
35    pub min_interval: Option<u32>,
36}
37
38/// Raw bencode response from HTTP tracker.
39#[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    /// Compact IPv6 peers (BEP 7): 18 bytes each (16 IP + 2 port).
49    #[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    /// BEP 31: minimum re-announce interval (floor on `interval`).
58    #[serde(default, rename = "min interval")]
59    min_interval: Option<u32>,
60    /// BEP 31: retry delay hint on failure responses.
61    #[serde(default, rename = "retry in")]
62    retry_in: Option<u32>,
63}
64
65impl HttpTracker {
66    /// Creates a new HTTP tracker client with default settings.
67    #[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    /// Create an HTTP tracker client for anonymous mode.
78    ///
79    /// Uses an empty user-agent string to avoid identifying the client software.
80    #[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    /// Create an HTTP tracker client with an optional proxy.
91    ///
92    /// When `proxy_url` is provided (e.g. `"socks5://host:port"`), all
93    /// HTTP requests are routed through it.
94    #[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    /// Create an HTTP tracker client with URL security features.
108    ///
109    /// When `ssrf_mitigation` is enabled, a custom redirect policy blocks
110    /// redirects from global (public) URLs to private/loopback IP addresses.
111    /// When `validate_tls` is false, TLS certificate validation is disabled.
112    #[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                // Check if the original URL was on a global (non-local) address.
130                let orig_local = match original.host() {
131                    Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
132                    // IPv6: loopback-only; ULA/link-local caught by url_guard
133                    // in irontide-session (crate boundary prevents reuse).
134                    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    /// Build the announce URL with query parameters.
175    ///
176    /// # Errors
177    ///
178    /// This function is infallible but returns `Result` for API consistency.
179    pub fn build_announce_url(base_url: &str, req: &AnnounceRequest) -> Result<String> {
180        let mut url = base_url.to_string();
181
182        // URL-encode the info_hash and peer_id as raw bytes
183        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    /// Send an announce request to an HTTP tracker.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if the HTTP request fails or the response is malformed.
219    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        // Merge IPv6 peers from peers6 key (BEP 7)
242        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/// HTTP scrape response containing per-torrent stats.
261#[derive(Debug, Clone)]
262pub struct HttpScrapeResponse {
263    /// Per-torrent scrape statistics keyed by info hash.
264    pub files: HashMap<Id20, ScrapeInfo>,
265}
266
267impl HttpTracker {
268    /// Build a scrape URL from an announce URL and a list of `info_hashes`.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the announce URL cannot be converted to a scrape URL.
273    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    /// Send a scrape request to an HTTP tracker.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the HTTP request fails or the response is malformed.
291    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        // Parse using BencodeValue since keys are raw 20-byte hashes
301        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
356/// Returns `true` if the IPv4 address is loopback, private (RFC 1918), or link-local.
357fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
358    ip.is_loopback() || ip.is_private() || ip.is_link_local()
359}
360
361/// URL-encode raw bytes (percent-encoding).
362fn 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        // Build a raw bencode response with both peers and peers6
441        let mut peers = Vec::new();
442        peers.extend_from_slice(&[192, 168, 1, 1, 0x1A, 0xE1]); // 192.168.1.1:6881
443
444        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        // Manually parse as the announce method would
463        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        // Basic smoke test: with_security with SSRF mitigation enabled builds.
489        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        // Builds with TLS validation disabled and SSRF mitigation off.
496        let tracker = HttpTracker::with_security(None, false, false);
497        drop(tracker);
498    }
499
500    #[test]
501    fn build_announce_url_includes_i2p_destination() {
502        // Use I2P-specific Base64 chars (`-`, `~`) and padding to exercise stripping
503        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        // Verify padding was removed: no `=` should appear after the destination
524        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}