mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-05-04 11:00:10 -05:00
Add TUN/TAP interface support (#43)
* feat: add TUN/TAP interface support Add comprehensive support for TUN/TAP virtual network interfaces by refactoring link layer parsing into modular components. New modules: - link_layer: Modular packet parsing (ethernet, raw_ip, linux_sll, tun_tap) - protocol: Dedicated TCP/UDP/ICMP parsers Changes: - Remove TUN/TAP interface exclusions in capture.rs - Add TUN/TAP detection and parsing support - macOS PKTAP support with conditional compilation Platform compatibility: - Linux: Full TUN/TAP support - macOS: TUN (utun*) and TAP support - Windows: No breaking changes Fixes #39
This commit is contained in:
@@ -31,13 +31,18 @@ rustnet
|
||||
**Basic usage examples:**
|
||||
|
||||
```bash
|
||||
# Run with default settings (monitors default interface)
|
||||
# Run with default settings
|
||||
# macOS: Uses PKTAP for process metadata
|
||||
# Linux/Other: Auto-detects active interface
|
||||
rustnet
|
||||
|
||||
# Specify network interface
|
||||
rustnet -i eth0
|
||||
rustnet --interface wlan0
|
||||
|
||||
# Linux: Monitor all interfaces simultaneously
|
||||
rustnet -i any
|
||||
|
||||
# Filter out localhost connections (already filtered by default)
|
||||
rustnet --no-localhost
|
||||
|
||||
@@ -79,15 +84,51 @@ Options:
|
||||
|
||||
#### `-i, --interface <INTERFACE>`
|
||||
|
||||
Specify which network interface to monitor. If not provided, RustNet will use the first available non-loopback interface.
|
||||
Specify which network interface to monitor.
|
||||
|
||||
**Default behavior (no `-i` flag):**
|
||||
- **macOS**: Automatically uses PKTAP for enhanced process metadata (requires sudo)
|
||||
- **Linux/Other**: Auto-detects the first available non-loopback interface
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Default: Auto-detect interface (PKTAP on macOS)
|
||||
rustnet
|
||||
|
||||
# Linux: Monitor all interfaces using the special "any" pseudo-interface
|
||||
rustnet -i any
|
||||
|
||||
# Monitor specific interfaces
|
||||
rustnet -i eth0 # Monitor Ethernet interface
|
||||
rustnet -i wlan0 # Monitor WiFi interface
|
||||
rustnet -i en0 # Monitor macOS primary interface
|
||||
|
||||
# Monitor VPN and tunnel interfaces (TUN/TAP support)
|
||||
rustnet -i utun0 # macOS VPN tunnel (TUN, Layer 3)
|
||||
rustnet -i tun0 # Linux/BSD VPN tunnel (TUN, Layer 3)
|
||||
rustnet -i tap0 # TAP interface (Layer 2, includes Ethernet)
|
||||
```
|
||||
|
||||
**TUN/TAP Interface Support:**
|
||||
|
||||
RustNet fully supports monitoring VPN and virtual network interfaces:
|
||||
|
||||
- **TUN interfaces** (Layer 3): Carry IP packets directly without Ethernet headers
|
||||
- Common on VPNs: WireGuard, OpenVPN (tun mode), Tailscale
|
||||
- Examples: `utun0-utun9` (macOS), `tun0-tun9` (Linux/BSD)
|
||||
|
||||
- **TAP interfaces** (Layer 2): Include full Ethernet frames
|
||||
- Used by: OpenVPN (tap mode), QEMU/KVM virtual networks, Docker
|
||||
- Examples: `tap0-tap9` (Linux/BSD)
|
||||
|
||||
RustNet automatically detects TUN/TAP interfaces and adjusts packet parsing accordingly. The interface type is displayed in the UI status area.
|
||||
|
||||
**Platform-specific notes:**
|
||||
- **macOS**: Without `-i`, PKTAP is used automatically for better process detection. Use `-i <interface>` to monitor a specific interface instead
|
||||
- **Linux**: Use `-i any` to capture on all interfaces simultaneously (not available on other platforms)
|
||||
- **TUN/TAP**: Fully supported on all platforms - RustNet detects interface type by name and adjusts parsing
|
||||
- **All platforms**: If you specify a non-existent interface, an error will show available interfaces
|
||||
|
||||
**Finding your interfaces:**
|
||||
- Linux: `ip link show` or `ifconfig`
|
||||
- macOS: `ifconfig` or `networksetup -listallhardwareports`
|
||||
|
||||
+27
-1
@@ -188,6 +188,9 @@ impl App {
|
||||
|
||||
/// Start packet capture thread
|
||||
fn start_capture_thread(&self, packet_tx: Sender<Vec<u8>>) -> Result<()> {
|
||||
// Validate interface exists before spawning thread (fail fast)
|
||||
crate::network::capture::validate_interface(&self.config.interface)?;
|
||||
|
||||
let capture_config = CaptureConfig {
|
||||
interface: self.config.interface.clone(),
|
||||
filter: self.config.bpf_filter.clone(),
|
||||
@@ -210,7 +213,7 @@ impl App {
|
||||
// Check if PKTAP is active (linktype 149 or 258)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use crate::network::pktap;
|
||||
use crate::network::link_layer::pktap;
|
||||
if pktap::is_pktap_linktype(linktype) {
|
||||
_pktap_active.store(true, Ordering::Relaxed);
|
||||
info!("✓ PKTAP is active - process metadata will be provided directly");
|
||||
@@ -791,6 +794,29 @@ impl App {
|
||||
.unwrap_or_else(|_| String::from("unknown"))
|
||||
}
|
||||
|
||||
/// Get link layer information for the current interface
|
||||
/// Returns (link_layer_type_name, is_tunnel)
|
||||
pub fn get_link_layer_info(&self) -> (String, bool) {
|
||||
use crate::network::link_layer::LinkLayerType;
|
||||
|
||||
if let Ok(linktype_opt) = self.linktype.read()
|
||||
&& let Some(dlt) = *linktype_opt
|
||||
{
|
||||
// Get interface name to detect TUN/TAP more accurately
|
||||
let interface_name = self.current_interface
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|opt| opt.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let link_type = LinkLayerType::from_dlt_and_name(dlt, &interface_name);
|
||||
let type_name = format!("{:?}", link_type);
|
||||
let is_tunnel = link_type.is_tunnel();
|
||||
return (type_name, is_tunnel);
|
||||
}
|
||||
(String::from("Unknown"), false)
|
||||
}
|
||||
|
||||
/// Stop all threads gracefully
|
||||
pub fn stop(&self) {
|
||||
info!("Stopping application");
|
||||
|
||||
+74
-20
@@ -69,12 +69,19 @@ fn find_best_device() -> Result<Device> {
|
||||
// Find the best active device
|
||||
let suitable_device = devices
|
||||
.iter()
|
||||
// First priority: up, running, and has a valid IP address
|
||||
// First priority: up, running, has a valid IP address, and NOT virtual
|
||||
.find(|d| {
|
||||
// Check if it's a virtual/problematic interface
|
||||
let desc_lower = d.desc.as_ref().map(|s| s.to_lowercase()).unwrap_or_default();
|
||||
let is_virtual = desc_lower.contains("hyper-v")
|
||||
|| desc_lower.contains("vmware")
|
||||
|| desc_lower.contains("virtualbox");
|
||||
|
||||
!d.name.starts_with("lo")
|
||||
// Note: 'any' is excluded here because it's not a real interface
|
||||
// Users can still specify '-i any' explicitly on Linux
|
||||
&& d.name != "any"
|
||||
&& !is_virtual // Skip virtual adapters in first priority too
|
||||
&& d.flags.is_up()
|
||||
&& d.flags.is_running()
|
||||
&& d.addresses.iter().any(|addr| {
|
||||
@@ -97,16 +104,25 @@ fn find_best_device() -> Result<Device> {
|
||||
// Third priority: any up interface with valid addresses (excluding problematic ones)
|
||||
.or_else(|| {
|
||||
devices.iter().find(|d| {
|
||||
// Check if it's a virtual/problematic interface
|
||||
let desc_lower = d.desc.as_ref().map(|s| s.to_lowercase()).unwrap_or_default();
|
||||
let is_virtual = desc_lower.contains("hyper-v")
|
||||
|| desc_lower.contains("virtual")
|
||||
|| desc_lower.contains("vmware")
|
||||
|| desc_lower.contains("virtualbox")
|
||||
|| desc_lower.contains("loopback");
|
||||
|
||||
!d.name.starts_with("lo") &&
|
||||
!d.name.starts_with("ap") && // Skip Apple's ap interfaces
|
||||
!d.name.starts_with("awdl") && // Skip Apple Wireless Direct
|
||||
!d.name.starts_with("llw") && // Skip Low latency WLAN
|
||||
!d.name.starts_with("bridge") && // Skip bridges
|
||||
!d.name.starts_with("utun") && // Skip tunnels
|
||||
// TUN/TAP interfaces now supported - removed utun/tun/tap exclusion
|
||||
!d.name.starts_with("vmnet") && // Skip VM interfaces
|
||||
// Note: 'any' is excluded here because it's not a real interface
|
||||
// Users can still specify '-i any' explicitly on Linux
|
||||
d.name != "any" &&
|
||||
!is_virtual && // Skip virtual adapters
|
||||
d.flags.is_up() &&
|
||||
!d.addresses.is_empty()
|
||||
})
|
||||
@@ -137,9 +153,9 @@ fn find_best_device() -> Result<Device> {
|
||||
|
||||
/// Setup packet capture with the given configuration
|
||||
pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, String, i32)> {
|
||||
// Try PKTAP first on macOS for process metadata
|
||||
// Try PKTAP first on macOS for process metadata, but only when no interface is explicitly specified
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if config.interface.is_none() {
|
||||
log::info!("Attempting to use PKTAP for process metadata on macOS");
|
||||
|
||||
match Capture::from_device("pktap") {
|
||||
@@ -199,10 +215,26 @@ pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, S
|
||||
log::info!("Setting up regular packet capture");
|
||||
let device = find_capture_device(&config.interface)?;
|
||||
|
||||
// Check if this is a TUN/TAP interface
|
||||
use crate::network::link_layer::tun_tap;
|
||||
let is_tunnel = tun_tap::is_tunnel_interface(&device.name);
|
||||
let tunnel_type = if tun_tap::is_tun_interface(&device.name) {
|
||||
"TUN (Layer 3)"
|
||||
} else if tun_tap::is_tap_interface(&device.name) {
|
||||
"TAP (Layer 2)"
|
||||
} else {
|
||||
"N/A"
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Setting up capture on device: {} ({})",
|
||||
"Setting up capture on device: {} ({}){}",
|
||||
device.name,
|
||||
device.desc.as_deref().unwrap_or("no description")
|
||||
device.desc.as_deref().unwrap_or("no description"),
|
||||
if is_tunnel {
|
||||
format!(" [Tunnel: {}]", tunnel_type)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
|
||||
let device_name = device.name.clone();
|
||||
@@ -239,6 +271,16 @@ pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, S
|
||||
Ok((cap, device_name, linktype.0))
|
||||
}
|
||||
|
||||
/// Validate that the specified interface exists (if provided)
|
||||
/// This is useful for failing fast before starting capture threads
|
||||
pub fn validate_interface(interface_name: &Option<String>) -> Result<()> {
|
||||
if let Some(name) = interface_name {
|
||||
// This will return an error if the interface doesn't exist
|
||||
find_capture_device(&Some(name.clone()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a capture device by name or return the default
|
||||
fn find_capture_device(interface_name: &Option<String>) -> Result<Device> {
|
||||
match interface_name {
|
||||
@@ -279,19 +321,13 @@ fn find_capture_device(interface_name: &Option<String>) -> Result<Device> {
|
||||
// List available interfaces for error message
|
||||
let available: Vec<String> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
format!(
|
||||
"{} ({})",
|
||||
d.name,
|
||||
d.desc.as_deref().unwrap_or("no description")
|
||||
)
|
||||
})
|
||||
.map(|d| d.name.clone())
|
||||
.collect();
|
||||
|
||||
Err(anyhow!(
|
||||
"Interface '{}' not found. Available interfaces:\n{}",
|
||||
"Interface '{}' not found. Available interfaces: {}",
|
||||
name,
|
||||
available.join("\n")
|
||||
available.join(", ")
|
||||
))
|
||||
}
|
||||
None => {
|
||||
@@ -322,7 +358,7 @@ fn find_capture_device(interface_name: &Option<String>) -> Result<Device> {
|
||||
|| device.name.starts_with("awdl")
|
||||
|| device.name.starts_with("llw")
|
||||
|| device.name.starts_with("bridge")
|
||||
|| device.name.starts_with("utun")
|
||||
// TUN/TAP interfaces now supported - removed utun/tun/tap check
|
||||
|| device.name.starts_with("vmnet")
|
||||
|| (device.name == "any" && !cfg!(target_os = "linux"))
|
||||
|| device.flags.is_loopback();
|
||||
@@ -381,11 +417,23 @@ impl PacketReader {
|
||||
/// Get capture statistics
|
||||
pub fn stats(&mut self) -> Result<CaptureStats> {
|
||||
let stats = self.capture.stats()?;
|
||||
Ok(CaptureStats {
|
||||
let capture_stats = CaptureStats {
|
||||
received: stats.received,
|
||||
dropped: stats.dropped,
|
||||
if_dropped: stats.if_dropped,
|
||||
})
|
||||
};
|
||||
|
||||
// Log dropped packets if any occurred
|
||||
if capture_stats.total_dropped() > 0 {
|
||||
log::debug!(
|
||||
"Total {} packets dropped (kernel: {}, interface: {})",
|
||||
capture_stats.total_dropped(),
|
||||
capture_stats.dropped,
|
||||
capture_stats.if_dropped
|
||||
);
|
||||
}
|
||||
|
||||
Ok(capture_stats)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,11 +442,17 @@ impl PacketReader {
|
||||
pub struct CaptureStats {
|
||||
pub received: u32,
|
||||
pub dropped: u32,
|
||||
#[allow(dead_code)]
|
||||
// TODO: implement interface-specific dropped packets
|
||||
/// Interface-level dropped packets (platform-specific)
|
||||
pub if_dropped: u32,
|
||||
}
|
||||
|
||||
impl CaptureStats {
|
||||
/// Get total packets dropped (both kernel and interface level)
|
||||
pub fn total_dropped(&self) -> u32 {
|
||||
self.dropped.saturating_add(self.if_dropped)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Ethernet (IEEE 802.3) frame parsing
|
||||
//!
|
||||
//! Handles DLT_EN10MB (Ethernet) frames with 14-byte header
|
||||
|
||||
use crate::network::parser::{PacketParser, ParsedPacket};
|
||||
|
||||
/// Parse an Ethernet frame and extract the network layer packet
|
||||
///
|
||||
/// Ethernet frame format (14 bytes):
|
||||
/// - Destination MAC (6 bytes)
|
||||
/// - Source MAC (6 bytes)
|
||||
/// - EtherType (2 bytes)
|
||||
///
|
||||
/// Returns the parsed packet if successful
|
||||
pub fn parse(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.len() < 14 {
|
||||
log::debug!("Ethernet frame too small: {} bytes", data.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract EtherType from bytes 12-13
|
||||
let ethertype = u16::from_be_bytes([data[12], data[13]]);
|
||||
|
||||
match ethertype {
|
||||
0x0800 => {
|
||||
// IPv4
|
||||
log::trace!("Ethernet: IPv4 packet detected");
|
||||
parser.parse_ipv4_packet_inner(data, process_name, process_id)
|
||||
}
|
||||
0x86dd => {
|
||||
// IPv6
|
||||
log::trace!("Ethernet: IPv6 packet detected");
|
||||
parser.parse_ipv6_packet_inner(data, process_name, process_id)
|
||||
}
|
||||
0x0806 => {
|
||||
// ARP
|
||||
log::trace!("Ethernet: ARP packet detected");
|
||||
parser.parse_arp_packet_inner(data, process_name, process_id)
|
||||
}
|
||||
_ => {
|
||||
log::debug!("Ethernet: Unknown EtherType: 0x{:04x}", ethertype);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ethernet_frame_too_small() {
|
||||
// Ethernet frames must be at least 14 bytes
|
||||
let small_frame = vec![0x00, 0x11, 0x22];
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse(&small_frame, &parser, None, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Linux "cooked" capture parsing
|
||||
//!
|
||||
//! Handles DLT_LINUX_SLL (113) and DLT_LINUX_SLL2 (276)
|
||||
//! Used by the Linux "any" pseudo-interface
|
||||
|
||||
use crate::network::parser::{PacketParser, ParsedPacket};
|
||||
|
||||
/// Parse Linux Cooked Capture v1 packet (DLT_LINUX_SLL)
|
||||
///
|
||||
/// Header format (16 bytes):
|
||||
/// - Packet type (2 bytes)
|
||||
/// - ARPHRD type (2 bytes)
|
||||
/// - Link-layer address length (2 bytes)
|
||||
/// - Link-layer address (8 bytes)
|
||||
/// - Protocol type (2 bytes) - EtherType
|
||||
///
|
||||
/// IP payload starts at byte 16
|
||||
pub fn parse_sll(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.len() < 16 {
|
||||
log::debug!("Linux SLL packet too small: {} bytes", data.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
// Protocol type is at bytes 14-15 (EtherType)
|
||||
let protocol = u16::from_be_bytes([data[14], data[15]]);
|
||||
|
||||
match protocol {
|
||||
0x0800 => {
|
||||
// IPv4 - payload starts at byte 16
|
||||
log::trace!("Linux SLL: IPv4 packet detected");
|
||||
let ip_data = &data[16..];
|
||||
parser.parse_raw_ipv4_packet(ip_data, process_name, process_id)
|
||||
}
|
||||
0x86dd => {
|
||||
// IPv6 - payload starts at byte 16
|
||||
log::trace!("Linux SLL: IPv6 packet detected");
|
||||
let ip_data = &data[16..];
|
||||
parser.parse_raw_ipv6_packet(ip_data, process_name, process_id)
|
||||
}
|
||||
_ => {
|
||||
log::debug!("Linux SLL: Unknown protocol: 0x{:04x}", protocol);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Linux Cooked Capture v2 packet (DLT_LINUX_SLL2)
|
||||
///
|
||||
/// Header format (20 bytes):
|
||||
/// - Protocol type (2 bytes) - EtherType
|
||||
/// - Reserved (2 bytes)
|
||||
/// - Interface index (4 bytes)
|
||||
/// - ARPHRD type (2 bytes)
|
||||
/// - Packet type (1 byte)
|
||||
/// - Link-layer address length (1 byte)
|
||||
/// - Link-layer address (8 bytes)
|
||||
///
|
||||
/// IP payload starts at byte 20
|
||||
pub fn parse_sll2(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.len() < 20 {
|
||||
log::debug!("Linux SLL2 packet too small: {} bytes", data.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
// Protocol type is at bytes 0-1 (EtherType)
|
||||
let protocol = u16::from_be_bytes([data[0], data[1]]);
|
||||
|
||||
match protocol {
|
||||
0x0800 => {
|
||||
// IPv4 - payload starts at byte 20
|
||||
log::trace!("Linux SLL2: IPv4 packet detected");
|
||||
let ip_data = &data[20..];
|
||||
parser.parse_raw_ipv4_packet(ip_data, process_name, process_id)
|
||||
}
|
||||
0x86dd => {
|
||||
// IPv6 - payload starts at byte 20
|
||||
log::trace!("Linux SLL2: IPv6 packet detected");
|
||||
let ip_data = &data[20..];
|
||||
parser.parse_raw_ipv6_packet(ip_data, process_name, process_id)
|
||||
}
|
||||
_ => {
|
||||
log::debug!("Linux SLL2: Unknown protocol: 0x{:04x}", protocol);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sll_packet_too_small() {
|
||||
let small_packet = vec![0x00; 10];
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse_sll(&small_packet, &parser, None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sll2_packet_too_small() {
|
||||
let small_packet = vec![0x00; 15];
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse_sll2(&small_packet, &parser, None, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Link layer (Layer 2) packet parsing
|
||||
//!
|
||||
//! This module handles data-link layer protocols and extracts network-layer packets:
|
||||
//! - Ethernet (DLT_EN10MB)
|
||||
//! - Linux Cooked Capture v1 and v2 (DLT_LINUX_SLL, DLT_LINUX_SLL2)
|
||||
//! - Raw IP packets (DLT_RAW, LINKTYPE_IPV4, LINKTYPE_IPV6)
|
||||
//! - TUN/TAP interfaces
|
||||
//! - PKTAP (macOS process metadata)
|
||||
|
||||
pub mod ethernet;
|
||||
pub mod linux_sll;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod pktap;
|
||||
pub mod raw_ip;
|
||||
pub mod tun_tap;
|
||||
|
||||
/// Data Link Type (DLT) constants
|
||||
/// These match the values from libpcap
|
||||
pub mod dlt {
|
||||
pub const EN10MB: i32 = 1; // Ethernet
|
||||
pub const RAW: i32 = 12; // Raw IP (no link layer)
|
||||
pub const NULL: i32 = 0; // BSD loopback (sometimes used by TUN)
|
||||
pub const LINUX_SLL: i32 = 113; // Linux "cooked" capture v1
|
||||
pub const PKTAP: i32 = 149; // Apple PKTAP (DLT_USER2)
|
||||
pub const PKTAP_STANDARD: i32 = 258; // Standard PKTAP
|
||||
pub const LINUX_SLL2: i32 = 276; // Linux "cooked" capture v2
|
||||
|
||||
// Link type values for raw IP packets
|
||||
pub const LINKTYPE_RAW: i32 = 101; // Raw IPv4/IPv6
|
||||
pub const LINKTYPE_IPV4: i32 = 228; // Raw IPv4 only
|
||||
pub const LINKTYPE_IPV6: i32 = 229; // Raw IPv6 only
|
||||
}
|
||||
|
||||
/// Link layer type enum for identifying interface types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinkLayerType {
|
||||
Ethernet,
|
||||
RawIP,
|
||||
LinuxSLL,
|
||||
LinuxSLL2,
|
||||
Pktap,
|
||||
Tun,
|
||||
Tap,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LinkLayerType {
|
||||
/// Determine link layer type from DLT value
|
||||
pub fn from_dlt(dlt: i32) -> Self {
|
||||
match dlt {
|
||||
dlt::EN10MB => LinkLayerType::Ethernet,
|
||||
dlt::RAW | dlt::NULL | dlt::LINKTYPE_RAW | dlt::LINKTYPE_IPV4 | dlt::LINKTYPE_IPV6 => {
|
||||
LinkLayerType::RawIP
|
||||
}
|
||||
dlt::LINUX_SLL => LinkLayerType::LinuxSLL,
|
||||
dlt::LINUX_SLL2 => LinkLayerType::LinuxSLL2,
|
||||
dlt::PKTAP | dlt::PKTAP_STANDARD => LinkLayerType::Pktap,
|
||||
_ => LinkLayerType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine link layer type from both DLT value and interface name
|
||||
///
|
||||
/// This is more accurate than `from_dlt()` alone because it can distinguish
|
||||
/// TUN/TAP interfaces from regular interfaces that use the same DLT codes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let link_type = LinkLayerType::from_dlt_and_name(12, "tun0");
|
||||
/// assert!(matches!(link_type, LinkLayerType::Tun));
|
||||
///
|
||||
/// let link_type = LinkLayerType::from_dlt_and_name(1, "tap0");
|
||||
/// assert!(matches!(link_type, LinkLayerType::Tap));
|
||||
/// ```
|
||||
pub fn from_dlt_and_name(dlt: i32, interface_name: &str) -> Self {
|
||||
// Check if this is a TUN/TAP interface by name
|
||||
if tun_tap::is_tun_interface(interface_name) {
|
||||
return LinkLayerType::Tun;
|
||||
}
|
||||
if tun_tap::is_tap_interface(interface_name) {
|
||||
return LinkLayerType::Tap;
|
||||
}
|
||||
|
||||
// Otherwise, use DLT-based detection
|
||||
Self::from_dlt(dlt)
|
||||
}
|
||||
|
||||
/// Check if this link type represents a TUN/TAP interface
|
||||
pub fn is_tunnel(&self) -> bool {
|
||||
matches!(self, LinkLayerType::Tun | LinkLayerType::Tap)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_linktype_from_dlt() {
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::EN10MB), LinkLayerType::Ethernet);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::RAW), LinkLayerType::RawIP);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::NULL), LinkLayerType::RawIP);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::LINUX_SLL), LinkLayerType::LinuxSLL);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::LINUX_SLL2), LinkLayerType::LinuxSLL2);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::PKTAP), LinkLayerType::Pktap);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::PKTAP_STANDARD), LinkLayerType::Pktap);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::LINKTYPE_RAW), LinkLayerType::RawIP);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::LINKTYPE_IPV4), LinkLayerType::RawIP);
|
||||
assert_eq!(LinkLayerType::from_dlt(dlt::LINKTYPE_IPV6), LinkLayerType::RawIP);
|
||||
assert_eq!(LinkLayerType::from_dlt(999), LinkLayerType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_tunnel() {
|
||||
assert!(LinkLayerType::Tun.is_tunnel());
|
||||
assert!(LinkLayerType::Tap.is_tunnel());
|
||||
assert!(!LinkLayerType::Ethernet.is_tunnel());
|
||||
assert!(!LinkLayerType::RawIP.is_tunnel());
|
||||
assert!(!LinkLayerType::LinuxSLL.is_tunnel());
|
||||
assert!(!LinkLayerType::Pktap.is_tunnel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_dlt_and_name() {
|
||||
// Test TUN interface detection
|
||||
assert_eq!(
|
||||
LinkLayerType::from_dlt_and_name(dlt::RAW, "tun0"),
|
||||
LinkLayerType::Tun
|
||||
);
|
||||
assert_eq!(
|
||||
LinkLayerType::from_dlt_and_name(dlt::RAW, "utun0"),
|
||||
LinkLayerType::Tun
|
||||
);
|
||||
|
||||
// Test TAP interface detection
|
||||
assert_eq!(
|
||||
LinkLayerType::from_dlt_and_name(dlt::EN10MB, "tap0"),
|
||||
LinkLayerType::Tap
|
||||
);
|
||||
|
||||
// Test regular interface (not TUN/TAP)
|
||||
assert_eq!(
|
||||
LinkLayerType::from_dlt_and_name(dlt::EN10MB, "eth0"),
|
||||
LinkLayerType::Ethernet
|
||||
);
|
||||
assert_eq!(
|
||||
LinkLayerType::from_dlt_and_name(dlt::RAW, "wlan0"),
|
||||
LinkLayerType::RawIP
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Raw IP packet parsing (no link-layer header)
|
||||
//!
|
||||
//! Handles DLT_RAW (12), DLT_NULL (0), LINKTYPE_RAW (101),
|
||||
//! LINKTYPE_IPV4 (228), and LINKTYPE_IPV6 (229)
|
||||
//!
|
||||
//! Used by TUN devices which operate at Layer 3 (network layer)
|
||||
|
||||
use crate::network::parser::{PacketParser, ParsedPacket};
|
||||
|
||||
/// Parse a raw IP packet (auto-detect IPv4 or IPv6)
|
||||
///
|
||||
/// TUN devices typically send raw IP packets without any link-layer header.
|
||||
/// The first nibble of the packet indicates the IP version.
|
||||
pub fn parse(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.is_empty() {
|
||||
log::debug!("Raw IP: Empty packet");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check IP version from first nibble
|
||||
let version = data[0] >> 4;
|
||||
match version {
|
||||
4 => {
|
||||
log::trace!("Raw IP: IPv4 packet detected");
|
||||
parser.parse_raw_ipv4_packet(data, process_name, process_id)
|
||||
}
|
||||
6 => {
|
||||
log::trace!("Raw IP: IPv6 packet detected");
|
||||
parser.parse_raw_ipv6_packet(data, process_name, process_id)
|
||||
}
|
||||
_ => {
|
||||
log::debug!("Raw IP: Unknown IP version: {}", version);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a raw IPv4 packet only
|
||||
///
|
||||
/// Used when the link type explicitly indicates IPv4 (LINKTYPE_IPV4 = 228)
|
||||
pub fn parse_ipv4(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.is_empty() || data.len() < 20 {
|
||||
log::debug!("Raw IPv4: Packet too small");
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = data[0] >> 4;
|
||||
if version != 4 {
|
||||
log::warn!("Raw IPv4: Expected IPv4 but got version {}", version);
|
||||
return None;
|
||||
}
|
||||
|
||||
log::trace!("Raw IPv4: Parsing IPv4 packet");
|
||||
parser.parse_raw_ipv4_packet(data, process_name, process_id)
|
||||
}
|
||||
|
||||
/// Parse a raw IPv6 packet only
|
||||
///
|
||||
/// Used when the link type explicitly indicates IPv6 (LINKTYPE_IPV6 = 229)
|
||||
pub fn parse_ipv6(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if data.is_empty() || data.len() < 40 {
|
||||
log::debug!("Raw IPv6: Packet too small");
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = data[0] >> 4;
|
||||
if version != 6 {
|
||||
log::warn!("Raw IPv6: Expected IPv6 but got version {}", version);
|
||||
return None;
|
||||
}
|
||||
|
||||
log::trace!("Raw IPv6: Parsing IPv6 packet");
|
||||
parser.parse_raw_ipv6_packet(data, process_name, process_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_raw_ip_empty_packet() {
|
||||
let empty = vec![];
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse(&empty, &parser, None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_ipv4_version_check() {
|
||||
// Create a minimal IPv6 packet but try to parse as IPv4
|
||||
let ipv6_packet = vec![0x60, 0x00, 0x00, 0x00]; // Version 6
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse_ipv4(&ipv6_packet, &parser, None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_ipv6_version_check() {
|
||||
// Create a minimal IPv4 packet but try to parse as IPv6
|
||||
let ipv4_packet = vec![0x45, 0x00, 0x00, 0x00]; // Version 4
|
||||
let parser = PacketParser::new();
|
||||
assert!(parse_ipv6(&ipv4_packet, &parser, None, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! TUN/TAP interface support
|
||||
//!
|
||||
//! TUN (Layer 3) and TAP (Layer 2) virtual network interfaces
|
||||
//!
|
||||
//! - TUN: Operates at IP layer, carries raw IP packets (DLT_RAW or DLT_NULL)
|
||||
//! - TAP: Operates at Ethernet layer, carries Ethernet frames (DLT_EN10MB)
|
||||
|
||||
use crate::network::link_layer::{ethernet, raw_ip};
|
||||
use crate::network::parser::{PacketParser, ParsedPacket};
|
||||
|
||||
/// Detect if an interface name is a TUN interface
|
||||
///
|
||||
/// TUN interface naming conventions:
|
||||
/// - Linux: `tun0`, `tun1`, etc.
|
||||
/// - macOS: `utun0`, `utun1`, etc.
|
||||
/// - BSD: `tun0`, `tun1`, etc.
|
||||
pub fn is_tun_interface(name: &str) -> bool {
|
||||
name.starts_with("tun") || name.starts_with("utun")
|
||||
}
|
||||
|
||||
/// Detect if an interface name is a TAP interface
|
||||
///
|
||||
/// TAP interface naming conventions:
|
||||
/// - Linux: `tap0`, `tap1`, etc.
|
||||
/// - macOS: `tap0`, `tap1`, etc. (requires third-party drivers)
|
||||
/// - BSD: `tap0`, `tap1`, etc.
|
||||
pub fn is_tap_interface(name: &str) -> bool {
|
||||
name.starts_with("tap")
|
||||
}
|
||||
|
||||
/// Detect if an interface name is any tunnel interface (TUN or TAP)
|
||||
pub fn is_tunnel_interface(name: &str) -> bool {
|
||||
is_tun_interface(name) || is_tap_interface(name)
|
||||
}
|
||||
|
||||
/// Parse a TUN packet (raw IP, no link-layer header)
|
||||
///
|
||||
/// TUN devices operate at the network layer (Layer 3) and carry raw IP packets.
|
||||
/// This is a convenience wrapper around raw_ip::parse()
|
||||
pub fn parse_tun(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
log::trace!("TUN: Parsing raw IP packet ({} bytes)", data.len());
|
||||
raw_ip::parse(data, parser, process_name, process_id)
|
||||
}
|
||||
|
||||
/// Parse a TAP packet (Ethernet frame with full header)
|
||||
///
|
||||
/// TAP devices operate at the data-link layer (Layer 2) and carry Ethernet frames.
|
||||
/// This is a convenience wrapper around ethernet::parse()
|
||||
pub fn parse_tap(
|
||||
data: &[u8],
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
log::trace!("TAP: Parsing Ethernet frame ({} bytes)", data.len());
|
||||
ethernet::parse(data, parser, process_name, process_id)
|
||||
}
|
||||
|
||||
/// Determine the appropriate parser based on DLT type
|
||||
///
|
||||
/// This is the main entry point for parsing TUN/TAP packets when you know the DLT type.
|
||||
pub fn parse_by_dlt(
|
||||
data: &[u8],
|
||||
dlt: i32,
|
||||
parser: &PacketParser,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Option<ParsedPacket> {
|
||||
match dlt {
|
||||
// DLT_NULL - BSD/macOS loopback with 4-byte header
|
||||
0 => {
|
||||
log::trace!("TUN/TAP: DLT_NULL (0)");
|
||||
parse_tun(data, parser, process_name, process_id)
|
||||
}
|
||||
// TAP devices use Ethernet (Layer 2)
|
||||
1 => {
|
||||
log::trace!("TUN/TAP: DLT_EN10MB (1) - TAP");
|
||||
parse_tap(data, parser, process_name, process_id)
|
||||
}
|
||||
// DLT_RAW - Raw IP packets (no link layer)
|
||||
12 | 101 => {
|
||||
log::trace!("TUN/TAP: DLT_RAW ({}) - TUN", dlt);
|
||||
parse_tun(data, parser, process_name, process_id)
|
||||
}
|
||||
// IPv4-only packets
|
||||
228 => {
|
||||
log::trace!("TUN/TAP: LINKTYPE_IPV4 (228) - TUN");
|
||||
raw_ip::parse_ipv4(data, parser, process_name, process_id)
|
||||
}
|
||||
// IPv6-only packets
|
||||
229 => {
|
||||
log::trace!("TUN/TAP: LINKTYPE_IPV6 (229) - TUN");
|
||||
raw_ip::parse_ipv6(data, parser, process_name, process_id)
|
||||
}
|
||||
_ => {
|
||||
log::warn!("TUN/TAP: Unsupported DLT type: {}", dlt);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tun_interface_detection() {
|
||||
assert!(is_tun_interface("tun0"));
|
||||
assert!(is_tun_interface("tun1"));
|
||||
assert!(is_tun_interface("tun10"));
|
||||
assert!(is_tun_interface("utun0")); // macOS
|
||||
assert!(is_tun_interface("utun1"));
|
||||
assert!(!is_tun_interface("eth0"));
|
||||
assert!(!is_tun_interface("tap0"));
|
||||
assert!(!is_tun_interface("wlan0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tap_interface_detection() {
|
||||
assert!(is_tap_interface("tap0"));
|
||||
assert!(is_tap_interface("tap1"));
|
||||
assert!(is_tap_interface("tap10"));
|
||||
assert!(!is_tap_interface("tun0"));
|
||||
assert!(!is_tap_interface("eth0"));
|
||||
assert!(!is_tap_interface("wlan0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tunnel_interface_detection() {
|
||||
assert!(is_tunnel_interface("tun0"));
|
||||
assert!(is_tunnel_interface("tap0"));
|
||||
assert!(is_tunnel_interface("utun0"));
|
||||
assert!(!is_tunnel_interface("eth0"));
|
||||
assert!(!is_tunnel_interface("wlan0"));
|
||||
assert!(!is_tunnel_interface("lo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_by_dlt() {
|
||||
use crate::network::parser::PacketParser;
|
||||
|
||||
let parser = PacketParser::new();
|
||||
|
||||
// Test with empty packet - should return None
|
||||
let empty = vec![];
|
||||
assert!(parse_by_dlt(&empty, 12, &parser, None, None).is_none());
|
||||
assert!(parse_by_dlt(&empty, 1, &parser, None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tun_tap() {
|
||||
use crate::network::parser::PacketParser;
|
||||
|
||||
let parser = PacketParser::new();
|
||||
let empty = vec![];
|
||||
|
||||
// TUN parsing should fail on empty packet
|
||||
assert!(parse_tun(&empty, &parser, None, None).is_none());
|
||||
|
||||
// TAP parsing should fail on empty packet
|
||||
assert!(parse_tap(&empty, &parser, None, None).is_none());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
pub mod capture;
|
||||
pub mod dpi;
|
||||
pub mod link_layer;
|
||||
pub mod merge;
|
||||
pub mod parser;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod pktap;
|
||||
pub mod platform;
|
||||
pub mod privileges;
|
||||
pub mod protocol;
|
||||
pub mod services;
|
||||
pub mod types;
|
||||
|
||||
+471
-462
File diff suppressed because it is too large
Load Diff
+166
-12
@@ -29,10 +29,17 @@ struct ProcessCache {
|
||||
|
||||
impl WindowsProcessLookup {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Use a very old timestamp that's guaranteed to be before now
|
||||
// by using checked_sub and falling back to epoch
|
||||
let now = Instant::now();
|
||||
let initial_refresh = now
|
||||
.checked_sub(Duration::from_secs(3600))
|
||||
.unwrap_or_else(|| now.checked_sub(Duration::from_secs(60)).unwrap_or(now));
|
||||
|
||||
Ok(Self {
|
||||
cache: RwLock::new(ProcessCache {
|
||||
lookup: HashMap::new(),
|
||||
last_refresh: Instant::now() - Duration::from_secs(3600), // Force initial refresh
|
||||
last_refresh: initial_refresh,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -64,9 +71,16 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if WIN32_ERROR(result) != ERROR_INSUFFICIENT_BUFFER {
|
||||
log::debug!("GetExtendedTcpTable (IPv4) returned no data or error: {}", result);
|
||||
return Ok(()); // No connections or error
|
||||
}
|
||||
|
||||
if size == 0 || size > 100_000_000 {
|
||||
// Sanity check: reject unreasonably large sizes (100MB limit)
|
||||
log::warn!("GetExtendedTcpTable (IPv4) returned invalid size: {}", size);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allocate buffer and get actual data
|
||||
table = vec![0u8; size as usize];
|
||||
let result = GetExtendedTcpTable(
|
||||
@@ -79,13 +93,35 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if result != 0 {
|
||||
log::debug!("GetExtendedTcpTable (IPv4) second call failed: {}", result);
|
||||
return Ok(()); // Error getting table
|
||||
}
|
||||
|
||||
// Verify we have enough data for the header
|
||||
if table.len() < std::mem::size_of::<u32>() {
|
||||
log::warn!("TCP table buffer too small for header");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse the table
|
||||
let tcp_table = &*(table.as_ptr() as *const MIB_TCPTABLE_OWNER_PID);
|
||||
let num_entries = tcp_table.dwNumEntries as usize;
|
||||
|
||||
// Bounds check: ensure we have enough space for all entries
|
||||
let required_size = std::mem::size_of::<u32>()
|
||||
+ num_entries * std::mem::size_of::<MIB_TCPROW_OWNER_PID>();
|
||||
if table.len() < required_size {
|
||||
log::warn!(
|
||||
"TCP table buffer too small: got {} bytes, need {} for {} entries",
|
||||
table.len(),
|
||||
required_size,
|
||||
num_entries
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::debug!("Processing {} TCP IPv4 connections", num_entries);
|
||||
|
||||
// Get pointer to the first entry
|
||||
let rows_ptr = &tcp_table.table[0] as *const MIB_TCPROW_OWNER_PID;
|
||||
|
||||
@@ -94,12 +130,12 @@ impl WindowsProcessLookup {
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes())),
|
||||
u16::from_be((row.dwLocalPort as u16).to_be()),
|
||||
u16::from_be(row.dwLocalPort as u16),
|
||||
);
|
||||
|
||||
let remote_addr = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::from(row.dwRemoteAddr.to_ne_bytes())),
|
||||
u16::from_be((row.dwRemotePort as u16).to_be()),
|
||||
u16::from_be(row.dwRemotePort as u16),
|
||||
);
|
||||
|
||||
let key = ConnectionKey {
|
||||
@@ -109,6 +145,14 @@ impl WindowsProcessLookup {
|
||||
};
|
||||
|
||||
if let Some(process_name) = get_process_name_from_pid(row.dwOwningPid) {
|
||||
log::trace!(
|
||||
"Cached: {:?} {} -> {} (PID: {}, {})",
|
||||
key.protocol,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
row.dwOwningPid,
|
||||
process_name
|
||||
);
|
||||
cache.insert(key, (row.dwOwningPid, process_name));
|
||||
}
|
||||
}
|
||||
@@ -133,9 +177,16 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if WIN32_ERROR(result) != ERROR_INSUFFICIENT_BUFFER {
|
||||
log::debug!("GetExtendedTcpTable (IPv6) returned no data or error: {}", result);
|
||||
return Ok(()); // No connections or error
|
||||
}
|
||||
|
||||
if size == 0 || size > 100_000_000 {
|
||||
// Sanity check: reject unreasonably large sizes (100MB limit)
|
||||
log::warn!("GetExtendedTcpTable (IPv6) returned invalid size: {}", size);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allocate buffer and get actual data
|
||||
table = vec![0u8; size as usize];
|
||||
let result = GetExtendedTcpTable(
|
||||
@@ -148,13 +199,35 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if result != 0 {
|
||||
log::debug!("GetExtendedTcpTable (IPv6) second call failed: {}", result);
|
||||
return Ok(()); // Error getting table
|
||||
}
|
||||
|
||||
// Verify we have enough data for the header
|
||||
if table.len() < std::mem::size_of::<u32>() {
|
||||
log::warn!("TCP IPv6 table buffer too small for header");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse the table
|
||||
let tcp_table = &*(table.as_ptr() as *const MIB_TCP6TABLE_OWNER_PID);
|
||||
let num_entries = tcp_table.dwNumEntries as usize;
|
||||
|
||||
// Bounds check: ensure we have enough space for all entries
|
||||
let required_size = std::mem::size_of::<u32>()
|
||||
+ num_entries * std::mem::size_of::<MIB_TCP6ROW_OWNER_PID>();
|
||||
if table.len() < required_size {
|
||||
log::warn!(
|
||||
"TCP IPv6 table buffer too small: got {} bytes, need {} for {} entries",
|
||||
table.len(),
|
||||
required_size,
|
||||
num_entries
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::debug!("Processing {} TCP IPv6 connections", num_entries);
|
||||
|
||||
// Get pointer to the first entry
|
||||
let rows_ptr = &tcp_table.table[0] as *const MIB_TCP6ROW_OWNER_PID;
|
||||
|
||||
@@ -163,12 +236,12 @@ impl WindowsProcessLookup {
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
IpAddr::V6(Ipv6Addr::from(row.ucLocalAddr)),
|
||||
u16::from_be((row.dwLocalPort as u16).to_be()),
|
||||
u16::from_be(row.dwLocalPort as u16),
|
||||
);
|
||||
|
||||
let remote_addr = SocketAddr::new(
|
||||
IpAddr::V6(Ipv6Addr::from(row.ucRemoteAddr)),
|
||||
u16::from_be((row.dwRemotePort as u16).to_be()),
|
||||
u16::from_be(row.dwRemotePort as u16),
|
||||
);
|
||||
|
||||
let key = ConnectionKey {
|
||||
@@ -178,6 +251,14 @@ impl WindowsProcessLookup {
|
||||
};
|
||||
|
||||
if let Some(process_name) = get_process_name_from_pid(row.dwOwningPid) {
|
||||
log::trace!(
|
||||
"Cached: {:?} {} -> {} (PID: {}, {})",
|
||||
key.protocol,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
row.dwOwningPid,
|
||||
process_name
|
||||
);
|
||||
cache.insert(key, (row.dwOwningPid, process_name));
|
||||
}
|
||||
}
|
||||
@@ -213,9 +294,16 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if WIN32_ERROR(result) != ERROR_INSUFFICIENT_BUFFER {
|
||||
log::debug!("GetExtendedUdpTable (IPv4) returned no data or error: {}", result);
|
||||
return Ok(()); // No connections or error
|
||||
}
|
||||
|
||||
if size == 0 || size > 100_000_000 {
|
||||
// Sanity check: reject unreasonably large sizes (100MB limit)
|
||||
log::warn!("GetExtendedUdpTable (IPv4) returned invalid size: {}", size);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allocate buffer and get actual data
|
||||
table = vec![0u8; size as usize];
|
||||
let result = GetExtendedUdpTable(
|
||||
@@ -228,13 +316,35 @@ impl WindowsProcessLookup {
|
||||
);
|
||||
|
||||
if result != 0 {
|
||||
log::debug!("GetExtendedUdpTable (IPv4) second call failed: {}", result);
|
||||
return Ok(()); // Error getting table
|
||||
}
|
||||
|
||||
// Verify we have enough data for the header
|
||||
if table.len() < std::mem::size_of::<u32>() {
|
||||
log::warn!("UDP table buffer too small for header");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse the table
|
||||
let udp_table = &*(table.as_ptr() as *const MIB_UDPTABLE_OWNER_PID);
|
||||
let num_entries = udp_table.dwNumEntries as usize;
|
||||
|
||||
// Bounds check: ensure we have enough space for all entries
|
||||
let required_size = std::mem::size_of::<u32>()
|
||||
+ num_entries * std::mem::size_of::<MIB_UDPROW_OWNER_PID>();
|
||||
if table.len() < required_size {
|
||||
log::warn!(
|
||||
"UDP table buffer too small: got {} bytes, need {} for {} entries",
|
||||
table.len(),
|
||||
required_size,
|
||||
num_entries
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::debug!("Processing {} UDP IPv4 connections", num_entries);
|
||||
|
||||
// Get pointer to the first entry
|
||||
let rows_ptr = &udp_table.table[0] as *const MIB_UDPROW_OWNER_PID;
|
||||
|
||||
@@ -243,7 +353,7 @@ impl WindowsProcessLookup {
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes())),
|
||||
u16::from_be((row.dwLocalPort as u16).to_be()),
|
||||
u16::from_be(row.dwLocalPort as u16),
|
||||
);
|
||||
|
||||
// UDP doesn't have remote address in the table
|
||||
@@ -256,6 +366,14 @@ impl WindowsProcessLookup {
|
||||
};
|
||||
|
||||
if let Some(process_name) = get_process_name_from_pid(row.dwOwningPid) {
|
||||
log::trace!(
|
||||
"Cached: {:?} {} -> {} (PID: {}, {})",
|
||||
key.protocol,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
row.dwOwningPid,
|
||||
process_name
|
||||
);
|
||||
cache.insert(key, (row.dwOwningPid, process_name));
|
||||
}
|
||||
}
|
||||
@@ -275,20 +393,46 @@ impl ProcessLookup for WindowsProcessLookup {
|
||||
fn get_process_for_connection(&self, conn: &Connection) -> Option<(u32, String)> {
|
||||
let key = ConnectionKey::from_connection(conn);
|
||||
|
||||
// Try cache first
|
||||
// Try cache first - handle poisoned lock gracefully
|
||||
{
|
||||
let cache = self.cache.read().unwrap();
|
||||
let cache = match self.cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(poisoned) => {
|
||||
log::warn!("Process cache lock was poisoned, recovering data");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
|
||||
if cache.last_refresh.elapsed() < Duration::from_secs(2)
|
||||
&& let Some(process_info) = cache.lookup.get(&key)
|
||||
{
|
||||
log::trace!("✓ Cache hit: {:?} {} -> {} => {:?}",
|
||||
key.protocol, key.local_addr, key.remote_addr, process_info);
|
||||
return Some(process_info.clone());
|
||||
} else {
|
||||
log::trace!("✗ Cache miss: {:?} {} -> {} (cache: {} entries, age: {}s)",
|
||||
key.protocol, key.local_addr, key.remote_addr,
|
||||
cache.lookup.len(), cache.last_refresh.elapsed().as_secs());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is stale or miss, refresh
|
||||
if self.refresh().is_ok() {
|
||||
let cache = self.cache.read().unwrap();
|
||||
cache.lookup.get(&key).cloned()
|
||||
let cache = match self.cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(poisoned) => {
|
||||
log::warn!("Process cache lock was poisoned after refresh, recovering data");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
let result = cache.lookup.get(&key).cloned();
|
||||
if result.is_some() {
|
||||
log::trace!("✓ Found after refresh: {:?} => {:?}", key, result);
|
||||
} else {
|
||||
log::trace!("✗ Still no match after refresh for: {:?} {} -> {}",
|
||||
key.protocol, key.local_addr, key.remote_addr);
|
||||
}
|
||||
result
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -300,15 +444,25 @@ impl ProcessLookup for WindowsProcessLookup {
|
||||
self.refresh_tcp_processes(&mut new_cache)?;
|
||||
self.refresh_udp_processes(&mut new_cache)?;
|
||||
|
||||
let mut cache = self.cache.write().unwrap();
|
||||
let mut cache = match self.cache.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(poisoned) => {
|
||||
log::warn!("Process cache write lock was poisoned, recovering and replacing cache");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
|
||||
let total_entries = new_cache.len();
|
||||
cache.lookup = new_cache;
|
||||
cache.last_refresh = Instant::now();
|
||||
|
||||
log::debug!("Windows process lookup refresh complete: {} entries cached", total_entries);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_detection_method(&self) -> &str {
|
||||
"N/A"
|
||||
"windows-iphlpapi"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! ICMP (Internet Control Message Protocol) parsing
|
||||
//! Handles both ICMPv4 and ICMPv6
|
||||
|
||||
use crate::network::parser::ParsedPacket;
|
||||
use crate::network::protocol::TransportParams;
|
||||
use crate::network::types::{Protocol, ProtocolState};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Parse an ICMP (IPv4) packet
|
||||
pub fn parse(
|
||||
transport_data: &[u8],
|
||||
params: TransportParams,
|
||||
local_ips: &std::collections::HashSet<std::net::IpAddr>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if transport_data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icmp_type = transport_data[0];
|
||||
let icmp_code = if transport_data.len() > 1 {
|
||||
transport_data[1]
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Determine direction based on local IPs
|
||||
let is_outgoing = local_ips.contains(¶ms.src_ip);
|
||||
|
||||
let (local_addr, remote_addr) = if is_outgoing {
|
||||
(
|
||||
SocketAddr::new(params.src_ip, 0),
|
||||
SocketAddr::new(params.dst_ip, 0),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
SocketAddr::new(params.dst_ip, 0),
|
||||
SocketAddr::new(params.src_ip, 0),
|
||||
)
|
||||
};
|
||||
|
||||
Some(ParsedPacket {
|
||||
connection_key: format!("ICMP:{}-ICMP:{}", local_addr, remote_addr),
|
||||
protocol: Protocol::ICMP,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
tcp_flags: None,
|
||||
protocol_state: ProtocolState::Icmp {
|
||||
icmp_type,
|
||||
icmp_code,
|
||||
},
|
||||
is_outgoing,
|
||||
packet_len: params.packet_len,
|
||||
dpi_result: None,
|
||||
process_name: params.process_name,
|
||||
process_id: params.process_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ICMPv6 packet
|
||||
pub fn parse_v6(
|
||||
transport_data: &[u8],
|
||||
params: TransportParams,
|
||||
local_ips: &std::collections::HashSet<std::net::IpAddr>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if transport_data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icmp_type = transport_data[0];
|
||||
let icmp_code = if transport_data.len() > 1 {
|
||||
transport_data[1]
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Determine direction based on local IPs
|
||||
let is_outgoing = local_ips.contains(¶ms.src_ip);
|
||||
|
||||
let (local_addr, remote_addr) = if is_outgoing {
|
||||
(
|
||||
SocketAddr::new(params.src_ip, 0),
|
||||
SocketAddr::new(params.dst_ip, 0),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
SocketAddr::new(params.dst_ip, 0),
|
||||
SocketAddr::new(params.src_ip, 0),
|
||||
)
|
||||
};
|
||||
|
||||
Some(ParsedPacket {
|
||||
connection_key: format!("ICMP:{}-ICMP:{}", local_addr, remote_addr),
|
||||
protocol: Protocol::ICMP,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
tcp_flags: None,
|
||||
protocol_state: ProtocolState::Icmp {
|
||||
icmp_type,
|
||||
icmp_code,
|
||||
},
|
||||
is_outgoing,
|
||||
packet_len: params.packet_len,
|
||||
dpi_result: None, // No DPI for ICMPv6
|
||||
process_name: params.process_name,
|
||||
process_id: params.process_id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Transport layer protocol parsing
|
||||
//!
|
||||
//! This module handles transport layer protocols (Layer 4 of the OSI model):
|
||||
//! - TCP (Transmission Control Protocol)
|
||||
//! - UDP (User Datagram Protocol)
|
||||
//! - ICMP (Internet Control Message Protocol)
|
||||
//! - ICMPv6 (Internet Control Message Protocol for IPv6)
|
||||
|
||||
pub mod icmp;
|
||||
pub mod tcp;
|
||||
pub mod udp;
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Common parameters for transport layer parsing
|
||||
/// Note: Direction (is_outgoing) is determined by the protocol parsers
|
||||
/// based on local_ips, not passed as a parameter
|
||||
#[derive(Clone)]
|
||||
pub struct TransportParams {
|
||||
pub src_ip: IpAddr,
|
||||
pub dst_ip: IpAddr,
|
||||
pub packet_len: usize,
|
||||
pub process_name: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl TransportParams {
|
||||
pub fn new(
|
||||
src_ip: IpAddr,
|
||||
dst_ip: IpAddr,
|
||||
packet_len: usize,
|
||||
process_name: Option<String>,
|
||||
process_id: Option<u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
src_ip,
|
||||
dst_ip,
|
||||
packet_len,
|
||||
process_name,
|
||||
process_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! TCP (Transmission Control Protocol) parsing
|
||||
|
||||
use crate::network::dpi;
|
||||
use crate::network::parser::{ParsedPacket, ParserConfig};
|
||||
use crate::network::protocol::TransportParams;
|
||||
use crate::network::types::{Protocol, ProtocolState, TcpState};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
// Define TCP flags as bit masks
|
||||
const TCP_FIN: u8 = 0x01;
|
||||
const TCP_SYN: u8 = 0x02;
|
||||
const TCP_RST: u8 = 0x04;
|
||||
const TCP_PSH: u8 = 0x08;
|
||||
const TCP_ACK: u8 = 0x10;
|
||||
const TCP_URG: u8 = 0x20;
|
||||
|
||||
/// TCP flags from the TCP header
|
||||
/// All flags are public fields as they represent the actual TCP flags
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TcpFlags {
|
||||
pub fin: bool,
|
||||
pub syn: bool,
|
||||
pub rst: bool,
|
||||
pub psh: bool,
|
||||
pub ack: bool,
|
||||
pub urg: bool,
|
||||
}
|
||||
|
||||
/// Parse TCP flags from the flags byte
|
||||
pub fn parse_tcp_flags(flags: u8) -> TcpFlags {
|
||||
TcpFlags {
|
||||
fin: (flags & TCP_FIN) != 0,
|
||||
syn: (flags & TCP_SYN) != 0,
|
||||
rst: (flags & TCP_RST) != 0,
|
||||
psh: (flags & TCP_PSH) != 0,
|
||||
ack: (flags & TCP_ACK) != 0,
|
||||
urg: (flags & TCP_URG) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a TCP packet
|
||||
pub fn parse(
|
||||
transport_data: &[u8],
|
||||
params: TransportParams,
|
||||
config: &ParserConfig,
|
||||
local_ips: &std::collections::HashSet<std::net::IpAddr>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if transport_data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let src_port = u16::from_be_bytes([transport_data[0], transport_data[1]]);
|
||||
let dst_port = u16::from_be_bytes([transport_data[2], transport_data[3]]);
|
||||
let flags = transport_data[13];
|
||||
|
||||
let tcp_flags = parse_tcp_flags(flags);
|
||||
|
||||
// Log TCP flags for debugging
|
||||
log::trace!(
|
||||
"TCP flags: FIN={} SYN={} RST={} PSH={} ACK={} URG={}",
|
||||
tcp_flags.fin,
|
||||
tcp_flags.syn,
|
||||
tcp_flags.rst,
|
||||
tcp_flags.psh,
|
||||
tcp_flags.ack,
|
||||
tcp_flags.urg
|
||||
);
|
||||
|
||||
// Determine direction based on local IPs
|
||||
let is_outgoing = local_ips.contains(¶ms.src_ip);
|
||||
|
||||
let (local_addr, remote_addr) = if is_outgoing {
|
||||
(
|
||||
SocketAddr::new(params.src_ip, src_port),
|
||||
SocketAddr::new(params.dst_ip, dst_port),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
SocketAddr::new(params.dst_ip, dst_port),
|
||||
SocketAddr::new(params.src_ip, src_port),
|
||||
)
|
||||
};
|
||||
|
||||
// Perform DPI if enabled and there's payload
|
||||
let dpi_result = if config.enable_dpi {
|
||||
let tcp_header_len = ((transport_data[12] >> 4) as usize) * 4;
|
||||
if transport_data.len() > tcp_header_len {
|
||||
let payload = &transport_data[tcp_header_len..];
|
||||
dpi::analyze_tcp_packet(
|
||||
payload,
|
||||
local_addr.port(),
|
||||
remote_addr.port(),
|
||||
is_outgoing,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(ParsedPacket {
|
||||
connection_key: format!("TCP:{}-TCP:{}", local_addr, remote_addr),
|
||||
protocol: Protocol::TCP,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
tcp_flags: Some(tcp_flags),
|
||||
protocol_state: ProtocolState::Tcp(TcpState::Unknown),
|
||||
is_outgoing,
|
||||
packet_len: params.packet_len,
|
||||
dpi_result,
|
||||
process_name: params.process_name,
|
||||
process_id: params.process_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tcp_flags_parsing() {
|
||||
let flags = parse_tcp_flags(0x02); // SYN
|
||||
assert!(flags.syn);
|
||||
assert!(!flags.ack);
|
||||
assert!(!flags.fin);
|
||||
|
||||
let flags = parse_tcp_flags(0x12); // SYN + ACK
|
||||
assert!(flags.syn);
|
||||
assert!(flags.ack);
|
||||
|
||||
let flags = parse_tcp_flags(0x11); // FIN + ACK
|
||||
assert!(flags.fin);
|
||||
assert!(flags.ack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! UDP (User Datagram Protocol) parsing
|
||||
|
||||
use crate::network::dpi;
|
||||
use crate::network::parser::{ParsedPacket, ParserConfig};
|
||||
use crate::network::protocol::TransportParams;
|
||||
use crate::network::types::{Protocol, ProtocolState};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Parse a UDP packet
|
||||
pub fn parse(
|
||||
transport_data: &[u8],
|
||||
params: TransportParams,
|
||||
config: &ParserConfig,
|
||||
local_ips: &std::collections::HashSet<std::net::IpAddr>,
|
||||
) -> Option<ParsedPacket> {
|
||||
if transport_data.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let src_port = u16::from_be_bytes([transport_data[0], transport_data[1]]);
|
||||
let dst_port = u16::from_be_bytes([transport_data[2], transport_data[3]]);
|
||||
|
||||
// Determine direction based on local IPs
|
||||
let is_outgoing = local_ips.contains(¶ms.src_ip);
|
||||
|
||||
let (local_addr, remote_addr) = if is_outgoing {
|
||||
(
|
||||
SocketAddr::new(params.src_ip, src_port),
|
||||
SocketAddr::new(params.dst_ip, dst_port),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
SocketAddr::new(params.dst_ip, dst_port),
|
||||
SocketAddr::new(params.src_ip, src_port),
|
||||
)
|
||||
};
|
||||
|
||||
// Perform DPI if enabled and there's payload
|
||||
let dpi_result = if config.enable_dpi && transport_data.len() > 8 {
|
||||
let payload = &transport_data[8..];
|
||||
dpi::analyze_udp_packet(
|
||||
payload,
|
||||
local_addr.port(),
|
||||
remote_addr.port(),
|
||||
is_outgoing,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(ParsedPacket {
|
||||
connection_key: format!("UDP:{}-UDP:{}", local_addr, remote_addr),
|
||||
protocol: Protocol::UDP,
|
||||
local_addr,
|
||||
remote_addr,
|
||||
tcp_flags: None,
|
||||
protocol_state: ProtocolState::Udp,
|
||||
is_outgoing,
|
||||
packet_len: params.packet_len,
|
||||
dpi_result,
|
||||
process_name: params.process_name,
|
||||
process_id: params.process_id,
|
||||
})
|
||||
}
|
||||
@@ -735,9 +735,14 @@ fn draw_stats_panel(
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let process_detection_method = app.get_process_detection_method();
|
||||
let (link_layer_type, is_tunnel) = app.get_link_layer_info();
|
||||
|
||||
let conn_stats_text: Vec<Line> = vec![
|
||||
Line::from(format!("Interface: {}", interface_name)),
|
||||
Line::from(format!("Link Layer: {}{}",
|
||||
link_layer_type,
|
||||
if is_tunnel { " (Tunnel)" } else { "" }
|
||||
)),
|
||||
Line::from(format!("Process Detection: {}", process_detection_method)),
|
||||
Line::from(""),
|
||||
Line::from(format!("TCP Connections: {}", tcp_count)),
|
||||
|
||||
Reference in New Issue
Block a user