Skip to main content

rustnet_capture/
lib.rs

1//! # rustnet-capture
2//!
3//! Packet-capture backend for [RustNet](https://github.com/domcyrus/rustnet),
4//! built on `libpcap` / `Npcap` via the [`pcap`] crate. This crate owns all of
5//! RustNet's pcap-based capture: device selection, BPF-filter setup, the macOS
6//! PKTAP fast path for process metadata, TUN/TAP handling, and a simple
7//! [`PacketReader`] that yields raw link-layer frames.
8//!
9//! It is deliberately separate from the analysis core (`rustnet-core`) and the
10//! `rustnet` application so that alternative front-ends — e.g. a headless
11//! Prometheus exporter — can pair capture with `rustnet-core` without pulling
12//! in the TUI, and so that platforms wanting a bespoke capture path (e.g. a
13//! root-free macOS pktap helper) can swap this crate out entirely.
14//!
15//! Capture yields raw bytes plus the libpcap data-link type (DLT); parsing
16//! those bytes is `rustnet-core`'s job.
17use anyhow::{Result, anyhow};
18use pcap::{Active, Capture, Device, Error as PcapError};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21/// Why the macOS PKTAP fast path could not be used during capture setup.
22///
23/// PKTAP attaches process metadata to captured packets, but it requires root,
24/// a usable BPF device, the default interface, and no BPF filter. When any of
25/// those preconditions fail, capture records the reason here so the application
26/// can surface it (the `rustnet` binary maps these to its UI-level
27/// `DegradationReason`). Kept capture-native so this crate has no dependency on
28/// the application's process-attribution types.
29#[cfg(target_os = "macos")]
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PktapUnavailable {
32    /// Could not open the BPF device (typically a permission issue).
33    NoBpfDeviceAccess,
34    /// PKTAP device creation failed — almost always missing root privileges.
35    MissingRootPrivileges,
36    /// A specific interface was requested; PKTAP only works on the default path.
37    InterfaceSpecified,
38    /// A BPF filter was supplied, which is incompatible with PKTAP.
39    BpfFilterIncompatible,
40}
41
42/// Stores why PKTAP is not available on macOS (set during capture setup).
43#[cfg(target_os = "macos")]
44pub static PKTAP_DEGRADATION_REASON: std::sync::OnceLock<PktapUnavailable> =
45    std::sync::OnceLock::new();
46
47/// Packet capture configuration
48#[derive(Debug, Clone)]
49pub struct CaptureConfig {
50    /// Network interface name (None for default)
51    pub interface: Option<String>,
52    /// Snapshot length (bytes to capture per packet)
53    pub snaplen: i32,
54    /// Buffer size for packet capture
55    pub buffer_size: i32,
56    /// Read timeout in milliseconds
57    pub timeout_ms: i32,
58    /// BPF filter string
59    pub filter: Option<String>,
60}
61
62impl Default for CaptureConfig {
63    fn default() -> Self {
64        Self {
65            interface: None,
66            snaplen: 1514,           // Limit packet size to keep more in buffer
67            buffer_size: 20_000_000, // 20MB buffer
68            timeout_ms: 150,         // 150ms timeout for UI responsiveness
69            filter: None,            // Start without filter to ensure we see packets
70        }
71    }
72}
73
74/// Find the best active network device
75fn find_best_device() -> Result<Device> {
76    let devices = Device::list().map_err(|e| {
77        anyhow!(
78            "Failed to list network devices: {}. This may indicate insufficient privileges.",
79            e
80        )
81    })?;
82
83    log::info!(
84        "Scanning {} devices for best active interface...",
85        devices.len()
86    );
87
88    // Log all devices for debugging
89    for d in &devices {
90        let has_valid_ip = d.addresses.iter().any(|addr| match &addr.addr {
91            std::net::IpAddr::V4(v4) => {
92                !v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
93            }
94            std::net::IpAddr::V6(v6) => {
95                !v6.is_loopback() && !v6.is_multicast() && !v6.is_unspecified()
96            }
97        });
98
99        log::debug!(
100            "  Device: {} [up: {}, running: {}, has_ip: {}]",
101            d.name,
102            d.flags.is_up(),
103            d.flags.is_running(),
104            has_valid_ip
105        );
106    }
107
108    if devices.is_empty() {
109        return Err(anyhow!("No network devices found"));
110    }
111
112    // Find the best active device
113    let suitable_device = devices
114        .iter()
115        // First priority: up, running, has a valid IP address, and NOT virtual
116        .find(|d| {
117            // Check if it's a virtual/problematic interface
118            let desc_lower = d
119                .desc
120                .as_ref()
121                .map(|s| s.to_lowercase())
122                .unwrap_or_default();
123            let is_virtual = desc_lower.contains("hyper-v")
124                || desc_lower.contains("vmware")
125                || desc_lower.contains("virtualbox");
126
127            !d.name.starts_with("lo")
128                // Note: 'any' is excluded here because it's not a real interface
129                // Users can still specify '-i any' explicitly on Linux
130                && d.name != "any"
131                && !is_virtual  // Skip virtual adapters in first priority too
132                && d.flags.is_up()
133                && d.flags.is_running()
134                && d.addresses.iter().any(|addr| {
135                    match &addr.addr {
136                        std::net::IpAddr::V4(v4) => {
137                            !v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
138                        }
139                        std::net::IpAddr::V6(_v6) => false, // Skip IPv6 for now
140                    }
141                })
142        })
143        // Second priority: common active interface names
144        .or_else(|| {
145            devices.iter().find(|d| {
146                (d.name == "en0" || d.name == "en1" || d.name.starts_with("eth"))
147                    && d.flags.is_up()
148                    && d.addresses.iter().any(|addr| addr.addr.is_ipv4())
149            })
150        })
151        // Third priority: any up interface with valid addresses (excluding problematic ones)
152        .or_else(|| {
153            devices.iter().find(|d| {
154                // Check if it's a virtual/problematic interface
155                let desc_lower = d
156                    .desc
157                    .as_ref()
158                    .map(|s| s.to_lowercase())
159                    .unwrap_or_default();
160                let is_virtual = desc_lower.contains("hyper-v")
161                    || desc_lower.contains("virtual")
162                    || desc_lower.contains("vmware")
163                    || desc_lower.contains("virtualbox")
164                    || desc_lower.contains("loopback");
165
166                !d.name.starts_with("lo") &&
167                !d.name.starts_with("ap") &&     // Skip Apple's ap interfaces
168                !d.name.starts_with("awdl") &&   // Skip Apple Wireless Direct
169                !d.name.starts_with("llw") &&    // Skip Low latency WLAN
170                !d.name.starts_with("bridge") && // Skip bridges
171                // TUN/TAP interfaces now supported - removed utun/tun/tap exclusion
172                !d.name.starts_with("vmnet") &&  // Skip VM interfaces
173                // Note: 'any' is excluded here because it's not a real interface
174                // Users can still specify '-i any' explicitly on Linux
175                d.name != "any" &&
176                !is_virtual &&                    // Skip virtual adapters
177                d.flags.is_up() &&
178                !d.addresses.is_empty()
179            })
180        })
181        .cloned();
182
183    match suitable_device {
184        Some(device) => {
185            log::info!(
186                "Selected active device: {} ({} addresses)",
187                device.name,
188                device.addresses.len()
189            );
190            for addr in &device.addresses {
191                log::debug!("  Address: {}", addr.addr);
192            }
193            Ok(device)
194        }
195        None => {
196            log::error!("No suitable active network device found!");
197            log::error!("Try specifying an interface manually with -i flag");
198            Err(anyhow!(
199                "No active network interface found. Use -i to specify one manually."
200            ))
201        }
202    }
203}
204
205/// Setup packet capture with the given configuration
206pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, String, i32)> {
207    // Try PKTAP first on macOS for process metadata, but only when:
208    // - No interface is explicitly specified
209    // - No BPF filter is specified (BPF filters don't work with PKTAP's linktype 149)
210    #[cfg(target_os = "macos")]
211    if config.interface.is_none() && config.filter.is_none() {
212        log::info!("Attempting to use PKTAP for process metadata on macOS");
213
214        match Capture::from_device("pktap") {
215            Ok(pktap_builder) => {
216                let pktap_cap = pktap_builder
217                    .promisc(false) // PKTAP doesn't use promiscuous mode
218                    .snaplen(config.snaplen)
219                    .buffer_size(config.buffer_size)
220                    .timeout(config.timeout_ms)
221                    .immediate_mode(true)
222                    .want_pktap(true)
223                    .open();
224
225                match pktap_cap {
226                    Ok(mut cap) => {
227                        // Try to set direction for better performance (optional)
228                        if let Err(e) = cap.direction(pcap::Direction::InOut) {
229                            log::debug!("Could not set PKTAP direction: {}", e);
230                        }
231
232                        let linktype = cap.get_datalink();
233                        log::info!(
234                            "✓ PKTAP enabled successfully, linktype: {} ({})",
235                            linktype.0,
236                            if linktype.0 == 149 {
237                                "Apple PKTAP"
238                            } else {
239                                "Unknown"
240                            }
241                        );
242
243                        // Apply BPF filter if specified
244                        if let Some(filter) = &config.filter {
245                            log::info!("Applying BPF filter to PKTAP: {}", filter);
246                            cap.filter(filter, true)?;
247                        }
248
249                        log::info!("PKTAP capture ready - process metadata will be available");
250                        return Ok((cap, "pktap".to_string(), linktype.0));
251                    }
252                    Err(e) => {
253                        log::warn!("Failed to open PKTAP capture: {}", e);
254                        log::info!(
255                            "PKTAP requires root privileges - run with 'sudo' for process metadata support"
256                        );
257                        log::info!(
258                            "Falling back to regular capture (process detection will use lsof)"
259                        );
260                        // Store degradation reason - failed to open (permission issue)
261                        let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::NoBpfDeviceAccess);
262                    }
263                }
264            }
265            Err(e) => {
266                log::warn!("Failed to create PKTAP device: {}", e);
267                log::info!(
268                    "PKTAP requires root privileges - run with 'sudo' for process metadata support"
269                );
270                log::info!("Falling back to regular capture (process detection will use lsof)");
271                // Store degradation reason - failed to create device (permission issue)
272                let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::MissingRootPrivileges);
273            }
274        }
275    }
276
277    // Track PKTAP degradation reasons for macOS
278    #[cfg(target_os = "macos")]
279    {
280        if config.interface.is_some() {
281            // Specific interface requested - PKTAP can't be used
282            let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::InterfaceSpecified);
283        }
284        if config.filter.is_some() {
285            log::warn!(
286                "BPF filter specified - using regular capture instead of PKTAP (BPF filters don't work with PKTAP)"
287            );
288            // Store degradation reason - BPF filter incompatible
289            let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::BpfFilterIncompatible);
290        }
291    }
292
293    // Fallback to regular capture (original code)
294    log::info!("Setting up regular packet capture");
295    let device = find_capture_device(&config.interface)?;
296
297    // Check if this is a TUN/TAP interface (for the log line below). This is a
298    // capture-side device concern, so we match the names here rather than pull
299    // all of `rustnet-core` into this crate just to label a log message. The
300    // actual TUN/TAP frame parsing still lives in `rustnet-core`.
301    let is_tun = device.name.starts_with("tun") || device.name.starts_with("utun");
302    let is_tap = device.name.starts_with("tap");
303    let is_tunnel = is_tun || is_tap;
304    let tunnel_type = if is_tun {
305        "TUN (Layer 3)"
306    } else if is_tap {
307        "TAP (Layer 2)"
308    } else {
309        "N/A"
310    };
311
312    log::info!(
313        "Setting up capture on device: {} ({}){}",
314        device.name,
315        device.desc.as_deref().unwrap_or("no description"),
316        if is_tunnel {
317            format!(" [Tunnel: {}]", tunnel_type)
318        } else {
319            String::new()
320        }
321    );
322
323    let device_name = device.name.clone();
324
325    // Create capture handle with promiscuous mode disabled
326    // We use non-promiscuous mode (read-only packet capture) which only requires CAP_NET_RAW
327    let cap = Capture::from_device(device)?
328        .promisc(false)
329        .snaplen(config.snaplen)
330        .buffer_size(config.buffer_size)
331        .timeout(config.timeout_ms)
332        .immediate_mode(true); // Parse packets ASAP
333
334    // Open the capture
335    let mut cap = cap.open()?;
336
337    // Apply BPF filter if specified
338    if let Some(filter) = &config.filter {
339        log::info!("Applying BPF filter: {}", filter);
340        cap.filter(filter, true)?;
341    }
342
343    // Note: We're not setting non-blocking mode as we're using timeout instead
344    let linktype = cap.get_datalink();
345
346    Ok((cap, device_name, linktype.0))
347}
348
349/// Validate that the specified interface exists (if provided)
350/// This is useful for failing fast before starting capture threads
351pub fn validate_interface(interface_name: &Option<String>) -> Result<()> {
352    if let Some(name) = interface_name {
353        // This will return an error if the interface doesn't exist
354        find_capture_device(&Some(name.clone()))?;
355    }
356    Ok(())
357}
358
359/// Find a capture device by name or return the default
360fn find_capture_device(interface_name: &Option<String>) -> Result<Device> {
361    match interface_name {
362        Some(name) => {
363            log::info!("Looking for interface: {}", name);
364
365            // Special handling for 'any' interface
366            if name == "any" {
367                #[cfg(not(target_os = "linux"))]
368                {
369                    return Err(anyhow!(
370                        "The 'any' interface is only supported on Linux.\n\
371                        On your platform, please specify a specific interface with -i <interface>.\n\
372                        Run without -i to auto-detect the default interface."
373                    ));
374                }
375
376                #[cfg(target_os = "linux")]
377                {
378                    log::info!("Using 'any' pseudo-interface to capture on all interfaces");
379                }
380            }
381
382            // List all devices
383            let devices = Device::list()?;
384
385            // Find exact match first
386            if let Some(device) = devices.iter().find(|d| d.name == *name) {
387                return Ok(device.clone());
388            }
389
390            // Try case-insensitive match
391            let name_lower = name.to_lowercase();
392            if let Some(device) = devices.iter().find(|d| d.name.to_lowercase() == name_lower) {
393                return Ok(device.clone());
394            }
395
396            // List available interfaces for error message
397            let available: Vec<String> = devices.iter().map(|d| d.name.clone()).collect();
398
399            Err(anyhow!(
400                "Interface '{}' not found. Available interfaces: {}",
401                name,
402                available.join(", ")
403            ))
404        }
405        None => {
406            log::info!("No interface specified, using default");
407
408            // Resolve active interface via OS routing table by creating a connectionless UDP socket
409            if let Some(active_ip) = std::net::UdpSocket::bind("0.0.0.0:0")
410                .and_then(|s| {
411                    let _ = s.connect("8.8.8.8:53");
412                    s.local_addr()
413                })
414                .ok()
415                .map(|addr| addr.ip())
416            {
417                log::info!("Found active routed IP: {}", active_ip);
418                if let Ok(devices) = Device::list()
419                    && let Some(device) = devices
420                        .into_iter()
421                        .find(|d| d.addresses.iter().any(|a| a.addr == active_ip))
422                {
423                    log::info!("Selected interface {} based on active route", device.name);
424                    return Ok(device);
425                }
426            }
427            log::info!("Fallback: using libpcap default device logic");
428
429            // Try to get default device
430            match Device::lookup() {
431                Ok(Some(device)) => {
432                    log::info!(
433                        "Found default device: {} ({})",
434                        device.name,
435                        device.desc.as_deref().unwrap_or("no description")
436                    );
437
438                    // Check if the default device is actually active
439                    let has_valid_ip = device.addresses.iter().any(|addr| {
440                        match &addr.addr {
441                            std::net::IpAddr::V4(v4) => {
442                                !v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
443                            }
444                            std::net::IpAddr::V6(_v6) => false, // Skip IPv6 for now
445                        }
446                    });
447
448                    // Check if it's a problematic interface type
449                    // Note: 'any' is excluded on non-Linux platforms where it doesn't work
450                    let is_problematic = device.name.starts_with("ap")
451                        || device.name.starts_with("awdl")
452                        || device.name.starts_with("llw")
453                        || device.name.starts_with("bridge")
454                        // TUN/TAP interfaces now supported - removed utun/tun/tap check
455                        || device.name.starts_with("vmnet")
456                        || (device.name == "any" && !cfg!(target_os = "linux"))
457                        || device.flags.is_loopback();
458
459                    if device.flags.is_up()
460                        && device.flags.is_running()
461                        && has_valid_ip
462                        && !is_problematic
463                    {
464                        log::info!("Default device appears active, using it");
465                        Ok(device)
466                    } else {
467                        log::warn!(
468                            "Default device '{}' is not suitable (up: {}, running: {}, has_ip: {}, problematic: {})",
469                            device.name,
470                            device.flags.is_up(),
471                            device.flags.is_running(),
472                            has_valid_ip,
473                            is_problematic
474                        );
475                        log::info!("Looking for a better interface...");
476
477                        // Fall through to the device selection logic below
478                        find_best_device()
479                    }
480                }
481                Ok(None) => {
482                    log::info!("No default device found");
483                    find_best_device()
484                }
485                Err(e) => Err(e.into()),
486            }
487        }
488    }
489}
490
491/// Simple packet reader that handles timeouts gracefully
492pub struct PacketReader {
493    capture: Capture<Active>,
494}
495
496/// A captured link-layer frame with the timestamp reported by libpcap/Npcap.
497#[derive(Debug, Clone)]
498pub struct CapturedPacket {
499    pub data: Vec<u8>,
500    pub timestamp: SystemTime,
501    pub original_len: u32,
502}
503
504impl PacketReader {
505    pub fn new(capture: Capture<Active>) -> Self {
506        Self { capture }
507    }
508
509    /// Read next packet, returning None on timeout.
510    pub fn next_packet(&mut self) -> Result<Option<CapturedPacket>> {
511        match self.capture.next_packet() {
512            Ok(packet) => {
513                let ts = packet.header.ts;
514                Ok(Some(CapturedPacket {
515                    data: packet.data.to_vec(),
516                    timestamp: timeval_to_system_time(ts.tv_sec, ts.tv_usec),
517                    original_len: packet.header.len,
518                }))
519            }
520            Err(PcapError::TimeoutExpired) => Ok(None),
521            Err(e) => Err(e.into()),
522        }
523    }
524
525    /// Get capture statistics
526    pub fn stats(&mut self) -> Result<CaptureStats> {
527        let stats = self.capture.stats()?;
528        let capture_stats = CaptureStats {
529            received: stats.received,
530            dropped: stats.dropped,
531            if_dropped: stats.if_dropped,
532        };
533
534        // Log dropped packets if any occurred
535        if capture_stats.total_dropped() > 0 {
536            log::debug!(
537                "Total {} packets dropped (kernel: {}, interface: {})",
538                capture_stats.total_dropped(),
539                capture_stats.dropped,
540                capture_stats.if_dropped
541            );
542        }
543
544        Ok(capture_stats)
545    }
546}
547
548fn timeval_to_system_time<S, U>(secs: S, usecs: U) -> SystemTime
549where
550    S: Into<i64>,
551    U: Into<i64>,
552{
553    let secs = secs.into();
554    let usecs = usecs.into().clamp(0, 999_999);
555    if secs < 0 {
556        UNIX_EPOCH
557    } else {
558        UNIX_EPOCH + Duration::from_secs(secs as u64) + Duration::from_micros(usecs as u64)
559    }
560}
561
562/// Packet capture statistics
563#[derive(Debug, Clone, Default)]
564pub struct CaptureStats {
565    pub received: u32,
566    pub dropped: u32,
567    /// Interface-level dropped packets (platform-specific)
568    pub if_dropped: u32,
569}
570
571impl CaptureStats {
572    /// Get total packets dropped (both kernel and interface level)
573    pub fn total_dropped(&self) -> u32 {
574        self.dropped.saturating_add(self.if_dropped)
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_default_config() {
584        let config = CaptureConfig::default();
585        assert_eq!(config.snaplen, 1514);
586        assert!(config.filter.is_none()); // Default starts without filter
587    }
588
589    #[test]
590    fn test_udp_routing_resolution_can_execute() {
591        // Sanity-check test to ensure the OS handles UDP metric routing cleanly.
592        // It's perfectly fine if this fails in hermetic CI environments without outbound routes.
593        if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0")
594            && socket.connect("8.8.8.8:53").is_ok()
595            && let Ok(addr) = socket.local_addr()
596        {
597            assert!(
598                !addr.ip().is_loopback(),
599                "Active routed IP should not be loopback"
600            );
601            assert!(
602                !addr.ip().is_unspecified(),
603                "Active routed IP should not be unspecified"
604            );
605        }
606    }
607}