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_secs(30 * 60); const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
21
22const DEFAULT_INTERVAL: Duration = Duration::from_secs(30 * 60); #[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 TrackerManager {
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 TrackerManager {
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 TrackerManager {
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(interval as u64);
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 =
524 tracker.scrape_info.map(|s| s.downloaded).unwrap_or(0);
525 tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
526 complete: seeders.unwrap_or(0),
527 incomplete: leechers.unwrap_or(0),
528 downloaded: prev_downloaded,
529 });
530 }
531 }
532
533 for peer in peers {
534 if seen_peers.insert(peer) {
535 all_peers.push(peer);
536 }
537 }
538 outcomes.push(TrackerOutcome {
539 url: tracker.url.clone(),
540 result: Ok(num_peers),
541 });
542 }
543 Err(e) => {
544 let msg = e.to_string();
545 warn!(url = %tracker.url, error = %msg, %hash, "tracker announce failed");
546 if hash == self.info_hash {
548 tracker.state = TrackerState::Failed {
549 _error: msg.clone(),
550 };
551 tracker.consecutive_failures += 1;
552 tracker.backoff = if tracker.backoff.is_zero() {
553 INITIAL_BACKOFF
554 } else {
555 (tracker.backoff * 2).min(MAX_BACKOFF)
556 };
557 tracker.next_announce = now + tracker.backoff;
558 }
559 outcomes.push(TrackerOutcome {
560 url: tracker.url.clone(),
561 result: Err(msg),
562 });
563 }
564 }
565 }
566
567 AnnounceResult {
568 peers: all_peers,
569 outcomes,
570 }
571 }
572
573 #[allow(dead_code)]
575 pub async fn announce_started(
576 &mut self,
577 uploaded: u64,
578 downloaded: u64,
579 left: u64,
580 ) -> AnnounceResult {
581 self.announce(AnnounceEvent::Started, uploaded, downloaded, left)
582 .await
583 }
584
585 pub async fn announce_completed(&mut self, uploaded: u64, downloaded: u64) -> AnnounceResult {
587 self.announce(AnnounceEvent::Completed, uploaded, downloaded, 0)
588 .await
589 }
590
591 pub async fn announce_stopped(&mut self, uploaded: u64, downloaded: u64, left: u64) {
593 let _ = self
594 .announce(AnnounceEvent::Stopped, uploaded, downloaded, left)
595 .await;
596 }
597
598 pub fn start_announce(
610 &mut self,
611 event: AnnounceEvent,
612 uploaded: u64,
613 downloaded: u64,
614 left: u64,
615 ) -> mpsc::Receiver<TrackerPeerBatch> {
616 let (tx, rx) = mpsc::channel(32);
617 let now = Instant::now();
618
619 let mut tasks: Vec<(usize, String, TrackerProtocol)> = Vec::new();
621 for (idx, tracker) in self.trackers.iter().enumerate() {
622 if tracker.next_announce > now {
623 continue;
624 }
625 tasks.push((idx, tracker.url.clone(), tracker.protocol));
626 }
627
628 for &(idx, _, _) in &tasks {
632 self.trackers[idx].next_announce = now + Duration::from_secs(120);
633 }
634
635 if tasks.is_empty() {
637 return rx;
638 }
639
640 let info_hash = self.info_hash;
641 let info_hashes = self.info_hashes.clone();
642 let peer_id = self.peer_id;
643 let port = self.announce_port();
644 let i2p_dest = self.i2p_destination.clone();
645 let http_client = self.http_client.clone();
646 let udp_client = self.udp_client.clone();
647
648 tokio::spawn(async move {
649 let req = AnnounceRequest {
650 info_hash,
651 peer_id,
652 port,
653 uploaded,
654 downloaded,
655 left,
656 event,
657 num_want: None,
658 compact: true,
659 i2p_destination: i2p_dest.clone(),
660 };
661
662 Self::spawn_tracker_announces(&tx, &tasks, &req, &http_client, &udp_client).await;
664
665 if info_hashes.is_hybrid()
667 && let Some(v2) = info_hashes.v2
668 {
669 let v2_as_v1 = Id20(v2.0[..20].try_into().expect("Id32 always has >= 20 bytes"));
670 if v2_as_v1 != info_hash {
671 let mut v2_req = req;
672 v2_req.info_hash = v2_as_v1;
673 Self::spawn_tracker_announces(&tx, &tasks, &v2_req, &http_client, &udp_client)
674 .await;
675 }
676 }
677 });
679
680 rx
681 }
682
683 async fn spawn_tracker_announces(
686 tx: &mpsc::Sender<TrackerPeerBatch>,
687 tasks: &[(usize, String, TrackerProtocol)],
688 req: &AnnounceRequest,
689 http_client: &HttpTracker,
690 udp_client: &UdpTracker,
691 ) {
692 let mut join_set = tokio::task::JoinSet::new();
693
694 for &(idx, ref url, protocol) in tasks {
695 let http = http_client.clone();
696 let udp = udp_client.clone();
697 let url = url.clone();
698 let req = req.clone();
699
700 join_set.spawn(async move {
701 let result = match protocol {
702 TrackerProtocol::Http => Self::announce_http(&http, &url, &req).await,
703 TrackerProtocol::Udp => Self::announce_udp(&udp, &url, &req).await,
704 };
705 TrackerPeerBatch {
706 tracker_idx: idx,
707 url,
708 result,
709 }
710 });
711 }
712
713 while let Some(join_result) = join_set.join_next().await {
714 if let Ok(batch) = join_result {
715 if tx.send(batch).await.is_err() {
717 break;
718 }
719 }
720 }
721 }
722
723 pub fn process_tracker_result(
728 &mut self,
729 batch: TrackerPeerBatch,
730 ) -> (Vec<SocketAddr>, TrackerOutcome) {
731 let Some(tracker) = self.trackers.get_mut(batch.tracker_idx) else {
732 return (
734 Vec::new(),
735 TrackerOutcome {
736 url: batch.url,
737 result: Err("tracker removed during announce".to_string()),
738 },
739 );
740 };
741
742 match batch.result {
743 Ok((peers, interval, tracker_id, seeders, leechers)) => {
744 let num_peers = peers.len();
745 debug!(
746 url = %tracker.url,
747 peer_count = num_peers,
748 interval,
749 "streaming tracker announce success"
750 );
751 tracker.state = TrackerState::Active;
752 tracker.interval = Duration::from_secs(u64::from(interval));
753 tracker.next_announce = Instant::now() + tracker.interval;
754 tracker.backoff = Duration::ZERO;
755 tracker.consecutive_failures = 0;
756 if let Some(id) = tracker_id {
757 tracker.tracker_id = Some(id);
758 }
759 if seeders.is_some() || leechers.is_some() {
760 let prev_downloaded = tracker.scrape_info.map(|s| s.downloaded).unwrap_or(0);
761 tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
762 complete: seeders.unwrap_or(0),
763 incomplete: leechers.unwrap_or(0),
764 downloaded: prev_downloaded,
765 });
766 }
767
768 let outcome = TrackerOutcome {
769 url: batch.url,
770 result: Ok(num_peers),
771 };
772 (peers, outcome)
773 }
774 Err(e) => {
775 let msg = e.to_string();
776 warn!(url = %tracker.url, error = %msg, "streaming tracker announce failed");
777 tracker.state = TrackerState::Failed {
778 _error: msg.clone(),
779 };
780 tracker.consecutive_failures = tracker.consecutive_failures.saturating_add(1);
781 tracker.backoff = if tracker.backoff.is_zero() {
782 INITIAL_BACKOFF
783 } else {
784 (tracker.backoff.saturating_mul(2)).min(MAX_BACKOFF)
785 };
786 tracker.next_announce = Instant::now() + tracker.backoff;
787
788 let outcome = TrackerOutcome {
789 url: batch.url,
790 result: Err(msg),
791 };
792 (Vec::new(), outcome)
793 }
794 }
795 }
796
797 async fn announce_http(
800 client: &HttpTracker,
801 url: &str,
802 req: &AnnounceRequest,
803 ) -> Result<AnnounceOk, irontide_tracker::Error> {
804 let resp = client.announce(url, req).await?;
805 Ok((
806 resp.response.peers,
807 resp.response.interval,
808 resp.tracker_id,
809 resp.response.seeders,
810 resp.response.leechers,
811 ))
812 }
813
814 async fn announce_udp(
815 client: &UdpTracker,
816 url: &str,
817 req: &AnnounceRequest,
818 ) -> Result<AnnounceOk, irontide_tracker::Error> {
819 let addr = parse_udp_addr(url);
822 let resp = client.announce(&addr, req).await?;
823 Ok((
824 resp.response.peers,
825 resp.response.interval,
826 None,
827 resp.response.seeders,
828 resp.response.leechers,
829 ))
830 }
831
832 pub fn tracker_list(&self) -> Vec<TrackerInfo> {
836 self.trackers
837 .iter()
838 .map(|t| {
839 let status = match t.state {
840 TrackerState::NeedsAnnounce => TrackerStatus::NotContacted,
841 TrackerState::Active => TrackerStatus::Working,
842 TrackerState::Failed { .. } => TrackerStatus::Error,
843 };
844 TrackerInfo {
845 url: t.url.clone(),
846 tier: t.tier,
847 status,
848 seeders: t.scrape_info.map(|s| s.complete),
849 leechers: t.scrape_info.map(|s| s.incomplete),
850 downloaded: t.scrape_info.map(|s| s.downloaded),
851 next_announce_secs: t
852 .next_announce
853 .saturating_duration_since(Instant::now())
854 .as_secs(),
855 consecutive_failures: t.consecutive_failures,
856 }
857 })
858 .collect()
859 }
860
861 pub fn force_reannounce(&mut self) {
863 let now = Instant::now();
864 for tracker in &mut self.trackers {
865 tracker.next_announce = now;
866 }
867 }
868
869 pub fn add_tracker_url(&mut self, url: &str) -> bool {
873 let url = url.trim();
874 if url.is_empty() {
875 return false;
876 }
877 let Some(protocol) = classify_url(url) else {
878 return false;
879 };
880 if self.trackers.iter().any(|t| t.url == url) {
882 return false;
883 }
884 let new_tier = self.trackers.last().map(|t| t.tier + 1).unwrap_or(0);
885 self.trackers.push(TrackerEntry {
886 url: url.to_string(),
887 tier: new_tier,
888 protocol,
889 state: TrackerState::NeedsAnnounce,
890 tracker_id: None,
891 next_announce: Instant::now(),
892 interval: DEFAULT_INTERVAL,
893 backoff: Duration::ZERO,
894 scrape_info: None,
895 consecutive_failures: 0,
896 });
897 true
898 }
899
900 pub fn replace_all(&mut self, urls: Vec<String>) {
905 self.trackers.clear();
906 for url in &urls {
907 self.add_tracker_url(url);
908 }
909 }
910
911 #[allow(dead_code)] pub fn add_tracker_url_validated(
917 &mut self,
918 url: &str,
919 security: &crate::url_guard::UrlSecurityConfig,
920 ) -> bool {
921 let url = url.trim();
922 if url.is_empty() {
923 return false;
924 }
925 if let Err(e) = crate::url_guard::validate_tracker_url(url, security) {
926 warn!(%url, %e, "tracker URL rejected by security policy");
927 return false;
928 }
929 self.add_tracker_url(url)
930 }
931
932 pub async fn scrape(&self) -> Option<(String, irontide_tracker::ScrapeInfo)> {
936 for tracker in &self.trackers {
937 let result = match tracker.protocol {
938 TrackerProtocol::Http => self
939 .http_client
940 .scrape(&tracker.url, &[self.info_hash])
941 .await
942 .ok()
943 .and_then(|resp| resp.files.get(&self.info_hash).copied()),
944 TrackerProtocol::Udp => {
945 let addr = parse_udp_addr(&tracker.url);
946 self.udp_client
947 .scrape(&addr, &[self.info_hash])
948 .await
949 .ok()
950 .and_then(|resp| resp.results.into_iter().next())
951 }
952 };
953 if let Some(info) = result {
954 return Some((tracker.url.clone(), info));
955 }
956 }
957 None
958 }
959}
960
961fn classify_url(url: &str) -> Option<TrackerProtocol> {
963 if url.starts_with("http://") || url.starts_with("https://") {
964 Some(TrackerProtocol::Http)
965 } else if url.starts_with("udp://") {
966 Some(TrackerProtocol::Udp)
967 } else {
968 None }
970}
971
972fn parse_udp_addr(url: &str) -> String {
977 let without_scheme = url.strip_prefix("udp://").unwrap_or(url);
978 match without_scheme.find('/') {
980 Some(idx) => without_scheme[..idx].to_string(),
981 None => without_scheme.to_string(),
982 }
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988 use irontide_core::{Id20, InfoHashes};
989
990 fn torrent_with_trackers(
992 announce: Option<&str>,
993 announce_list: Option<Vec<Vec<&str>>>,
994 ) -> TorrentMetaV1 {
995 use serde::Serialize;
996
997 let data = vec![0u8; 16384];
998 let hash = irontide_core::sha1(&data);
999 let mut pieces = Vec::new();
1000 pieces.extend_from_slice(hash.as_bytes());
1001
1002 #[derive(Serialize)]
1003 struct Info<'a> {
1004 length: u64,
1005 name: &'a str,
1006 #[serde(rename = "piece length")]
1007 piece_length: u64,
1008 #[serde(with = "serde_bytes")]
1009 pieces: &'a [u8],
1010 }
1011
1012 #[derive(Serialize)]
1013 struct Torrent<'a> {
1014 #[serde(skip_serializing_if = "Option::is_none")]
1015 announce: Option<&'a str>,
1016 info: Info<'a>,
1017 }
1018
1019 let t = Torrent {
1020 announce,
1021 info: Info {
1022 length: 16384,
1023 name: "test",
1024 piece_length: 16384,
1025 pieces: &pieces,
1026 },
1027 };
1028
1029 let bytes = irontide_bencode::to_bytes(&t).unwrap();
1030 let mut meta = irontide_core::torrent_from_bytes(&bytes).unwrap();
1031 meta.announce_list = announce_list.map(|tiers| {
1032 tiers
1033 .into_iter()
1034 .map(|tier| tier.into_iter().map(String::from).collect())
1035 .collect()
1036 });
1037 if announce.is_some() {
1038 meta.announce = announce.map(String::from);
1039 }
1040 meta
1041 }
1042
1043 fn test_peer_id() -> Id20 {
1044 Id20::from_hex("0102030405060708091011121314151617181920").unwrap()
1045 }
1046
1047 #[test]
1048 fn parse_single_announce_url() {
1049 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1050 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1051 assert_eq!(mgr.tracker_count(), 1);
1052 assert_eq!(mgr.trackers[0].protocol, TrackerProtocol::Http);
1053 assert_eq!(mgr.trackers[0].tier, 0);
1054 }
1055
1056 #[test]
1057 fn parse_announce_list_tiers() {
1058 let meta = torrent_with_trackers(
1059 None,
1060 Some(vec![
1061 vec![
1062 "http://tier0-a.example.com/announce",
1063 "http://tier0-b.example.com/announce",
1064 ],
1065 vec!["udp://tier1.example.com:6969/announce"],
1066 ]),
1067 );
1068 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1069 assert_eq!(mgr.tracker_count(), 3);
1070 assert_eq!(mgr.trackers[0].tier, 0);
1071 assert_eq!(mgr.trackers[1].tier, 0);
1072 assert_eq!(mgr.trackers[2].tier, 1);
1073 assert_eq!(mgr.trackers[2].protocol, TrackerProtocol::Udp);
1074 }
1075
1076 #[test]
1077 fn classify_http_url() {
1078 assert_eq!(classify_url("http://t.co/a"), Some(TrackerProtocol::Http));
1079 assert_eq!(classify_url("https://t.co/a"), Some(TrackerProtocol::Http));
1080 }
1081
1082 #[test]
1083 fn classify_udp_url() {
1084 assert_eq!(
1085 classify_url("udp://t.co:6969/a"),
1086 Some(TrackerProtocol::Udp)
1087 );
1088 }
1089
1090 #[test]
1091 fn classify_unknown_url() {
1092 assert_eq!(classify_url("wss://t.co/a"), None);
1093 }
1094
1095 #[test]
1096 fn deduplicate_urls() {
1097 let meta = torrent_with_trackers(
1098 Some("http://tracker.example.com/announce"),
1099 Some(vec![vec!["http://tracker.example.com/announce"]]),
1100 );
1101 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1102 assert_eq!(mgr.tracker_count(), 1);
1104 }
1105
1106 #[test]
1107 fn empty_announce_list() {
1108 let meta = torrent_with_trackers(None, None);
1109 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1110 assert_eq!(mgr.tracker_count(), 0);
1111 assert_eq!(mgr.next_announce_in(), None);
1112 }
1113
1114 #[test]
1115 fn next_announce_timing() {
1116 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1117 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1118 let next = mgr.next_announce_in().unwrap();
1120 assert!(next <= Duration::from_millis(10));
1121 }
1122
1123 #[test]
1124 fn backoff_on_failure() {
1125 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1126 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1127
1128 mgr.trackers[0].state = TrackerState::Failed {
1130 _error: "connection refused".into(),
1131 };
1132 mgr.trackers[0].backoff = INITIAL_BACKOFF;
1133 mgr.trackers[0].next_announce = Instant::now() + INITIAL_BACKOFF;
1134
1135 let next = mgr.next_announce_in().unwrap();
1136 assert!(next >= Duration::from_secs(29));
1138 assert!(next <= Duration::from_secs(31));
1139 }
1140
1141 #[test]
1142 fn backoff_max_cap() {
1143 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1144 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1145
1146 mgr.trackers[0].backoff = Duration::from_secs(20 * 60); let doubled = (mgr.trackers[0].backoff * 2).min(MAX_BACKOFF);
1150 assert_eq!(doubled, MAX_BACKOFF);
1151 }
1152
1153 #[test]
1154 fn parse_udp_addr_strips_scheme_and_path() {
1155 assert_eq!(
1156 parse_udp_addr("udp://tracker.example.com:6969/announce"),
1157 "tracker.example.com:6969"
1158 );
1159 assert_eq!(parse_udp_addr("udp://example.com:1234"), "example.com:1234");
1160 }
1161
1162 #[test]
1163 fn empty_manager_for_magnet() {
1164 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1165 let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1166 assert_eq!(mgr.tracker_count(), 0);
1167 }
1168
1169 #[test]
1170 fn set_metadata_populates_trackers() {
1171 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1172 let mut mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1173 assert_eq!(mgr.tracker_count(), 0);
1174
1175 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1176 mgr.set_metadata(&meta);
1177 assert_eq!(mgr.tracker_count(), 1);
1178 }
1179
1180 #[test]
1181 fn tracker_list_returns_info() {
1182 let meta = torrent_with_trackers(
1183 None,
1184 Some(vec![
1185 vec!["http://tracker1.example.com/announce"],
1186 vec!["udp://tracker2.example.com:6969/announce"],
1187 ]),
1188 );
1189 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1190 let list = mgr.tracker_list();
1191 assert_eq!(list.len(), 2);
1192 assert_eq!(list[0].status, TrackerStatus::NotContacted);
1193 assert_eq!(list[0].seeders, None);
1194 assert_eq!(list[0].tier, 0);
1195 assert_eq!(list[1].tier, 1);
1196 }
1197
1198 #[test]
1199 fn force_reannounce_resets_timers() {
1200 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1201 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1202 mgr.trackers[0].next_announce = Instant::now() + Duration::from_secs(3600);
1204 assert!(mgr.next_announce_in().unwrap() > Duration::from_secs(3500));
1205 mgr.force_reannounce();
1206 let next = mgr.next_announce_in().unwrap();
1207 assert!(next <= Duration::from_millis(10));
1208 }
1209
1210 #[test]
1211 fn add_tracker_url_new() {
1212 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1213 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1214 assert_eq!(mgr.tracker_count(), 1);
1215 let added = mgr.add_tracker_url("http://new-tracker.example.com/announce");
1216 assert!(added);
1217 assert_eq!(mgr.tracker_count(), 2);
1218 assert_eq!(mgr.trackers[1].tier, 1); }
1220
1221 #[test]
1222 fn add_tracker_url_duplicate() {
1223 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1224 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1225 let added = mgr.add_tracker_url("http://tracker.example.com/announce");
1226 assert!(!added);
1227 assert_eq!(mgr.tracker_count(), 1);
1228 }
1229
1230 #[test]
1231 fn tracker_manager_stores_info_hashes() {
1232 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1233 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1234 assert!(!mgr.info_hashes.is_hybrid());
1235
1236 let v2 = irontide_core::Id32::from_hex(
1237 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1238 )
1239 .unwrap();
1240 mgr.set_info_hashes(InfoHashes::hybrid(meta.info_hash, v2));
1241 assert!(mgr.info_hashes.is_hybrid());
1242 }
1243
1244 #[test]
1245 fn add_tracker_url_empty() {
1246 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1247 let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1248 assert!(!mgr.add_tracker_url(""));
1249 assert!(!mgr.add_tracker_url(" "));
1250 assert_eq!(mgr.tracker_count(), 1);
1251 }
1252
1253 fn ssrf_config() -> crate::url_guard::UrlSecurityConfig {
1254 crate::url_guard::UrlSecurityConfig {
1255 ssrf_mitigation: true,
1256 allow_idna: false,
1257 validate_https_trackers: true,
1258 }
1259 }
1260
1261 #[test]
1262 fn localhost_tracker_announce_path_accepted() {
1263 let meta = torrent_with_trackers(Some("http://127.0.0.1:8080/announce"), None);
1265 let cfg = ssrf_config();
1266 let mgr =
1267 TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1268 assert_eq!(mgr.tracker_count(), 1);
1269 assert_eq!(mgr.trackers[0].url, "http://127.0.0.1:8080/announce");
1270 }
1271
1272 #[test]
1273 fn localhost_tracker_bad_path_filtered() {
1274 let meta = torrent_with_trackers(
1277 None,
1278 Some(vec![vec![
1279 "http://127.0.0.1:8080/api/admin",
1280 "http://tracker.example.com/announce",
1281 ]]),
1282 );
1283 let cfg = ssrf_config();
1284 let mgr =
1285 TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1286 assert_eq!(mgr.tracker_count(), 1);
1287 assert_eq!(mgr.trackers[0].url, "http://tracker.example.com/announce");
1288 }
1289
1290 #[test]
1291 fn add_tracker_url_validates() {
1292 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1293 let cfg = ssrf_config();
1294 let mut mgr =
1295 TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1296 assert_eq!(mgr.tracker_count(), 1);
1297
1298 assert!(mgr.add_tracker_url_validated("http://other.example.com/announce", &cfg));
1300 assert_eq!(mgr.tracker_count(), 2);
1301
1302 assert!(!mgr.add_tracker_url_validated("http://127.0.0.1:8080/api/admin", &cfg));
1304 assert_eq!(mgr.tracker_count(), 2);
1305
1306 assert!(mgr.add_tracker_url_validated("udp://tracker.example.com:6969/announce", &cfg));
1308 assert_eq!(mgr.tracker_count(), 3);
1309 }
1310
1311 #[test]
1312 fn anonymous_mode_zeroes_announce_port() {
1313 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1314 let cfg = ssrf_config();
1315 let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, true);
1316 assert!(mgr.anonymous_mode);
1317 assert_eq!(mgr.announce_port(), 0);
1318 }
1319
1320 #[test]
1321 fn normal_mode_includes_port() {
1322 let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1323 let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1324 assert!(!mgr.anonymous_mode);
1325 assert_eq!(mgr.announce_port(), 6881);
1326 }
1327
1328 #[test]
1329 fn empty_manager_with_dscp_and_anonymous() {
1330 let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1331 let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0x2E, true);
1332 assert!(mgr.anonymous_mode);
1333 assert_eq!(mgr.dscp, 0x2E);
1334 assert_eq!(mgr.announce_port(), 0);
1335 }
1336}