1use std::net::SocketAddr;
7use std::time::{Duration, Instant};
8
9use serde::Serialize;
10use tokio::sync::mpsc;
11use tracing::{debug, warn};
12
13use irontide_core::{Id20, InfoHashes, TorrentMetaV1};
14use irontide_tracker::{AnnounceEvent, AnnounceRequest, HttpTracker, UdpTracker};
15
16const MAX_BACKOFF: Duration = Duration::from_mins(30); const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
21
22const DEFAULT_INTERVAL: Duration = Duration::from_mins(30); #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum TrackerProtocol {
28 Http,
29 Udp,
30}
31
32#[derive(Debug, Clone)]
34enum TrackerState {
35 NeedsAnnounce,
37 Active,
39 Failed { _error: String },
41}
42
43#[derive(Debug, Clone)]
45struct TrackerEntry {
46 url: String,
47 tier: usize,
48 protocol: TrackerProtocol,
49 state: TrackerState,
50 tracker_id: Option<String>,
51 next_announce: Instant,
52 interval: Duration,
53 backoff: Duration,
54 scrape_info: Option<irontide_tracker::ScrapeInfo>,
55 consecutive_failures: u32,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60pub enum TrackerStatus {
61 NotContacted,
63 Working,
65 Error,
67}
68
69#[derive(Debug, Clone, Serialize)]
71pub struct TrackerInfo {
72 pub url: String,
74 pub tier: usize,
76 pub status: TrackerStatus,
78 pub seeders: Option<u32>,
80 pub leechers: Option<u32>,
82 pub downloaded: Option<u32>,
84 pub next_announce_secs: u64,
86 pub consecutive_failures: u32,
88}
89
90#[derive(Debug, Clone)]
92pub(crate) struct TrackerOutcome {
93 pub url: String,
94 pub result: Result<usize, String>,
95}
96
97#[derive(Debug, Clone)]
99pub(crate) struct AnnounceResult {
100 pub peers: Vec<SocketAddr>,
101 pub outcomes: Vec<TrackerOutcome>,
102}
103
104#[derive(Debug)]
110pub(crate) struct TrackerPeerBatch {
111 pub tracker_idx: usize,
113 pub url: String,
115 pub result: Result<AnnounceOk, irontide_tracker::Error>,
117}
118
119pub(crate) type AnnounceOk = (
121 Vec<SocketAddr>,
122 u32,
123 Option<String>,
124 Option<u32>,
125 Option<u32>,
126);
127
128pub(crate) struct TrackerManager {
133 trackers: Vec<TrackerEntry>,
134 info_hash: Id20,
135 info_hashes: InfoHashes,
136 peer_id: Id20,
137 port: u16,
138 http_client: HttpTracker,
139 udp_client: UdpTracker,
140 anonymous_mode: bool,
141 dscp: u8,
142 i2p_destination: Option<String>,
144}
145
146impl TrackerManager {
147 #[cfg(test)]
152 pub fn from_torrent(meta: &TorrentMetaV1, peer_id: Id20, port: u16) -> Self {
153 let mut trackers = Vec::new();
154 let mut seen_urls = std::collections::HashSet::new();
155
156 if let Some(ref tiers) = meta.announce_list {
158 for (tier_idx, tier) in tiers.iter().enumerate() {
159 for url in tier {
160 let url = url.trim().to_string();
161 if url.is_empty() || !seen_urls.insert(url.clone()) {
162 continue;
163 }
164 if let Some(protocol) = classify_url(&url) {
165 trackers.push(TrackerEntry {
166 url,
167 tier: tier_idx,
168 protocol,
169 state: TrackerState::NeedsAnnounce,
170 tracker_id: None,
171 next_announce: Instant::now(),
172 interval: DEFAULT_INTERVAL,
173 backoff: Duration::ZERO,
174 scrape_info: None,
175 consecutive_failures: 0,
176 });
177 }
178 }
179 }
180 }
181
182 if let Some(ref url) = meta.announce {
184 let url = url.trim().to_string();
185 if !url.is_empty()
186 && seen_urls.insert(url.clone())
187 && let Some(protocol) = classify_url(&url)
188 {
189 trackers.push(TrackerEntry {
190 url,
191 tier: if trackers.is_empty() {
192 0
193 } else {
194 trackers.last().unwrap().tier + 1
195 },
196 protocol,
197 state: TrackerState::NeedsAnnounce,
198 tracker_id: None,
199 next_announce: Instant::now(),
200 interval: DEFAULT_INTERVAL,
201 backoff: Duration::ZERO,
202 scrape_info: None,
203 consecutive_failures: 0,
204 });
205 }
206 }
207
208 Self {
209 trackers,
210 info_hash: meta.info_hash,
211 info_hashes: InfoHashes::v1_only(meta.info_hash),
212 peer_id,
213 port,
214 http_client: HttpTracker::new(),
215 udp_client: UdpTracker::new(),
216 anonymous_mode: false,
217 dscp: 0,
218 i2p_destination: None,
219 }
220 }
221
222 #[allow(dead_code)] pub fn from_torrent_filtered(
229 meta: &TorrentMetaV1,
230 peer_id: Id20,
231 port: u16,
232 security: crate::url_guard::UrlSecurityConfig,
233 dscp: u8,
234 anonymous_mode: bool,
235 ) -> Self {
236 let mut trackers = Vec::new();
237 let mut seen_urls = std::collections::HashSet::new();
238
239 if let Some(ref tiers) = meta.announce_list {
241 for (tier_idx, tier) in tiers.iter().enumerate() {
242 for url in tier {
243 let url = url.trim().to_string();
244 if url.is_empty() || !seen_urls.insert(url.clone()) {
245 continue;
246 }
247 if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
248 warn!(%url, %e, "tracker URL rejected by security policy");
249 continue;
250 }
251 if let Some(protocol) = classify_url(&url) {
252 trackers.push(TrackerEntry {
253 url,
254 tier: tier_idx,
255 protocol,
256 state: TrackerState::NeedsAnnounce,
257 tracker_id: None,
258 next_announce: Instant::now(),
259 interval: DEFAULT_INTERVAL,
260 backoff: Duration::ZERO,
261 scrape_info: None,
262 consecutive_failures: 0,
263 });
264 }
265 }
266 }
267 }
268
269 if let Some(ref url) = meta.announce {
271 let url = url.trim().to_string();
272 if !url.is_empty() && seen_urls.insert(url.clone()) {
273 if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
274 warn!(%url, %e, "tracker URL rejected by security policy");
275 } else if let Some(protocol) = classify_url(&url) {
276 trackers.push(TrackerEntry {
277 url,
278 tier: if trackers.is_empty() {
279 0
280 } else {
281 trackers.last().unwrap().tier + 1
282 },
283 protocol,
284 state: TrackerState::NeedsAnnounce,
285 tracker_id: None,
286 next_announce: Instant::now(),
287 interval: DEFAULT_INTERVAL,
288 backoff: Duration::ZERO,
289 scrape_info: None,
290 consecutive_failures: 0,
291 });
292 }
293 }
294 }
295
296 Self {
297 trackers,
298 info_hash: meta.info_hash,
299 info_hashes: InfoHashes::v1_only(meta.info_hash),
300 peer_id,
301 port,
302 http_client: if anonymous_mode {
303 HttpTracker::with_anonymous()
304 } else {
305 HttpTracker::new()
306 },
307 udp_client: UdpTracker::new().with_dscp(dscp),
308 anonymous_mode,
309 dscp,
310 i2p_destination: None,
311 }
312 }
313
314 pub fn empty(
316 info_hash: Id20,
317 peer_id: Id20,
318 port: u16,
319 dscp: u8,
320 anonymous_mode: bool,
321 ) -> Self {
322 Self {
323 trackers: Vec::new(),
324 info_hash,
325 info_hashes: InfoHashes::v1_only(info_hash),
326 peer_id,
327 port,
328 http_client: if anonymous_mode {
329 HttpTracker::with_anonymous()
330 } else {
331 HttpTracker::new()
332 },
333 udp_client: UdpTracker::new().with_dscp(dscp),
334 anonymous_mode,
335 dscp,
336 i2p_destination: None,
337 }
338 }
339
340 #[cfg(test)]
342 pub fn set_metadata(&mut self, meta: &TorrentMetaV1) {
343 let fresh = Self::from_torrent(meta, self.peer_id, self.port);
344 self.trackers = fresh.trackers;
345 }
346
347 pub fn set_metadata_filtered(
349 &mut self,
350 meta: &TorrentMetaV1,
351 security: crate::url_guard::UrlSecurityConfig,
352 ) {
353 let fresh = Self::from_torrent_filtered(
354 meta,
355 self.peer_id,
356 self.port,
357 security,
358 self.dscp,
359 self.anonymous_mode,
360 );
361 self.trackers = fresh.trackers;
362 }
363
364 pub fn set_info_hashes(&mut self, info_hashes: InfoHashes) {
366 self.info_hashes = info_hashes;
367 }
368
369 pub fn set_i2p_destination(&mut self, dest: Option<String>) {
371 self.i2p_destination = dest;
372 }
373
374 #[cfg(test)]
376 pub fn tracker_count(&self) -> usize {
377 self.trackers.len()
378 }
379
380 pub fn next_announce_in(&self) -> Option<Duration> {
384 self.trackers
385 .iter()
386 .map(|t| t.next_announce.saturating_duration_since(Instant::now()))
387 .min()
388 }
389
390 pub async fn announce(
395 &mut self,
396 event: AnnounceEvent,
397 uploaded: u64,
398 downloaded: u64,
399 left: u64,
400 ) -> AnnounceResult {
401 let mut all_peers = Vec::new();
402 let mut seen_peers = std::collections::HashSet::new();
403 let mut all_outcomes = Vec::new();
404
405 let result = self
407 .announce_with_hash(self.info_hash, event, uploaded, downloaded, left)
408 .await;
409 for peer in result.peers {
410 if seen_peers.insert(peer) {
411 all_peers.push(peer);
412 }
413 }
414 all_outcomes.extend(result.outcomes);
415
416 if self.info_hashes.is_hybrid()
418 && let Some(v2) = self.info_hashes.v2
419 {
420 let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
421 if v2_as_v1 != self.info_hash {
423 let result = self
424 .announce_with_hash(v2_as_v1, event, uploaded, downloaded, left)
425 .await;
426 for peer in result.peers {
427 if seen_peers.insert(peer) {
428 all_peers.push(peer);
429 }
430 }
431 all_outcomes.extend(result.outcomes);
432 }
433 }
434
435 AnnounceResult {
436 peers: all_peers,
437 outcomes: all_outcomes,
438 }
439 }
440
441 fn announce_port(&self) -> u16 {
443 if self.anonymous_mode { 0 } else { self.port }
444 }
445
446 async fn announce_with_hash(
448 &mut self,
449 hash: Id20,
450 event: AnnounceEvent,
451 uploaded: u64,
452 downloaded: u64,
453 left: u64,
454 ) -> AnnounceResult {
455 let req = AnnounceRequest {
456 info_hash: hash,
457 peer_id: self.peer_id,
458 port: self.announce_port(),
459 uploaded,
460 downloaded,
461 left,
462 event,
463 num_want: None,
464 compact: true,
465 i2p_destination: self.i2p_destination.clone(),
466 };
467 let now = Instant::now();
468
469 let mut join_set =
471 tokio::task::JoinSet::<(usize, Result<AnnounceOk, irontide_tracker::Error>)>::new();
472
473 for (idx, tracker) in self.trackers.iter().enumerate() {
474 if hash == self.info_hash && tracker.next_announce > now {
477 continue;
478 }
479
480 let http_client = self.http_client.clone();
481 let udp_client = self.udp_client.clone();
482 let url = tracker.url.clone();
483 let protocol = tracker.protocol;
484 let req = req.clone();
485
486 join_set.spawn(async move {
487 let result = match protocol {
488 TrackerProtocol::Http => Self::announce_http(&http_client, &url, &req).await,
489 TrackerProtocol::Udp => Self::announce_udp(&udp_client, &url, &req).await,
490 };
491 (idx, result)
492 });
493 }
494
495 let mut all_peers = Vec::new();
497 let mut seen_peers = std::collections::HashSet::new();
498 let mut outcomes = Vec::new();
499
500 while let Some(Ok((idx, result))) = join_set.join_next().await {
501 let tracker = &mut self.trackers[idx];
502 match result {
503 Ok((peers, interval, tracker_id, seeders, leechers)) => {
504 let num_peers = peers.len();
505 debug!(
506 url = %tracker.url,
507 peer_count = num_peers,
508 interval,
509 %hash,
510 "tracker announce success"
511 );
512 if hash == self.info_hash {
514 tracker.state = TrackerState::Active;
515 tracker.interval = Duration::from_secs(u64::from(interval));
516 tracker.next_announce = now + tracker.interval;
517 tracker.backoff = Duration::ZERO;
518 tracker.consecutive_failures = 0;
519 if let Some(id) = tracker_id {
520 tracker.tracker_id = Some(id);
521 }
522 if seeders.is_some() || leechers.is_some() {
523 let prev_downloaded = tracker.scrape_info.map_or(0, |s| s.downloaded);
524 tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
525 complete: seeders.unwrap_or(0),
526 incomplete: leechers.unwrap_or(0),
527 downloaded: prev_downloaded,
528 });
529 }
530 }
531
532 for peer in peers {
533 if seen_peers.insert(peer) {
534 all_peers.push(peer);
535 }
536 }
537 outcomes.push(TrackerOutcome {
538 url: tracker.url.clone(),
539 result: Ok(num_peers),
540 });
541 }
542 Err(e) => {
543 let msg = e.to_string();
544 let retry_floor = match &e {
545 irontide_tracker::Error::TrackerError {
546 retry_in: Some(secs),
547 ..
548 } => Duration::from_secs(u64::from(*secs)),
549 _ => Duration::ZERO,
550 };
551 warn!(url = %tracker.url, error = %msg, %hash, "tracker announce failed");
552 if hash == self.info_hash {
554 tracker.state = TrackerState::Failed {
555 _error: msg.clone(),
556 };
557 tracker.consecutive_failures += 1;
558 tracker.backoff = if tracker.backoff.is_zero() {
559 INITIAL_BACKOFF
560 } else {
561 (tracker.backoff * 2).min(MAX_BACKOFF)
562 };
563 tracker.backoff = tracker.backoff.max(retry_floor);
564 tracker.next_announce = now + tracker.backoff;
565 }
566 outcomes.push(TrackerOutcome {
567 url: tracker.url.clone(),
568 result: Err(msg),
569 });
570 }
571 }
572 }
573
574 AnnounceResult {
575 peers: all_peers,
576 outcomes,
577 }
578 }
579
580 #[allow(dead_code)]
582 pub async fn announce_started(
583 &mut self,
584 uploaded: u64,
585 downloaded: u64,
586 left: u64,
587 ) -> AnnounceResult {
588 self.announce(AnnounceEvent::Started, uploaded, downloaded, left)
589 .await
590 }
591
592 pub async fn announce_completed(&mut self, uploaded: u64, downloaded: u64) -> AnnounceResult {
594 self.announce(AnnounceEvent::Completed, uploaded, downloaded, 0)
595 .await
596 }
597
598 pub async fn announce_stopped(&mut self, uploaded: u64, downloaded: u64, left: u64) {
600 let _ = self
601 .announce(AnnounceEvent::Stopped, uploaded, downloaded, left)
602 .await;
603 }
604
605 pub fn start_announce(
617 &mut self,
618 event: AnnounceEvent,
619 uploaded: u64,
620 downloaded: u64,
621 left: u64,
622 ) -> mpsc::Receiver<TrackerPeerBatch> {
623 let (tx, rx) = mpsc::channel(32);
624 let now = Instant::now();
625
626 let mut tasks: Vec<(usize, String, TrackerProtocol)> = Vec::new();
628 for (idx, tracker) in self.trackers.iter().enumerate() {
629 if tracker.next_announce > now {
630 continue;
631 }
632 tasks.push((idx, tracker.url.clone(), tracker.protocol));
633 }
634
635 for &(idx, _, _) in &tasks {
639 self.trackers[idx].next_announce = now + Duration::from_mins(2);
640 }
641
642 if tasks.is_empty() {
644 return rx;
645 }
646
647 let info_hash = self.info_hash;
648 let info_hashes = self.info_hashes.clone();
649 let peer_id = self.peer_id;
650 let port = self.announce_port();
651 let i2p_dest = self.i2p_destination.clone();
652 let http_client = self.http_client.clone();
653 let udp_client = self.udp_client.clone();
654
655 tokio::spawn(async move {
656 let req = AnnounceRequest {
657 info_hash,
658 peer_id,
659 port,
660 uploaded,
661 downloaded,
662 left,
663 event,
664 num_want: None,
665 compact: true,
666 i2p_destination: i2p_dest.clone(),
667 };
668
669 Self::spawn_tracker_announces(&tx, &tasks, &req, &http_client, &udp_client).await;
671
672 if info_hashes.is_hybrid()
674 && let Some(v2) = info_hashes.v2
675 {
676 let v2_as_v1 = Id20(v2.0[..20].try_into().expect("Id32 always has >= 20 bytes"));
677 if v2_as_v1 != info_hash {
678 let mut v2_req = req;
679 v2_req.info_hash = v2_as_v1;
680 Self::spawn_tracker_announces(&tx, &tasks, &v2_req, &http_client, &udp_client)
681 .await;
682 }
683 }
684 });
686
687 rx
688 }
689
690 async fn spawn_tracker_announces(
693 tx: &mpsc::Sender<TrackerPeerBatch>,
694 tasks: &[(usize, String, TrackerProtocol)],
695 req: &AnnounceRequest,
696 http_client: &HttpTracker,
697 udp_client: &UdpTracker,
698 ) {
699 let mut join_set = tokio::task::JoinSet::new();
700
701 for &(idx, ref url, protocol) in tasks {
702 let http = http_client.clone();
703 let udp = udp_client.clone();
704 let url = url.clone();
705 let req = req.clone();
706
707 join_set.spawn(async move {
708 let result = match protocol {
709 TrackerProtocol::Http => Self::announce_http(&http, &url, &req).await,
710 TrackerProtocol::Udp => Self::announce_udp(&udp, &url, &req).await,
711 };
712 TrackerPeerBatch {
713 tracker_idx: idx,
714 url,
715 result,
716 }
717 });
718 }
719
720 while let Some(join_result) = join_set.join_next().await {
721 if let Ok(batch) = join_result {
722 if tx.send(batch).await.is_err() {
724 break;
725 }
726 }
727 }
728 }
729
730 pub fn process_tracker_result(
735 &mut self,
736 batch: TrackerPeerBatch,
737 ) -> (Vec<SocketAddr>, TrackerOutcome) {
738 let Some(tracker) = self.trackers.get_mut(batch.tracker_idx) else {
739 return (
741 Vec::new(),
742 TrackerOutcome {
743 url: batch.url,
744 result: Err("tracker removed during announce".to_string()),
745 },
746 );
747 };
748
749 match batch.result {
750 Ok((peers, interval, tracker_id, seeders, leechers)) => {
751 let num_peers = peers.len();
752 debug!(
753 url = %tracker.url,
754 peer_count = num_peers,
755 interval,
756 "streaming tracker announce success"
757 );
758 tracker.state = TrackerState::Active;
759 tracker.interval = Duration::from_secs(u64::from(interval));
760 tracker.next_announce = Instant::now() + tracker.interval;
761 tracker.backoff = Duration::ZERO;
762 tracker.consecutive_failures = 0;
763 if let Some(id) = tracker_id {
764 tracker.tracker_id = Some(id);
765 }
766 if seeders.is_some() || leechers.is_some() {
767 let prev_downloaded = tracker.scrape_info.map_or(0, |s| s.downloaded);
768 tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
769 complete: seeders.unwrap_or(0),
770 incomplete: leechers.unwrap_or(0),
771 downloaded: prev_downloaded,
772 });
773 }
774
775 let outcome = TrackerOutcome {
776 url: batch.url,
777 result: Ok(num_peers),
778 };
779 (peers, outcome)
780 }
781 Err(e) => {
782 let msg = e.to_string();
783 let retry_floor = match &e {
784 irontide_tracker::Error::TrackerError {
785 retry_in: Some(secs),
786 ..
787 } => Duration::from_secs(u64::from(*secs)),
788 _ => Duration::ZERO,
789 };
790 warn!(url = %tracker.url, error = %msg, "streaming tracker announce failed");
791 tracker.state = TrackerState::Failed {
792 _error: msg.clone(),
793 };
794 tracker.consecutive_failures = tracker.consecutive_failures.saturating_add(1);
795 tracker.backoff = if tracker.backoff.is_zero() {
796 INITIAL_BACKOFF
797 } else {
798 (tracker.backoff.saturating_mul(2)).min(MAX_BACKOFF)
799 };
800 tracker.backoff = tracker.backoff.max(retry_floor);
801 tracker.next_announce = Instant::now() + tracker.backoff;
802
803 let outcome = TrackerOutcome {
804 url: batch.url,
805 result: Err(msg),
806 };
807 (Vec::new(), outcome)
808 }
809 }
810 }
811
812 async fn announce_http(
815 client: &HttpTracker,
816 url: &str,
817 req: &AnnounceRequest,
818 ) -> Result<AnnounceOk, irontide_tracker::Error> {
819 let resp = client.announce(url, req).await?;
820 Ok((
821 resp.response.peers,
822 resp.response.interval,
823 resp.tracker_id,
824 resp.response.seeders,
825 resp.response.leechers,
826 ))
827 }
828
829 async fn announce_udp(
830 client: &UdpTracker,
831 url: &str,
832 req: &AnnounceRequest,
833 ) -> Result<AnnounceOk, irontide_tracker::Error> {
834 let addr = parse_udp_addr(url);
837 let resp = client.announce(&addr, req).await?;
838 Ok((
839 resp.response.peers,
840 resp.response.interval,
841 None,
842 resp.response.seeders,
843 resp.response.leechers,
844 ))
845 }
846
847 pub fn tracker_list(&self) -> Vec<TrackerInfo> {
851 self.trackers
852 .iter()
853 .map(|t| {
854 let status = match t.state {
855 TrackerState::NeedsAnnounce => TrackerStatus::NotContacted,
856 TrackerState::Active => TrackerStatus::Working,
857 TrackerState::Failed { .. } => TrackerStatus::Error,
858 };
859 TrackerInfo {
860 url: t.url.clone(),
861 tier: t.tier,
862 status,
863 seeders: t.scrape_info.map(|s| s.complete),
864 leechers: t.scrape_info.map(|s| s.incomplete),
865 downloaded: t.scrape_info.map(|s| s.downloaded),
866 next_announce_secs: t
867 .next_announce
868 .saturating_duration_since(Instant::now())
869 .as_secs(),
870 consecutive_failures: t.consecutive_failures,
871 }
872 })
873 .collect()
874 }
875
876 pub fn force_reannounce(&mut self) {
878 let now = Instant::now();
879 for tracker in &mut self.trackers {
880 tracker.next_announce = now;
881 }
882 }
883
884 pub fn add_tracker_url(&mut self, url: &str) -> bool {
888 let url = url.trim();
889 if url.is_empty() {
890 return false;
891 }
892 let Some(protocol) = classify_url(url) else {
893 return false;
894 };
895 if self.trackers.iter().any(|t| t.url == url) {
897 return false;
898 }
899 let new_tier = self.trackers.last().map_or(0, |t| t.tier + 1);
900 self.trackers.push(TrackerEntry {
901 url: url.to_string(),
902 tier: new_tier,
903 protocol,
904 state: TrackerState::NeedsAnnounce,
905 tracker_id: None,
906 next_announce: Instant::now(),
907 interval: DEFAULT_INTERVAL,
908 backoff: Duration::ZERO,
909 scrape_info: None,
910 consecutive_failures: 0,
911 });
912 true
913 }
914
915 pub fn replace_all(&mut self, urls: &[String]) {
920 self.trackers.clear();
921 for url in urls {
922 self.add_tracker_url(url);
923 }
924 }
925
926 #[allow(dead_code)] pub fn add_tracker_url_validated(
932 &mut self,
933 url: &str,
934 security: crate::url_guard::UrlSecurityConfig,
935 ) -> bool {
936 let url = url.trim();
937 if url.is_empty() {
938 return false;
939 }
940 if let Err(e) = crate::url_guard::validate_tracker_url(url, security) {
941 warn!(%url, %e, "tracker URL rejected by security policy");
942 return false;
943 }
944 self.add_tracker_url(url)
945 }
946
947 pub async fn scrape(&self) -> Option<(String, irontide_tracker::ScrapeInfo)> {
951 for tracker in &self.trackers {
952 let result = match tracker.protocol {
953 TrackerProtocol::Http => self
954 .http_client
955 .scrape(&tracker.url, &[self.info_hash])
956 .await
957 .ok()
958 .and_then(|resp| resp.files.get(&self.info_hash).copied()),
959 TrackerProtocol::Udp => {
960 let addr = parse_udp_addr(&tracker.url);
961 self.udp_client
962 .scrape(&addr, &[self.info_hash])
963 .await
964 .ok()
965 .and_then(|resp| resp.results.into_iter().next())
966 }
967 };
968 if let Some(info) = result {
969 return Some((tracker.url.clone(), info));
970 }
971 }
972 None
973 }
974}
975
976fn classify_url(url: &str) -> Option<TrackerProtocol> {
978 if url.starts_with("http://") || url.starts_with("https://") {
979 Some(TrackerProtocol::Http)
980 } else if url.starts_with("udp://") {
981 Some(TrackerProtocol::Udp)
982 } else {
983 None }
985}
986
987fn parse_udp_addr(url: &str) -> String {
992 let without_scheme = url.strip_prefix("udp://").unwrap_or(url);
993 match without_scheme.find('/') {
995 Some(idx) => without_scheme[..idx].to_string(),
996 None => without_scheme.to_string(),
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use irontide_core::{Id20, InfoHashes};
1004
1005 fn torrent_with_trackers(
1007 announce: Option<&str>,
1008 announce_list: Option<Vec<Vec<&str>>>,
1009 ) -> TorrentMetaV1 {
1010 use serde::Serialize;
1011
1012 #[derive(Serialize)]
1013 struct Info<'a> {
1014 length: u64,
1015 name: &'a str,
1016 #[serde(rename = "piece length")]
1017 piece_length: u64,
1018 #[serde(with = "serde_bytes")]
1019 pieces: &'a [u8],
1020 }
1021
1022 #[derive(Serialize)]
1023 struct Torrent<'a> {
1024 #[serde(skip_serializing_if = "Option::is_none")]
1025 announce: Option<&'a str>,
1026 info: Info<'a>,
1027 }
1028
1029 let data = vec![0u8; 16384];
1030 let hash = irontide_core::sha1(&data);
1031 let mut pieces = Vec::new();
1032 pieces.extend_from_slice(hash.as_bytes());
1033
1034 let t = Torrent {
1035 announce,
1036 info: Info {
1037 length: 16384,
1038 name: "test",
1039 piece_length: 16384,
1040 pieces: &pieces,
1041 },
1042 };
1043
1044 let bytes = irontide_bencode::to_bytes(&t).unwrap();
1045 let mut meta = irontide_core::torrent_from_bytes(&bytes).unwrap();
1046 meta.announce_list = announce_list.map(|tiers| {
1047 tiers
1048 .into_iter()
1049 .map(|tier| tier.into_iter().map(String::from).collect())
1050 .collect()
1051 });
1052 if announce.is_some() {
1053 meta.announce = announce.map(String::from);
1054 }
1055 meta
1056 }
1057
1058 fn test_peer_id() -> Id20 {
1059 Id20::from_hex("0102030405060708091011121314151617181920").unwrap()
1060 }
1061
1062 #[test]
1063 fn parse_single_announce_url() {
1064 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1065 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1066 assert_eq!(mgr.tracker_count(), 1);
1067 assert_eq!(mgr.trackers[0].protocol, TrackerProtocol::Http);
1068 assert_eq!(mgr.trackers[0].tier, 0);
1069 }
1070
1071 #[test]
1072 fn parse_announce_list_tiers() {
1073 let meta = torrent_with_trackers(
1074 None,
1075 Some(vec![
1076 vec![
1077 "http://tier0-a.example.com/announce",
1078 "http://tier0-b.example.com/announce",
1079 ],
1080 vec!["udp://tier1.example.com:6969/announce"],
1081 ]),
1082 );
1083 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1084 assert_eq!(mgr.tracker_count(), 3);
1085 assert_eq!(mgr.trackers[0].tier, 0);
1086 assert_eq!(mgr.trackers[1].tier, 0);
1087 assert_eq!(mgr.trackers[2].tier, 1);
1088 assert_eq!(mgr.trackers[2].protocol, TrackerProtocol::Udp);
1089 }
1090
1091 #[test]
1092 fn classify_http_url() {
1093 assert_eq!(classify_url("http://t.co/a"), Some(TrackerProtocol::Http));
1094 assert_eq!(classify_url("https://t.co/a"), Some(TrackerProtocol::Http));
1095 }
1096
1097 #[test]
1098 fn classify_udp_url() {
1099 assert_eq!(
1100 classify_url("udp://t.co:6969/a"),
1101 Some(TrackerProtocol::Udp)
1102 );
1103 }
1104
1105 #[test]
1106 fn classify_unknown_url() {
1107 assert_eq!(classify_url("wss://t.co/a"), None);
1108 }
1109
1110 #[test]
1111 fn deduplicate_urls() {
1112 let meta = torrent_with_trackers(
1113 Some("http://tracker.example.com/announce"),
1114 Some(vec![vec!["http://tracker.example.com/announce"]]),
1115 );
1116 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1117 assert_eq!(mgr.tracker_count(), 1);
1119 }
1120
1121 #[test]
1122 fn empty_announce_list() {
1123 let meta = torrent_with_trackers(None, None);
1124 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1125 assert_eq!(mgr.tracker_count(), 0);
1126 assert_eq!(mgr.next_announce_in(), None);
1127 }
1128
1129 #[test]
1130 fn next_announce_timing() {
1131 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1132 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1133 let next = mgr.next_announce_in().unwrap();
1135 assert!(next <= Duration::from_millis(10));
1136 }
1137
1138 #[test]
1139 fn backoff_on_failure() {
1140 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1141 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1142
1143 mgr.trackers[0].state = TrackerState::Failed {
1145 _error: "connection refused".into(),
1146 };
1147 mgr.trackers[0].backoff = INITIAL_BACKOFF;
1148 mgr.trackers[0].next_announce = Instant::now() + INITIAL_BACKOFF;
1149
1150 let next = mgr.next_announce_in().unwrap();
1151 assert!(next >= Duration::from_secs(29));
1153 assert!(next <= Duration::from_secs(31));
1154 }
1155
1156 #[test]
1157 fn backoff_max_cap() {
1158 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1159 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1160
1161 mgr.trackers[0].backoff = Duration::from_mins(20); let doubled = (mgr.trackers[0].backoff * 2).min(MAX_BACKOFF);
1165 assert_eq!(doubled, MAX_BACKOFF);
1166 }
1167
1168 #[test]
1169 fn parse_udp_addr_strips_scheme_and_path() {
1170 assert_eq!(
1171 parse_udp_addr("udp://tracker.example.com:6969/announce"),
1172 "tracker.example.com:6969"
1173 );
1174 assert_eq!(parse_udp_addr("udp://example.com:1234"), "example.com:1234");
1175 }
1176
1177 #[test]
1178 fn empty_manager_for_magnet() {
1179 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1180 let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1181 assert_eq!(mgr.tracker_count(), 0);
1182 }
1183
1184 #[test]
1185 fn set_metadata_populates_trackers() {
1186 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1187 let mut mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1188 assert_eq!(mgr.tracker_count(), 0);
1189
1190 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1191 mgr.set_metadata(&meta);
1192 assert_eq!(mgr.tracker_count(), 1);
1193 }
1194
1195 #[test]
1196 fn tracker_list_returns_info() {
1197 let meta = torrent_with_trackers(
1198 None,
1199 Some(vec![
1200 vec!["http://tracker1.example.com/announce"],
1201 vec!["udp://tracker2.example.com:6969/announce"],
1202 ]),
1203 );
1204 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1205 let list = mgr.tracker_list();
1206 assert_eq!(list.len(), 2);
1207 assert_eq!(list[0].status, TrackerStatus::NotContacted);
1208 assert_eq!(list[0].seeders, None);
1209 assert_eq!(list[0].tier, 0);
1210 assert_eq!(list[1].tier, 1);
1211 }
1212
1213 #[test]
1214 fn force_reannounce_resets_timers() {
1215 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1216 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1217 mgr.trackers[0].next_announce = Instant::now() + Duration::from_hours(1);
1219 assert!(mgr.next_announce_in().unwrap() > Duration::from_secs(3500));
1220 mgr.force_reannounce();
1221 let next = mgr.next_announce_in().unwrap();
1222 assert!(next <= Duration::from_millis(10));
1223 }
1224
1225 #[test]
1226 fn add_tracker_url_new() {
1227 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1228 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1229 assert_eq!(mgr.tracker_count(), 1);
1230 let added = mgr.add_tracker_url("http://new-tracker.example.com/announce");
1231 assert!(added);
1232 assert_eq!(mgr.tracker_count(), 2);
1233 assert_eq!(mgr.trackers[1].tier, 1); }
1235
1236 #[test]
1237 fn add_tracker_url_duplicate() {
1238 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1239 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1240 let added = mgr.add_tracker_url("http://tracker.example.com/announce");
1241 assert!(!added);
1242 assert_eq!(mgr.tracker_count(), 1);
1243 }
1244
1245 #[test]
1246 fn tracker_manager_stores_info_hashes() {
1247 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1248 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1249 assert!(!mgr.info_hashes.is_hybrid());
1250
1251 let v2 = irontide_core::Id32::from_hex(
1252 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1253 )
1254 .unwrap();
1255 mgr.set_info_hashes(InfoHashes::hybrid(meta.info_hash, v2));
1256 assert!(mgr.info_hashes.is_hybrid());
1257 }
1258
1259 #[test]
1260 fn add_tracker_url_empty() {
1261 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1262 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1263 assert!(!mgr.add_tracker_url(""));
1264 assert!(!mgr.add_tracker_url(" "));
1265 assert_eq!(mgr.tracker_count(), 1);
1266 }
1267
1268 fn ssrf_config() -> crate::url_guard::UrlSecurityConfig {
1269 crate::url_guard::UrlSecurityConfig {
1270 ssrf_mitigation: true,
1271 allow_idna: false,
1272 validate_https_trackers: true,
1273 }
1274 }
1275
1276 #[test]
1277 fn localhost_tracker_announce_path_accepted() {
1278 let meta = torrent_with_trackers(Some("http://127.0.0.1:8080/announce"), None);
1280 let cfg = ssrf_config();
1281 let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1282 assert_eq!(mgr.tracker_count(), 1);
1283 assert_eq!(mgr.trackers[0].url, "http://127.0.0.1:8080/announce");
1284 }
1285
1286 #[test]
1287 fn localhost_tracker_bad_path_filtered() {
1288 let meta = torrent_with_trackers(
1291 None,
1292 Some(vec![vec![
1293 "http://127.0.0.1:8080/api/admin",
1294 "http://tracker.example.com/announce",
1295 ]]),
1296 );
1297 let cfg = ssrf_config();
1298 let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1299 assert_eq!(mgr.tracker_count(), 1);
1300 assert_eq!(mgr.trackers[0].url, "http://tracker.example.com/announce");
1301 }
1302
1303 #[test]
1304 fn add_tracker_url_validates() {
1305 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1306 let cfg = ssrf_config();
1307 let mut mgr =
1308 TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, false);
1309 assert_eq!(mgr.tracker_count(), 1);
1310
1311 assert!(mgr.add_tracker_url_validated("http://other.example.com/announce", cfg));
1313 assert_eq!(mgr.tracker_count(), 2);
1314
1315 assert!(!mgr.add_tracker_url_validated("http://127.0.0.1:8080/api/admin", cfg));
1317 assert_eq!(mgr.tracker_count(), 2);
1318
1319 assert!(mgr.add_tracker_url_validated("udp://tracker.example.com:6969/announce", cfg));
1321 assert_eq!(mgr.tracker_count(), 3);
1322 }
1323
1324 #[test]
1325 fn anonymous_mode_zeroes_announce_port() {
1326 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1327 let cfg = ssrf_config();
1328 let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, cfg, 0, true);
1329 assert!(mgr.anonymous_mode);
1330 assert_eq!(mgr.announce_port(), 0);
1331 }
1332
1333 #[test]
1334 fn normal_mode_includes_port() {
1335 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1336 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1337 assert!(!mgr.anonymous_mode);
1338 assert_eq!(mgr.announce_port(), 6881);
1339 }
1340
1341 #[test]
1342 fn empty_manager_with_dscp_and_anonymous() {
1343 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1344 let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0x2E, true);
1345 assert!(mgr.anonymous_mode);
1346 assert_eq!(mgr.dscp, 0x2E);
1347 assert_eq!(mgr.announce_port(), 0);
1348 }
1349
1350 #[test]
1351 fn failure_with_retry_in_floors_backoff() {
1352 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1353 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1354
1355 let batch = TrackerPeerBatch {
1356 tracker_idx: 0,
1357 url: "http://tracker.example.com/announce".into(),
1358 result: Err(irontide_tracker::Error::TrackerError {
1359 message: "rate limited".into(),
1360 retry_in: Some(120),
1361 }),
1362 };
1363
1364 let (_peers, outcome) = mgr.process_tracker_result(batch);
1365 assert!(outcome.result.is_err());
1366 assert!(mgr.trackers[0].backoff >= Duration::from_mins(2));
1367 }
1368
1369 #[test]
1370 fn failure_without_retry_in_uses_exponential() {
1371 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1372 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1373
1374 let batch = TrackerPeerBatch {
1375 tracker_idx: 0,
1376 url: "http://tracker.example.com/announce".into(),
1377 result: Err(irontide_tracker::Error::TrackerError {
1378 message: "connection refused".into(),
1379 retry_in: None,
1380 }),
1381 };
1382
1383 let (_peers, outcome) = mgr.process_tracker_result(batch);
1384 assert!(outcome.result.is_err());
1385 assert_eq!(mgr.trackers[0].backoff, INITIAL_BACKOFF);
1386 }
1387
1388 #[test]
1389 fn success_with_min_interval_floors_reannounce() {
1390 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1391 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1392
1393 let batch = TrackerPeerBatch {
1396 tracker_idx: 0,
1397 url: "http://tracker.example.com/announce".into(),
1398 result: Ok((
1399 vec!["192.168.1.1:6881".parse().unwrap()],
1400 1800, None,
1402 Some(10),
1403 Some(5),
1404 )),
1405 };
1406
1407 let (peers, outcome) = mgr.process_tracker_result(batch);
1408 assert_eq!(peers.len(), 1);
1409 assert!(outcome.result.is_ok());
1410 assert_eq!(mgr.trackers[0].interval, Duration::from_mins(30));
1411 }
1412}