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