Skip to main content

aria2_core/engine/
bt_tracker_comm.rs

1#![allow(clippy::empty_line_after_doc_comments)]
2
3use crate::engine::http_tracker_client::{TrackerEvent, build_tracker_client, is_https_tracker};
4use crate::error::{Aria2Error, RecoverableError, Result};
5use std::time::{Duration, Instant};
6use tracing::{debug, info};
7
8/// Tracker request timeout (seconds)
9const TRACKER_REQUEST_TIMEOUT_SECS: u64 = 5;
10
11/// BitTorrent tracker communication module.
12///
13/// Handles all interactions with:
14/// - HTTP/HTTPS trackers (announce requests with event state machine)
15/// - UDP trackers
16/// - DHT peer discovery
17/// - Public tracker lists for fallback peer discovery
18///
19/// Extracted from BtDownloadCommand to separate network communication
20/// concerns from download orchestration logic.
21// ======================================================================
22// URL Encoding Helper
23// ======================================================================
24
25/// URL-encodes a 20-byte info hash or peer ID for use in tracker URLs.
26///
27/// Each byte is encoded as `%XX` where XX is the uppercase hex representation.
28/// This is required by the BitTorrent tracker protocol specification.
29pub fn urlencode_infohash(hash: &[u8; 20]) -> String {
30    hash.iter().map(|b| format!("%{:02X}", b)).collect()
31}
32
33// ======================================================================
34// HTTP Tracker Communication
35// ======================================================================
36
37/// Announce to a public tracker and collect peer addresses.
38///
39/// Sends an HTTP/HTTPS GET request to the tracker with standard announce parameters
40/// and parses the response to extract peer information.
41///
42/// This function automatically detects HTTPS URLs and uses TLS when required.
43/// reqwest supports HTTPS natively via its default features (native-tls).
44///
45/// # Arguments
46/// * `tracker_url` - The announce URL of the public tracker (http:// or https://)
47/// * `info_hash` - 20-byte SHA-1 hash of the torrent's info dictionary
48/// * `peer_id` - 20-byte unique identifier for this client
49/// * `total_size` - Total size of the torrent content in bytes
50/// * `event` - Optional tracker event (started, completed, stopped, or empty)
51///
52/// # Returns
53/// A vector of `(ip_address, port)` tuples on success.
54///
55/// # Errors
56/// Returns error string if HTTP request fails, response parsing fails,
57/// or tracker reports failure.
58pub async fn announce_to_public_tracker(
59    tracker_url: &str,
60    info_hash: &[u8; 20],
61    peer_id: &[u8; 20],
62    total_size: u64,
63) -> std::result::Result<Vec<(String, u16)>, String> {
64    announce_to_public_tracker_with_event(
65        tracker_url,
66        info_hash,
67        peer_id,
68        total_size,
69        TrackerEvent::Started, // Default event type
70    )
71    .await
72}
73
74/// Announce to a public tracker with explicit event control.
75///
76/// Extended version of [`announce_to_public_tracker`] that accepts a specific
77/// [`TrackerEvent`] for state machine integration.
78///
79/// # Arguments
80/// * `tracker_url` - The announce URL of the public tracker
81/// * `info_hash` - 20-byte SHA-1 hash of the torrent's info dictionary
82/// * `peer_id` - 20-byte unique identifier for this client
83/// * `total_size` - Total size of the torrent content in bytes
84/// * `event` - The tracker event to send
85pub async fn announce_to_public_tracker_with_event(
86    tracker_url: &str,
87    info_hash: &[u8; 20],
88    peer_id: &[u8; 20],
89    total_size: u64,
90    event: TrackerEvent,
91) -> std::result::Result<Vec<(String, u16)>, String> {
92    // Detect HTTPS scheme for logging and configuration purposes
93    let is_https = is_https_tracker(tracker_url);
94    if is_https {
95        debug!("HTTPS tracker detected: {} (using native-tls)", tracker_url);
96    }
97
98    let event_param = if event == TrackerEvent::None {
99        String::new()
100    } else {
101        format!("&event={}", event.as_str())
102    };
103
104    let url = format!(
105        "{}?info_hash={}&peer_id={}&port=6881&uploaded=0&downloaded=0&left={}{}&compact=1",
106        tracker_url,
107        urlencode_infohash(info_hash),
108        urlencode_infohash(peer_id),
109        total_size,
110        event_param,
111    );
112
113    let client = build_tracker_client(TRACKER_REQUEST_TIMEOUT_SECS)
114        .map_err(|e| format!("build client: {}", e))?;
115
116    let resp = client
117        .get(&url)
118        .send()
119        .await
120        .map_err(|e| format!("request failed: {}", e))?;
121
122    if !resp.status().is_success() {
123        return Err(format!("HTTP {}", resp.status()));
124    }
125
126    let body = resp
127        .bytes()
128        .await
129        .map_err(|e| format!("read body: {}", e))?;
130
131    let tracker_resp = aria2_protocol::bittorrent::tracker::response::TrackerResponse::parse(&body)
132        .map_err(|e| format!("parse response: {}", e))?;
133
134    if tracker_resp.is_failure() {
135        return Err(tracker_resp
136            .failure_reason
137            .unwrap_or_else(|| "tracker failure".to_string()));
138    }
139
140    Ok(tracker_resp
141        .peers
142        .into_iter()
143        .map(|p| (p.ip, p.port))
144        .collect())
145}
146
147// ======================================================================
148// Tracker Peer Discovery Functions
149// ======================================================================
150
151/// Perform initial HTTP tracker announce and collect peers.
152///
153/// This is the first step in peer discovery after torrent metadata is parsed.
154/// Sends a "started" event to inform the tracker we're beginning download.
155///
156/// Automatically detects HTTPS URLs and uses TLS when required.
157///
158/// # Arguments
159/// * `announce_url` - The primary tracker announce URL from torrent metadata
160/// * `info_hash_raw` - Raw 20-byte info hash
161/// * `my_peer_id` - Our 20-byte peer ID
162/// * `total_size` - Total torrent size in bytes
163///
164/// # Returns
165/// Vector of peer addresses from the tracker response.
166///
167/// # Errors
168/// Returns error if HTTP request fails, response parsing fails,
169/// or tracker indicates failure.
170pub async fn perform_http_tracker_announce(
171    announce_url: &str,
172    info_hash_raw: &[u8; 20],
173    my_peer_id: &[u8; 20],
174    total_size: u64,
175) -> Result<Vec<aria2_protocol::bittorrent::peer::connection::PeerAddr>> {
176    // Detect HTTPS for logging
177    let is_https = is_https_tracker(announce_url);
178    if is_https {
179        debug!("[BT] HTTPS tracker detected for announce: {}", announce_url);
180    }
181
182    let url = format!(
183        "{}?info_hash={}&peer_id={}&port=6881&uploaded=0&downloaded=0&left={}&event=started&compact=1",
184        announce_url,
185        urlencode_infohash(info_hash_raw),
186        urlencode_infohash(my_peer_id),
187        total_size,
188    );
189
190    info!("[BT] Announcing to tracker: {}", url);
191    let client = build_tracker_client(TRACKER_REQUEST_TIMEOUT_SECS).map_err(|e| {
192        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
193            message: format!("Failed to build tracker client: {}", e),
194        })
195    })?;
196
197    let resp = client.get(&url).send().await.map_err(|e| {
198        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
199            message: format!("Tracker HTTP failed: {}", e),
200        })
201    })?;
202    info!("[BT] Tracker response status: {}", resp.status());
203    let body = resp.bytes().await.map_err(|e| {
204        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
205            message: format!("Tracker body read failed: {}", e),
206        })
207    })?;
208    debug!("[BT] Tracker body: {:?}", String::from_utf8_lossy(&body));
209
210    let tracker_resp = aria2_protocol::bittorrent::tracker::response::TrackerResponse::parse(&body)
211        .map_err(|e| {
212            Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
213                message: format!("Tracker parse failed: {}", e),
214            })
215        })?;
216
217    info!("[BT] Tracker response: {} peers", tracker_resp.peer_count());
218    for peer in &tracker_resp.peers {
219        debug!("[BT]   Peer: {}:{}", peer.ip, peer.port);
220    }
221
222    if tracker_resp.is_failure() {
223        return Err(Aria2Error::Recoverable(
224            RecoverableError::TemporaryNetworkFailure {
225                message: tracker_resp.failure_reason.unwrap_or_default(),
226            },
227        ));
228    }
229
230    Ok(tracker_resp
231        .peers
232        .iter()
233        .map(|p| aria2_protocol::bittorrent::peer::connection::PeerAddr::new(&p.ip, p.port))
234        .collect())
235}
236
237/// Perform an announce with a specific tracker event (for state machine integration).
238///
239/// Use this for sending Completed and Stopped events at appropriate lifecycle points.
240pub async fn perform_announce_with_event(
241    announce_url: &str,
242    info_hash_raw: &[u8; 20],
243    my_peer_id: &[u8; 20],
244    downloaded: u64,
245    left: u64,
246    uploaded: u64,
247    event: TrackerEvent,
248) -> Result<()> {
249    let is_https = is_https_tracker(announce_url);
250
251    let event_str = event.as_str();
252    let event_param = if event_str.is_empty() {
253        String::new()
254    } else {
255        format!("&event={}", event_str)
256    };
257
258    let url = format!(
259        "{}?info_hash={}&peer_id={}&port=6881&uploaded={}downloaded={}left={}{}compact=1",
260        announce_url,
261        urlencode_infohash(info_hash_raw),
262        urlencode_infohash(my_peer_id),
263        uploaded,
264        downloaded,
265        left,
266        event_param,
267    );
268
269    info!(
270        "[BT] Announce to {} (event={}, https={})",
271        announce_url, event_str, is_https
272    );
273
274    let client = build_tracker_client(TRACKER_REQUEST_TIMEOUT_SECS).map_err(|e| {
275        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
276            message: format!("Failed to build tracker client: {}", e),
277        })
278    })?;
279
280    let resp = client.get(&url).send().await.map_err(|e| {
281        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
282            message: format!("Tracker HTTP failed: {}", e),
283        })
284    })?;
285
286    let body = resp.bytes().await.map_err(|e| {
287        Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
288            message: format!("Tracker body read failed: {}", e),
289        })
290    })?;
291
292    let tracker_resp = aria2_protocol::bittorrent::tracker::response::TrackerResponse::parse(&body)
293        .map_err(|e| {
294            Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
295                message: format!("Tracker parse failed: {}", e),
296            })
297        })?;
298
299    if tracker_resp.is_failure() {
300        return Err(Aria2Error::Recoverable(
301            RecoverableError::TemporaryNetworkFailure {
302                message: tracker_resp.failure_reason.unwrap_or_default(),
303            },
304        ));
305    }
306
307    info!("[BT] Announce success (event={})", event_str);
308    Ok(())
309}
310
311// ======================================================================
312// Multi-home Tracker with Failover
313// ======================================================================
314
315/// A single tracker entry with health tracking
316#[derive(Debug, Clone)]
317pub struct TrackerEntry {
318    pub url: String,
319    pub last_success: Option<Instant>,
320    pub last_failure: Option<Instant>,
321    pub failure_count: u32,
322    pub success_count: u32,
323    pub avg_response_ms: f64,
324    pub next_retry_after: Option<Instant>,
325}
326
327impl TrackerEntry {
328    /// Create a new tracker entry with default values
329    pub fn new(url: String) -> Self {
330        Self {
331            url,
332            last_success: None,
333            last_failure: None,
334            failure_count: 0,
335            success_count: 0,
336            avg_response_ms: 0.0,
337            next_retry_after: None,
338        }
339    }
340
341    /// Reliability score 0.0..1.0 based on success/failure ratio weighted by recency
342    pub fn reliability_score(&self) -> f64 {
343        let total = self.success_count + self.failure_count;
344        if total == 0 {
345            return 0.5; // unknown -> neutral
346        }
347        let base_score = self.success_count as f64 / (total as f64 + 1.0);
348        // Weight by recency: recent failure reduces score more
349        let recency_penalty = match self.last_failure {
350            Some(t) if t.elapsed().as_secs() < 300 => 0.3,
351            Some(_) => 0.1,
352            None => 0.0,
353        };
354        (base_score - recency_penalty).clamp(0.0, 1.0)
355    }
356
357    /// Record a successful response with latency measurement
358    pub fn record_success(&mut self, latency_ms: f64) {
359        self.success_count += 1;
360        self.last_success = Some(Instant::now());
361        self.failure_count = 0; // reset on success
362        if self.avg_response_ms <= 0.0 {
363            self.avg_response_ms = latency_ms;
364        } else {
365            self.avg_response_ms = self.avg_response_ms * 0.9 + latency_ms * 0.1;
366        }
367    }
368
369    /// Record a failed response and schedule backoff
370    pub fn record_failure(&mut self) {
371        self.failure_count += 1;
372        self.last_failure = Some(Instant::now());
373        self.schedule_backoff(10);
374    }
375
376    /// Exponential backoff: min(base * 2^failures, 3600s)
377    pub fn schedule_backoff(&mut self, base_secs: u64) {
378        let exp = self.failure_count.saturating_sub(1).min(10);
379        let delay = base_secs.saturating_mul(1 << exp);
380        let capped = delay.min(3600);
381        self.next_retry_after = Some(Instant::now() + Duration::from_secs(capped));
382    }
383
384    /// Check if this tracker is available for retry
385    pub fn is_available(&self) -> bool {
386        if let Some(retry_at) = self.next_retry_after {
387            Instant::now() >= retry_at
388        } else {
389            true
390        }
391    }
392}
393
394/// A tier of trackers tried in order
395#[derive(Debug, Clone)]
396pub struct TrackerTier {
397    pub trackers: Vec<TrackerEntry>,
398    pub current_index: usize,
399    pub consecutive_failures: u32,
400}
401
402impl TrackerTier {
403    /// Create a new tier from a list of tracker URLs
404    pub fn new(urls: Vec<String>) -> Self {
405        let trackers = urls.into_iter().map(TrackerEntry::new).collect();
406        Self {
407            trackers,
408            current_index: 0,
409            consecutive_failures: 0,
410        }
411    }
412
413    /// Select next available tracker within this tier, preferring higher reliability
414    pub fn select_next(&mut self) -> Option<&TrackerEntry> {
415        // First try current index if available
416        if self.current_index < self.trackers.len()
417            && self.trackers[self.current_index].is_available()
418        {
419            return Some(&self.trackers[self.current_index]);
420        }
421
422        // Find best available tracker by reliability score
423        let mut best_idx = None;
424        let mut best_score = -1.0f64;
425        for (i, t) in self.trackers.iter().enumerate() {
426            if t.is_available() {
427                let score = t.reliability_score();
428                if score > best_score {
429                    best_score = score;
430                    best_idx = Some(i);
431                }
432            }
433        }
434
435        if let Some(idx) = best_idx {
436            self.current_index = idx;
437            return Some(&self.trackers[idx]);
438        }
439
440        None // all unavailable
441    }
442
443    /// Mark the current tracker as successful
444    pub fn mark_current_success(&mut self, latency_ms: f64) {
445        if self.current_index < self.trackers.len() {
446            self.trackers[self.current_index].record_success(latency_ms);
447        }
448        self.consecutive_failures = 0;
449    }
450
451    /// Mark the current tracker as failed
452    pub fn mark_current_failure(&mut self) {
453        if self.current_index < self.trackers.len() {
454            self.trackers[self.current_index].record_failure();
455        }
456        self.consecutive_failures += 1;
457    }
458}
459
460/// Full announce list with multiple tiers for failover support
461#[derive(Debug, Clone)]
462pub struct AnnounceList {
463    pub tiers: Vec<TrackerTier>,
464    pub current_tier: usize,
465}
466
467impl AnnounceList {
468    /// Create announce list from C++ format or single announce string
469    ///
470    /// C++ format: announce-list = [[tier1-url1, tier1-url2], [tier2-url1]]
471    /// Single announce string becomes tier 0 with one entry
472    pub fn new(announce_list: &[Vec<String>], announce: &Option<String>) -> Self {
473        let mut tiers = Vec::new();
474        if !announce_list.is_empty() {
475            for tier_urls in announce_list {
476                tiers.push(TrackerTier::new(tier_urls.clone()));
477            }
478        } else if let Some(url) = announce {
479            tiers.push(TrackerTier::new(vec![url.clone()]));
480        }
481        Self {
482            tiers,
483            current_tier: 0,
484        }
485    }
486
487    /// Select next tracker across tiers with failover logic
488    pub fn select_next_tracker(&mut self) -> Option<(usize, usize)> {
489        if self.tiers.is_empty() {
490            return None;
491        }
492
493        // Try current tier first
494        if let Some(_entry) = self.tiers[self.current_tier].select_next() {
495            return Some((
496                self.current_tier,
497                self.tiers[self.current_tier].current_index,
498            ));
499        }
500
501        // Current tier exhausted -> try next tier
502        for offset in 1..=self.tiers.len() {
503            let tier_idx = (self.current_tier + offset) % self.tiers.len();
504            if let Some(_entry) = self.tiers[tier_idx].select_next() {
505                self.current_tier = tier_idx;
506                return Some((tier_idx, self.tiers[tier_idx].current_index));
507            }
508        }
509
510        None // all trackers unavailable
511    }
512
513    /// Record successful response for a specific tier
514    pub fn record_success(&mut self, tier_idx: usize, latency_ms: f64) {
515        if tier_idx < self.tiers.len() {
516            self.tiers[tier_idx].mark_current_success(latency_ms);
517        }
518    }
519
520    /// Record failed response for a specific tier
521    pub fn record_failure(&mut self, tier_idx: usize) {
522        if tier_idx < self.tiers.len() {
523            self.tiers[tier_idx].mark_current_failure();
524        }
525    }
526
527    /// Get the URL for a specific tracker by tier and entry index
528    pub fn get_tracker_url(&self, tier_idx: usize, entry_idx: usize) -> Option<&String> {
529        self.tiers
530            .get(tier_idx)
531            .and_then(|t| t.trackers.get(entry_idx))
532            .map(|e| &e.url)
533    }
534}
535
536// ======================================================================
537// Tests for Multi-home Tracker
538// ======================================================================
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn test_announce_list_creation() {
546        // Test from announce string
547        let list1 = AnnounceList::new(&[], &Some("http://tracker1.com/announce".to_string()));
548        assert_eq!(list1.tiers.len(), 1);
549        assert_eq!(list1.tiers[0].trackers.len(), 1);
550        assert_eq!(
551            list1.get_tracker_url(0, 0).unwrap(),
552            &"http://tracker1.com/announce".to_string()
553        );
554
555        // Test from multi-tier list
556        let multi_tier = vec![
557            vec![
558                "http://tier1-1.com/announce".to_string(),
559                "http://tier1-2.com/announce".to_string(),
560            ],
561            vec!["http://tier2-1.com/announce".to_string()],
562        ];
563        let list2 = AnnounceList::new(&multi_tier, &None);
564        assert_eq!(list2.tiers.len(), 2);
565        assert_eq!(list2.tiers[0].trackers.len(), 2);
566        assert_eq!(list2.tiers[1].trackers.len(), 1);
567
568        // Test empty case
569        let mut list3 = AnnounceList::new(&[], &None);
570        assert_eq!(list3.tiers.len(), 0);
571        assert!(list3.select_next_tracker().is_none());
572    }
573
574    #[test]
575    fn test_tier_selection_order() {
576        let mut tier = TrackerTier::new(vec![
577            "http://tracker-a.com".to_string(),
578            "http://tracker-b.com".to_string(),
579            "http://tracker-c.com".to_string(),
580        ]);
581
582        // Give tracker-b better reliability through simulated successes
583        tier.trackers[1].record_success(50.0);
584        tier.trackers[1].record_success(45.0);
585        tier.trackers[1].record_success(55.0);
586
587        // Give tracker-c some failures
588        tier.trackers[2].record_failure();
589
590        // Make tracker-a temporarily unavailable to force reliability-based selection
591        tier.trackers[0].record_failure();
592
593        // Selection should prefer higher reliability among available trackers
594        let selected = tier.select_next();
595        assert!(selected.is_some());
596        // tracker-b should be selected due to highest reliability score
597        assert_eq!(tier.current_index, 1);
598    }
599
600    #[test]
601    fn test_failover_across_tiers() {
602        let mut announce_list = AnnounceList::new(
603            &[
604                vec!["http://tier1-tracker.com/announce".to_string()],
605                vec!["http://tier2-tracker.com/announce".to_string()],
606            ],
607            &None,
608        );
609
610        // Initially should select from tier 0
611        let selection1 = announce_list.select_next_tracker();
612        assert_eq!(selection1, Some((0, 0)));
613
614        // Make tier 0 tracker fail multiple times to trigger backoff
615        announce_list.tiers[0].trackers[0].record_failure();
616        announce_list.tiers[0].trackers[0].record_failure();
617        announce_list.tiers[0].trackers[0].record_failure(); // Will have long backoff
618
619        // Now should failover to tier 1
620        let selection2 = announce_list.select_next_tracker();
621        assert_eq!(selection2, Some((1, 0)));
622        assert_eq!(announce_list.current_tier, 1);
623    }
624
625    #[test]
626    fn test_exponential_backoff_sequence() {
627        let mut entry = TrackerEntry::new("http://tracker.test/announce".to_string());
628
629        // Verify backoff sequence: 10 -> 20 -> 40 -> 80 -> 160 -> 320 -> 640 -> 1280 -> 2560 -> 3600 (capped)
630        let expected_delays = [10u64, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600];
631
632        for (i, &expected) in expected_delays.iter().enumerate() {
633            entry.record_failure();
634
635            // Calculate expected delay based on failure count
636            let base: u64 = 10;
637            let exp = entry.failure_count.saturating_sub(1).min(10);
638            let calculated_delay = base.saturating_mul(1 << exp).min(3600);
639            assert_eq!(
640                calculated_delay,
641                expected,
642                "Failure {}: expected {}s, got {}s",
643                i + 1,
644                expected,
645                calculated_delay
646            );
647        }
648
649        // After many failures, should be capped at 3600 seconds
650        assert!(entry.next_retry_after.is_some());
651    }
652
653    #[test]
654    fn test_reliability_scoring() {
655        let mut entry1 = TrackerEntry::new("http://good.tracker/announce".to_string());
656        let mut entry2 = TrackerEntry::new("http://bad.tracker/announce".to_string());
657        let entry3 = TrackerEntry::new("http://unknown.tracker/announce".to_string());
658
659        // Simulate good tracker with many successes
660        for _ in 0..10 {
661            entry1.record_success(100.0);
662        }
663
664        // Simulate bad tracker with many failures
665        for _ in 0..5 {
666            entry2.record_failure();
667        }
668
669        // Unknown tracker has no history
670
671        let score1 = entry1.reliability_score();
672        let score2 = entry2.reliability_score();
673        let score3 = entry3.reliability_score();
674
675        // Good tracker should have highest score
676        assert!(
677            score1 > score3,
678            "Good tracker ({}) should beat unknown ({})",
679            score1,
680            score3
681        );
682
683        // Bad tracker should have lowest score (penalized by recent failures)
684        assert!(
685            score3 > score2,
686            "Unknown ({}) should beat bad tracker ({})",
687            score3,
688            score2
689        );
690
691        // Good tracker should definitely beat bad tracker
692        assert!(
693            score1 > score2,
694            "Good tracker ({}) should beat bad tracker ({})",
695            score1,
696            score2
697        );
698    }
699}