Skip to main content

irontide_tracker/
http.rs

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/// HTTP tracker client (BEP 3).
12#[derive(Clone)]
13pub struct HttpTracker {
14    client: reqwest::Client,
15}
16
17/// Raw HTTP announce response (bencode).
18#[derive(Debug, Clone)]
19pub struct HttpAnnounceResponse {
20    /// Common announce response data (interval, peers, etc.).
21    pub response: AnnounceResponse,
22    /// Tracker ID (some trackers return this for subsequent requests).
23    pub tracker_id: Option<String>,
24    /// Warning message from tracker.
25    pub warning: Option<String>,
26}
27
28/// Raw bencode response from HTTP tracker.
29#[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    /// Compact IPv6 peers (BEP 7): 18 bytes each (16 IP + 2 port).
39    #[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    /// Creates a new HTTP tracker client with default settings.
51    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    /// Create an HTTP tracker client for anonymous mode.
61    ///
62    /// Uses an empty user-agent string to avoid identifying the client software.
63    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    /// Create an HTTP tracker client with an optional proxy.
73    ///
74    /// When `proxy_url` is provided (e.g. `"socks5://host:port"`), all
75    /// HTTP requests are routed through it.
76    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    /// Create an HTTP tracker client with URL security features.
89    ///
90    /// When `ssrf_mitigation` is enabled, a custom redirect policy blocks
91    /// redirects from global (public) URLs to private/loopback IP addresses.
92    /// When `validate_tls` is false, TLS certificate validation is disabled.
93    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                // Check if the original URL was on a global (non-local) address.
110                let orig_local = match original.host() {
111                    Some(url::Host::Ipv4(ip)) => is_private_ipv4(ip),
112                    // IPv6: loopback-only; ULA/link-local caught by url_guard
113                    // in irontide-session (crate boundary prevents reuse).
114                    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    /// Build the announce URL with query parameters.
155    pub fn build_announce_url(base_url: &str, req: &AnnounceRequest) -> Result<String> {
156        let mut url = base_url.to_string();
157
158        // URL-encode the info_hash and peer_id as raw bytes
159        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    /// Send an announce request to an HTTP tracker.
190    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        // Merge IPv6 peers from peers6 key (BEP 7)
208        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/// HTTP scrape response containing per-torrent stats.
226#[derive(Debug, Clone)]
227pub struct HttpScrapeResponse {
228    /// Per-torrent scrape statistics keyed by info hash.
229    pub files: HashMap<Id20, ScrapeInfo>,
230}
231
232impl HttpTracker {
233    /// Build a scrape URL from an announce URL and a list of info_hashes.
234    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    /// Send a scrape request to an HTTP tracker.
248    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        // Parse using BencodeValue since keys are raw 20-byte hashes
258        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
313/// Returns `true` if the IPv4 address is loopback, private (RFC 1918), or link-local.
314fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
315    ip.is_loopback() || ip.is_private() || ip.is_link_local()
316}
317
318/// URL-encode raw bytes (percent-encoding).
319fn 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        // Build a raw bencode response with both peers and peers6
398        let mut peers = Vec::new();
399        peers.extend_from_slice(&[192, 168, 1, 1, 0x1A, 0xE1]); // 192.168.1.1:6881
400
401        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        // Manually parse as the announce method would
418        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        // Basic smoke test: with_security with SSRF mitigation enabled builds.
444        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        // Builds with TLS validation disabled and SSRF mitigation off.
451        let tracker = HttpTracker::with_security(None, false, false);
452        drop(tracker);
453    }
454
455    #[test]
456    fn build_announce_url_includes_i2p_destination() {
457        // Use I2P-specific Base64 chars (`-`, `~`) and padding to exercise stripping
458        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        // Verify padding was removed: no `=` should appear after the destination
479        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}