Skip to main content

aria2_core/engine/
lpd_manager.rs

1//! Local Peer Discovery (LPD) Manager - Phase 15 H8
2//!
3//! Implements BitTorrent Local Peer Discovery using UDP multicast on
4//! the standard LPD multicast group 239.192.152.143:6771.
5//!
6//! # Architecture
7//!
8//! ```text
9//! lpd_manager.rs (this file)
10//!   ├── LpdManager - High-level coordinator for LPD operations
11//!   ├── LpdAnnouncer - Low-level UDP multicast sender/receiver
12//!   ├── LpdPeer - Discovered peer information
13//!   └── parse_lpd_announcement() - Parse text-format LPD messages
14//!
15//! LPD Protocol:
16//!   Multicast Group: 239.192.152.143:6771
17//!   Message Format:
18//!     Hash: <info_hash_hex>\n
19//!     Port: <listen_port>\n
20//!     Token: <random_8hex>\n
21//!
22//!   Announce Interval: Every 5 minutes while active
23//! ```
24
25use std::collections::{HashMap, HashSet};
26use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29
30use tokio::sync::RwLock;
31use tracing::{debug, info, warn};
32
33use crate::constants;
34
35// Re-export LPD constants for backward compatibility with test imports
36pub use constants::{
37    LPD_DEFAULT_ANNOUNCE_INTERVAL_SECS as DEFAULT_ANNOUNCE_INTERVAL_SECS,
38    LPD_MULTICAST_ADDRESS as LPD_MULTICAST_ADDR, LPD_PORT,
39};
40
41// =========================================================================
42// Constants
43// =========================================================================
44
45/// Maximum number of peers to track per info hash
46pub const MAX_PEERS_PER_HASH: usize = 50;
47
48// =========================================================================
49// LpdPeer - Discovered peer from LPD announcement
50// =========================================================================
51
52/// Information about a peer discovered via LPD
53#[derive(Debug, Clone)]
54pub struct LpdPeer {
55    /// The torrent info hash this peer is sharing
56    pub info_hash: String,
57    /// The peer's listen port
58    pub port: u16,
59    /// The peer's IP address (from recv_from)
60    pub addr: IpAddr,
61    /// When this peer was last announced
62    pub last_seen: Instant,
63    /// Random token from the announcement (for anti-spoofing)
64    pub token: Option<u32>,
65}
66
67impl LpdPeer {
68    /// Create a new LpdPeer
69    pub fn new(info_hash: impl Into<String>, port: u16, addr: IpAddr) -> Self {
70        Self {
71            info_hash: info_hash.into(),
72            port,
73            addr,
74            last_seen: Instant::now(),
75            token: None,
76        }
77    }
78
79    /// Create with token
80    pub fn with_token(info_hash: impl Into<String>, port: u16, addr: IpAddr, token: u32) -> Self {
81        let mut p = Self::new(info_hash, port, addr);
82        p.token = Some(token);
83        p
84    }
85
86    /// Get the peer's address as SocketAddr
87    pub fn socket_addr(&self) -> SocketAddr {
88        SocketAddr::new(self.addr, self.port)
89    }
90
91    /// Check if this peer has expired based on age
92    pub fn is_expired(&self, max_age: Duration) -> bool {
93        self.last_seen.elapsed() > max_age
94    }
95}
96
97impl PartialEq for LpdPeer {
98    fn eq(&self, other: &Self) -> bool {
99        // Two peers are considered equal if they share same info_hash and IP
100        self.info_hash == other.info_hash && self.addr == other.addr
101    }
102}
103
104impl Eq for LpdPeer {}
105
106impl std::hash::Hash for LpdPeer {
107    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
108        self.info_hash.hash(state);
109        self.addr.hash(state);
110    }
111}
112
113// =========================================================================
114// LpdAnnouncer - UDP Multicast Sender/Receiver
115// =========================================================================
116
117/// Handles low-level UDP multicast I/O for LPD announcements.
118///
119/// Binds to a local UDP socket, joins the LPD multicast group, and provides
120/// methods for sending announcements and receiving peer discoveries.
121///
122/// # Thread Safety
123///
124/// `LpdAnnouncer` uses `UdpSocket` which is `Send + Sync`. However, concurrent
125/// send/recv calls may need external synchronization for correctness.
126pub struct LpdAnnouncer {
127    /// Bound UDP socket for multicast I/O
128    socket: UdpSocket,
129    /// The multicast address we send to / receive from
130    multicast_addr: SocketAddr,
131    /// Whether announcing is enabled
132    enabled: bool,
133    /// Current announce interval
134    announce_interval: Duration,
135}
136
137impl LpdAnnouncer {
138    /// Create a new LpdAnnouncer bound to an ephemeral local port
139    ///
140    /// Joins the LPD multicast group (239.192.152.143:6771) and enables
141    /// broadcast mode on the socket.
142    ///
143    /// # Errors
144    ///
145    /// Returns error if:
146    /// - Cannot bind to any local UDP port
147    /// - Cannot enable broadcast mode
148    /// - Cannot join multicast group
149    pub fn new() -> Result<Self, String> {
150        Self::with_config(constants::LPD_DEFAULT_ANNOUNCE_INTERVAL_SECS)
151    }
152
153    /// Create with custom announce interval
154    pub fn with_config(announce_interval_secs: u64) -> Result<Self, String> {
155        // Bind to ephemeral port on all interfaces
156        let socket = UdpSocket::bind("0.0.0.0:0")
157            .map_err(|e| format!("Failed to bind UDP socket: {}", e))?;
158
159        // Enable broadcast
160        socket
161            .set_broadcast(true)
162            .map_err(|e| format!("Failed to enable broadcast: {}", e))?;
163
164        // Set reuse address so multiple instances can coexist (Unix only)
165        #[cfg(unix)]
166        {
167            use std::os::unix::io::AsRawFd;
168            let fd = socket.as_raw_fd();
169            unsafe {
170                libc::setsockopt(
171                    fd,
172                    libc::SOL_SOCKET,
173                    libc::SO_REUSEADDR,
174                    &1i32 as *const i32 as *const libc::c_void,
175                    std::mem::size_of::<i32>() as libc::socklen_t,
176                );
177            }
178        }
179
180        // Parse multicast address
181        let multicast_addr: SocketAddr = format!(
182            "{}:{}",
183            constants::LPD_MULTICAST_ADDRESS,
184            constants::LPD_PORT
185        )
186        .parse()
187        .map_err(|e| format!("Invalid LPD multicast address: {}", e))?;
188
189        // Join multicast group
190        let multicast_ip: Ipv4Addr = constants::LPD_MULTICAST_ADDRESS
191            .parse()
192            .map_err(|e| format!("Invalid LPD multicast IP: {}", e))?;
193
194        socket
195            .join_multicast_v4(&multicast_ip, &Ipv4Addr::UNSPECIFIED)
196            .map_err(|e| format!("Failed to join LPD multicast group: {}", e))?;
197
198        debug!(
199            local = ?socket.local_addr().ok(),
200            multicast = %multicast_addr,
201            "LpdAnnouncer created successfully"
202        );
203
204        Ok(Self {
205            socket,
206            multicast_addr,
207            enabled: true,
208            announce_interval: Duration::from_secs(announce_interval_secs),
209        })
210    }
211
212    /// Disable announcing (for testing or when BT is disabled)
213    pub fn disable(&mut self) {
214        self.enabled = false;
215    }
216
217    /// Enable announcing
218    pub fn enable(&mut self) {
219        self.enabled = true;
220    }
221
222    /// Check if announcer is enabled
223    pub fn is_enabled(&self) -> bool {
224        self.enabled
225    }
226
227    /// Get the local bind address
228    pub fn local_addr(&self) -> Result<SocketAddr, String> {
229        self.socket
230            .local_addr()
231            .map_err(|e| format!("Failed to get local address: {}", e))
232    }
233
234    /// Send an LPD announcement for a torrent
235    ///
236    /// Formats and sends a text-based LPD message containing:
237    /// - Hash: <info_hash> (40-char hex)
238    /// - Port: <listen_port>
239    /// - Token: <random 8-hex>
240    ///
241    /// # Arguments
242    ///
243    /// * `info_hash` - 40-character hex string of the torrent's info hash
244    /// * `port` - Our listening port for incoming connections
245    ///
246    /// # Errors
247    ///
248    /// Returns error if UDP send fails.
249    pub fn announce(&self, info_hash: &str, port: u16) -> Result<(), String> {
250        if !self.enabled {
251            return Ok(()); // Silently succeed when disabled
252        }
253
254        // Validate info hash format (should be 40 hex chars)
255        if info_hash.len() != 40 || !info_hash.chars().all(|c| c.is_ascii_hexdigit()) {
256            return Err(format!(
257                "Invalid info_hash format: expected 40 hex chars, got {} chars",
258                info_hash.len()
259            ));
260        }
261
262        // Generate random anti-spoofing token
263        let token: u32 = rand::random::<u32>();
264
265        // Format LPD announcement per BEP-14 spec
266        let msg = format!(
267            "Hash: {}\nPort: {}\nToken: {:08x}\n",
268            info_hash, port, token
269        );
270
271        debug!(
272            info_hash = %&info_hash[..8],
273            port,
274            token = format!("{:08x}", token),
275            "Sending LPD announcement"
276        );
277
278        self.socket
279            .send_to(msg.as_bytes(), self.multicast_addr)
280            .map_err(|e| format!("LPD announce send failed: {}", e))?;
281
282        Ok(())
283    }
284
285    /// Receive LPD announcements within a timeout window
286    ///
287    /// Blocks for up to `timeout` duration, collecting all valid LPD
288    /// announcements received. Deduplicates by (info_hash, source_ip).
289    ///
290    /// # Arguments
291    ///
292    /// * `timeout` - Maximum time to wait for announcements
293    ///
294    /// # Returns
295    ///
296    /// Vector of discovered peers. May be empty if no announcements received.
297    pub fn receive_announcements(&self, timeout: Duration) -> Vec<LpdPeer> {
298        let mut buf = [0u8; constants::LPD_RECEIVE_BUFFER_SIZE];
299        let mut peers = Vec::new();
300        let mut seen: HashSet<(String, IpAddr)> = HashSet::new();
301
302        self.socket
303            .set_read_timeout(Some(timeout))
304            .expect("set_read_timeout should not fail");
305
306        let deadline = Instant::now() + timeout;
307
308        loop {
309            let remaining = deadline.saturating_duration_since(Instant::now());
310            if remaining.is_zero() {
311                break;
312            }
313
314            match self.socket.recv_from(&mut buf) {
315                Ok((len, src_addr)) => {
316                    if len == 0 {
317                        continue;
318                    }
319
320                    if let Some(peer) = parse_lpd_announcement(&buf[..len], src_addr.ip()) {
321                        // Deduplicate by (info_hash, ip)
322                        let key = (peer.info_hash.clone(), peer.addr);
323                        if seen.insert(key) {
324                            debug!(
325                                info_hash = %peer.info_hash[..8.min(peer.info_hash.len())],
326                                addr = %src_addr.ip(),
327                                "Received valid LPD announcement"
328                            );
329                            peers.push(peer);
330                        } else {
331                            debug!(
332                                addr = %src_addr.ip(),
333                                "Duplicate LPD announcement suppressed"
334                            );
335                        }
336                    }
337                }
338                Err(e) => {
339                    let kind = e.kind();
340                    if kind == std::io::ErrorKind::TimedOut
341                        || kind == std::io::ErrorKind::WouldBlock
342                    {
343                        break;
344                    }
345                    // Other errors: log but continue trying
346                    warn!(error = %e, "LPD receive error, continuing");
347                    // Small sleep to avoid busy-looping on persistent errors
348                    std::thread::sleep(Duration::from_millis(10));
349                }
350            }
351        }
352
353        debug!(count = peers.len(), "LPD receive completed");
354        peers
355    }
356
357    /// Perform a single announce+receive cycle (announce then collect responses)
358    ///
359    /// Sends our announcement, waits briefly, then collects any responses
360    /// from other peers who also announced in that window.
361    pub fn announce_and_discover(
362        &self,
363        info_hash: &str,
364        port: u16,
365        discover_timeout: Duration,
366    ) -> Result<Vec<LpdPeer>, String> {
367        // Announce ourselves
368        self.announce(info_hash, port)?;
369
370        // Wait a bit for others to respond
371        std::thread::sleep(Duration::from_millis(500));
372
373        // Collect announcements
374        let peers = self.receive_announcements(discover_timeout);
375
376        // Filter out our own announcement (by matching our info_hash + port)
377        let peers: Vec<LpdPeer> = peers
378            .into_iter()
379            .filter(|p| !(p.info_hash == info_hash && p.port == port))
380            .collect();
381
382        Ok(peers)
383    }
384}
385
386// =========================================================================
387// LpdManager - High-level coordinator
388// =========================================================================
389
390/// Manages LPD operations for all active torrents.
391///
392/// Maintains a registry of known peers discovered via LPD, handles periodic
393/// announcements for active downloads, and coordinates with the download engine.
394pub struct LpdManager {
395    /// The underlying UDP announcer
396    announcer: Arc<LpdAnnouncer>,
397    /// Registry of discovered peers keyed by info_hash
398    peers: Arc<RwLock<HashMap<String, HashSet<LpdPeer>>>>,
399    /// Track which info hashes we're currently announcing
400    pub active_hashes: Arc<RwLock<HashSet<String>>>,
401    /// Handle to the background announce task
402    _announce_task: Option<tokio::task::JoinHandle<()>>,
403}
404
405impl Default for LpdManager {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411impl LpdManager {
412    /// Create a new LpdManager
413    ///
414    /// Initializes the UDP socket, joins multicast group, and sets up
415    /// internal state tracking.
416    pub fn new() -> Self {
417        let announcer = LpdAnnouncer::new().unwrap_or_else(|_| {
418            // If we can't create real socket, create a dummy one for testing
419            // In production this would be an error
420            warn!("Could not create LPD announcer, LPD will be disabled");
421            LpdAnnouncer::with_config(constants::LPD_DEFAULT_ANNOUNCE_INTERVAL_SECS)
422                .unwrap_or_else(|_| panic!("Fatal: cannot create LPD announcer"))
423        });
424
425        Self {
426            announcer: Arc::new(announcer),
427            peers: Arc::new(RwLock::new(HashMap::new())),
428            active_hashes: Arc::new(RwLock::new(HashSet::new())),
429            _announce_task: None,
430        }
431    }
432
433    /// Create LpdManager with custom configuration
434    pub fn with_interval(announce_interval_secs: u64) -> Result<Self, String> {
435        let announcer = LpdAnnouncer::with_config(announce_interval_secs)?;
436
437        Ok(Self {
438            announcer: Arc::new(announcer),
439            peers: Arc::new(RwLock::new(HashMap::new())),
440            active_hashes: Arc::new(RwLock::new(HashSet::new())),
441            _announce_task: None,
442        })
443    }
444
445    /// Register a torrent for LPD announcements
446    ///
447    /// Adds the info_hash to the active set so it gets periodically announced.
448    pub async fn register_torrent(&self, info_hash: &str) -> Result<(), String> {
449        let mut active = self.active_hashes.write().await;
450        active.insert(info_hash.to_string());
451
452        // Ensure peer set exists
453        let mut peers_map = self.peers.write().await;
454        peers_map.entry(info_hash.to_string()).or_default();
455
456        info!(info_hash = %&info_hash[..8], "Torrent registered for LPD");
457        Ok(())
458    }
459
460    /// Unregister a torrent from LPD announcements
461    pub async fn unregister_torrent(&self, info_hash: &str) {
462        let mut active = self.active_hashes.write().await;
463        active.remove(info_hash);
464
465        info!(info_hash = %&info_hash[..8], "Torrent unregistered from LPD");
466    }
467
468    /// Manual announce for a specific torrent
469    pub async fn announce_torrent(&self, info_hash: &str, port: u16) -> Result<(), String> {
470        self.announcer.announce(info_hash, port)?;
471        Ok(())
472    }
473
474    /// Discover peers for a specific info_hash via LPD
475    pub async fn discover_peers(&self, _info_hash: &str, timeout_ms: Option<u64>) -> Vec<LpdPeer> {
476        let timeout =
477            Duration::from_millis(timeout_ms.unwrap_or(constants::LPD_DEFAULT_RECEIVE_TIMEOUT_MS));
478        self.announcer.receive_announcements(timeout)
479    }
480
481    /// Get all known peers for a given info_hash
482    pub async fn get_peers_for(&self, info_hash: &str) -> Vec<LpdPeer> {
483        let peers_map = self.peers.read().await;
484        peers_map
485            .get(info_hash)
486            .map(|set| set.iter().cloned().collect())
487            .unwrap_or_default()
488    }
489
490    /// Start periodic background announce task
491    ///
492    /// Spawns a Tokio task that announces all registered torrents every
493    /// N seconds (default 5 min).
494    ///
495    /// # Arguments
496    ///
497    /// * `port` - Our BT client listen port
498    ///
499    /// # Returns
500    ///
501    /// JoinHandle that can be used to cancel the task
502    pub fn start_background_announce(&mut self, port: u16) -> Option<tokio::task::JoinHandle<()>> {
503        if !self.announcer.is_enabled() {
504            debug!("LPD is disabled, not starting background announce");
505            return None;
506        }
507
508        let announcer = Arc::clone(&self.announcer);
509        let active_hashes = Arc::clone(&self.active_hashes);
510
511        info!(
512            interval_secs = self.announcer.announce_interval.as_secs(),
513            port, "Starting LPD background announce task"
514        );
515
516        let handle = tokio::spawn(async move {
517            let mut ticker = tokio::time::interval(announcer.announce_interval);
518
519            loop {
520                ticker.tick().await;
521
522                let hashes: Vec<String> = {
523                    let active = active_hashes.read().await;
524                    active.iter().cloned().collect()
525                };
526
527                for info_hash in &hashes {
528                    if let Err(e) = announcer.announce(info_hash, port) {
529                        warn!(
530                            info_hash = %&info_hash[..8.min(info_hash.len())],
531                            error = %e,
532                            "Background LPD announce failed"
533                        );
534                    }
535                }
536
537                debug!(
538                    count = hashes.len(),
539                    "LPD background announce cycle completed"
540                );
541            }
542        });
543
544        self._announce_task = Some(handle);
545        // Note: JoinHandle is not Clone, so we cannot return a copy.
546        // The task is stored internally and can be stopped via stop_background_announce().
547        None
548    }
549
550    /// Stop background announce task
551    pub fn stop_background_announce(&mut self) {
552        if let Some(handle) = self._announce_task.take() {
553            handle.abort();
554            info!("LPD background announce task stopped");
555        }
556    }
557
558    /// Update peer registry with newly discovered peers
559    pub async fn update_peers(&self, info_hash: &str, new_peers: Vec<LpdPeer>) {
560        let mut peers_map = self.peers.write().await;
561        let entry = peers_map.entry(info_hash.to_string()).or_default();
562
563        for peer in new_peers {
564            // Limit total peers per hash
565            if entry.len() >= MAX_PEERS_PER_HASH {
566                // Remove oldest expired peer first
567                let oldest = entry.iter().max_by_key(|p| p.last_seen.elapsed()).cloned();
568                if let Some(oldest_peer) = oldest {
569                    entry.remove(&oldest_peer);
570                }
571            }
572            entry.insert(peer);
573        }
574    }
575
576    /// Clean up expired peers from all registries
577    pub async fn cleanup_expired_peers(&self, max_age: Duration) -> usize {
578        let mut peers_map = self.peers.write().await;
579        let mut removed = 0usize;
580
581        for peers in peers_map.values_mut() {
582            let before = peers.len();
583            peers.retain(|p| !p.is_expired(max_age));
584            removed += before - peers.len();
585        }
586
587        if removed > 0 {
588            debug!(removed, "Cleaned up expired LPD peers");
589        }
590        removed
591    }
592
593    /// Check if LPD is available and working
594    pub fn is_available(&self) -> bool {
595        self.announcer.is_enabled()
596    }
597}
598
599// =========================================================================
600// LPD Announcement Parser
601// =========================================================================
602
603/// Parse a raw LPD announcement message into structured data
604///
605/// LPD messages are plain text with key-value pairs:
606///
607/// ```text
608/// Hash: <40-char-hex-info-hash>\n
609/// Port: <1-65535>\n
610/// Token: <8-char-hex-token>\n
611/// ```
612///
613/// # Arguments
614///
615/// * `data` - Raw bytes received from UDP socket
616/// * `sender_ip` - IP address of the sender (from recv_from)
617///
618/// # Returns
619///
620/// `Some(LpdPeer)` if parsing succeeds, `None` if malformed
621pub fn parse_lpd_announcement(data: &[u8], sender_ip: IpAddr) -> Option<LpdPeer> {
622    let text = std::str::from_utf8(data).ok()?;
623    let mut info_hash = String::new();
624    let mut port = 0u16;
625    let mut token: Option<u32> = None;
626
627    for line in text.lines() {
628        let line = line.trim();
629        if let Some(rest) = line.strip_prefix("Hash:") {
630            let val = rest.trim();
631            // Validate: must be exactly 40 hex characters
632            if val.len() == 40 && val.chars().all(|c| c.is_ascii_hexdigit()) {
633                info_hash = val.to_lowercase(); // Normalize to lowercase
634            } else {
635                return None; // Invalid info_hash format
636            }
637        } else if let Some(rest) = line.strip_prefix("Port:") {
638            port = rest.trim().parse().ok()?;
639            if port == 0 {
640                return None; // Port 0 is invalid
641            }
642        } else if let Some(rest) = line.strip_prefix("Token:") {
643            let token_str = rest.trim();
644            token = u32::from_str_radix(token_str, 16).ok();
645        }
646    }
647
648    // All three fields are required for a valid announcement
649    if !info_hash.is_empty() && port > 0 && token.is_some() {
650        let mut peer = LpdPeer::new(info_hash, port, sender_ip);
651        peer.token = token;
652        Some(peer)
653    } else {
654        debug!(
655            text_len = data.len(),
656            has_hash = !info_hash.is_empty(),
657            has_port = port > 0,
658            has_token = token.is_some(),
659            "Incomplete/malformed LPD announcement ignored"
660        );
661        None
662    }
663}