Implementing a Network Traffic Analyzer in Rust

In this article, we’ll delve into the intricacies of working with network traffic using Rust. We’ll explore capturing packets, parsing…

Implementing a Network Traffic Analyzer in Rust

In this article, we’ll delve into the intricacies of working with network traffic using Rust. We’ll explore capturing packets, parsing them, setting alerts, and even some flow analysis. By the end, you’ll have a foundational understanding of networking in Rust and a stepping stone to craft your own network monitoring solutions.

Let’s get started!

A bit about the Crates we will use

  1. pcap: The pcap crate offers bindings to the libpcap library, granting the ability to capture live network packets. Through it, users can set filters to capture specific traffic, read from saved pcap files, and directly interact with live packet data.
  2. pnet: The pnet crate is a comprehensive library for packet parsing and crafting in Rust. It provides decoding and encoding for a variety of network protocols, including Ethernet, IP, TCP, and UDP. Additionally, it offers utilities for creating, sending custom packets, and working with network devices.
  3. notify-rust: With notify-rust, users can send desktop notifications from their Rust applications. This crate supports platform-independent notifications, different levels of urgency, and customizable notification timeouts.
  4. serde: serde is a powerful serialization and deserialization framework in Rust. It supports a range of data formats, such as JSON and TOML. The crate is known for its efficiency, customization capabilities, and the provided macros that ease defining serialization behavior for custom structures.
  5. toml: Designed to work seamlessly with serde, the toml crate specializes in parsing and generating TOML-formatted strings and files. Whether deserializing TOML into Rust structures or encoding Rust data into TOML, this crate makes TOML handling straightforward.

Setting Up

First, add pcap to your Cargo.toml:

[dependencies] 
pcap = "0.8"

Install libpcap for your system if you haven’t already. E.g., for Ubuntu:

$ sudo apt-get install libpcap-dev

Working Example

Let’s write a simple program that captures packets on a given network interface and prints basic information about them:

// Import necessary dependencies 
extern crate pcap; 
 
fn main() { 
    // Choose the network interface for capturing. E.g., "eth0" 
    let interface = "eth0"; 
     
    // Open the capture for the given interface 
    let mut cap = pcap::Capture::from_device(interface).unwrap() 
        .promisc(true)  // Set the capture mode to promiscuous 
        .snaplen(5000)  // Set the maximum bytes to capture per packet 
        .open().unwrap(); 
    // Start capturing packets 
    while let Ok(packet) = cap.next() { 
        println!("Received packet with length: {}", packet.header.len); 
        // Here, you can add more processing or filtering logic if needed 
    } 
}
  • We first set up a capture for a specific device (e.g., eth0 which is a common interface name for wired connections on Linux systems). The specific name can vary depending on your system configuration.
  • The promisc(true) call sets the interface to promiscuous mode, which allows it to capture all packets, not just those destined for the machine.
  • The snaplen(5000) call sets the maximum byte length of packets that the capture will obtain. You can adjust this as needed.
  • In the loop, cap.next() captures the next packet, returning it if available. We then print the length of the packet, but you can extend this logic to parse the packet, analyze its content, and more.

Enhancing Your Monitor with Packet Parsing

To further extend our monitoring tool, we can dive deeper into the packet content to identify patterns, protocols, or specific packet information. For this, the pnet library in Rust provides an extensive framework for packet crafting and parsing.

Setting Up with pnet

Firstly, add pnet to your Cargo.toml:

[dependencies] 
pnet = "0.27"

Extending the Example

extern crate pcap; 
extern crate pnet; 
 
use pnet::packet::ethernet::EthernetPacket; 
use pnet::packet::ip::IpNextHeaderProtocols; 
use pnet::packet::tcp::TcpPacket; 
use pnet::packet::udp::UdpPacket; 
use pnet::packet::Packet; 
fn main() { 
    let interface = "eth0"; 
    let mut cap = pcap::Capture::from_device(interface).unwrap() 
        .promisc(true) 
        .snaplen(5000) 
        .open().unwrap(); 
    while let Ok(packet) = cap.next() { 
        // Parse the Ethernet frame from the captured packet data 
        if let Some(ethernet_packet) = EthernetPacket::new(&packet.data) { 
            match ethernet_packet.get_ethertype() { 
                IpNextHeaderProtocols::Tcp => { 
                    // Handle TCP packets 
                    let tcp_packet = TcpPacket::new(ethernet_packet.payload()); 
                    if let Some(tcp_packet) = tcp_packet { 
                        println!( 
                            "TCP Packet: {}:{} > {}:{}; Seq: {}, Ack: {}", 
                            ethernet_packet.get_source(), 
                            tcp_packet.get_source(), 
                            ethernet_packet.get_destination(), 
                            tcp_packet.get_destination(), 
                            tcp_packet.get_sequence(), 
                            tcp_packet.get_acknowledgment() 
                        ); 
                    } 
                }, 
                IpNextHeaderProtocols::Udp => { 
                    // Handle UDP packets 
                    let udp_packet = UdpPacket::new(ethernet_packet.payload()); 
                    if let Some(udp_packet) = udp_packet { 
                        println!( 
                            "UDP Packet: {}:{} > {}:{}; Len: {}", 
                            ethernet_packet.get_source(), 
                            udp_packet.get_source(), 
                            ethernet_packet.get_destination(), 
                            udp_packet.get_destination(), 
                            udp_packet.get_length() 
                        ); 
                    } 
                }, 
                _ => {} 
            } 
        } 
    } 
}

Download Now!


  • We introduced the pnet library to parse the captured packets.
  • The EthernetPacket::new method creates an Ethernet packet structure from raw bytes.
  • Depending on the ethertype, we check if it’s a TCP or UDP packet and handle them accordingly.
  • For each TCP packet, we print out source and destination IPs, ports, and sequence and acknowledgment numbers.
  • For UDP packets, we display source and destination IPs, ports, and the packet length.

Implementing Automatic Alerts

Implementing alerts in our network monitoring tool allows us to be promptly notified when specific network conditions are met. We can use various mechanisms to issue alerts, such as console messages, system notifications, or even integrating with external messaging platforms like Slack or email.

In this example, we’ll implement a basic alert mechanism using console messages and system notifications (using the notify-rust crate). If you wish to expand the system to use other alerting mechanisms, you can further build upon this foundation.

Setting Up

First, add notify-rust to your Cargo.toml:

[dependencies] 
notify-rust = "4.0"

Let’s say we want to trigger an alert when a specific IP address sends traffic on a particular port. Here’s how you can achieve that:

extern crate pcap; 
extern crate pnet; 
extern crate notify_rust; 
 
use pnet::packet::ethernet::EthernetPacket; 
use pnet::packet::ip::IpNextHeaderProtocols; 
use pnet::packet::tcp::TcpPacket; 
use pnet::packet::Packet; 
use notify_rust::Notification; 
const ALERT_IP: &str = "192.168.1.10"; 
const ALERT_PORT: u16 = 80; 
fn main() { 
    let interface = "eth0"; 
     
    let mut cap = pcap::Capture::from_device(interface).unwrap() 
        .promisc(true) 
        .snaplen(5000) 
        .open().unwrap(); 
    while let Ok(packet) = cap.next() { 
        if let Some(ethernet_packet) = EthernetPacket::new(&packet.data) { 
            match ethernet_packet.get_ethertype() { 
                IpNextHeaderProtocols::Tcp => { 
                    let tcp_packet = TcpPacket::new(ethernet_packet.payload()); 
                    if let Some(tcp_packet) = tcp_packet { 
                        if tcp_packet.get_destination() == ALERT_PORT && ethernet_packet.get_source().to_string() == ALERT_IP { 
                            send_alert(ALERT_IP, ALERT_PORT); 
                        } 
                    } 
                }, 
                _ => {} 
            } 
        } 
    } 
} 
fn send_alert(ip: &str, port: u16) { 
    println!("ALERT! Traffic from IP {} on port {}", ip, port); 
    Notification::new() 
        .summary("Network Monitoring Alert") 
        .body(&format!("Traffic from IP {} on port {}", ip, port)) 
        .show().unwrap(); 
}
  • We’ve defined constants ALERT_IP and ALERT_PORT to specify which IP and port should trigger the alert.
  • Inside our packet processing loop, we check if the current packet’s source IP and destination port match our alert criteria.
  • If the criteria match, we call the send_alert function.
  • send_alert function prints a message to the console and also sends a system notification using the notify-rust library.

Using Dynamic Configuration

To have a single configuration file that governs both alerts and the application mode (detailed or summary), we’ll define a unified structure in our config.toml file and then adjust our Rust application to read from it.

Configuration File Structure

Here’s a sample config.toml:

[general] 
mode = "detailed" # or "summary" 
 
[alert] 
ip = "192.168.1.10" 
port = 80

This configuration file defines two sections:

  • general: Holds general configuration items, like the mode of operation.
  • alert: Defines the criteria that should trigger an alert.

Implementing the Configuration

Here’s the adjusted Rust code:

// ... imports ... 
 
#[derive(Deserialize)] 
struct Config { 
    general: GeneralConfig, 
    alert: AlertConfig, 
} 
#[derive(Deserialize)] 
struct GeneralConfig { 
    mode: String, 
} 
#[derive(Deserialize)] 
struct AlertConfig { 
    ip: String, 
    port: u16, 
} 
fn main() { 
    // Load and parse the config 
    let config_content = fs::read_to_string("config.toml").unwrap(); 
    let config: Config = toml::from_str(&config_content).unwrap(); 
    // ... rest of the main ... 
    // Inside packet processing loop: 
    if config.general.mode == "detailed" { 
        // Detailed logging logic 
    } else if config.general.mode == "summary" { 
        // Summary logging logic 
    } 
    // For alerts: 
    if tcp_packet.get_destination() == config.alert.port && ethernet_packet.get_source().to_string() == config.alert.ip { 
        send_alert(&config.alert.ip, config.alert.port); 
    } 
} 
// ... rest of the code ...

In this version:

  1. We load the configuration from config.toml at the beginning of the main function.
  2. We define multiple structures (Config, GeneralConfig, and AlertConfig) to represent the sections and items in our TOML file.
  3. In the packet processing loop, we check the mode from the configuration to decide the logging behavior.
  4. We also use the alert configuration to check against packet attributes and determine if an alert should be triggered.

Implementing a Summary mode

By default, our application will to the console every packet received. It’s very helpful for a close look at the traffic, but sometimes we might be interested in the bigger picture. Let’s them implement a summary mode in the app.

For simplicity, we’ll use a text-based chart display, although more advanced charting solutions can be integrated.

Let’s use the terminal crate to help with console rendering. This allows us to refresh the display smoothly.

Setting Up:

First, add terminal to your Cargo.toml:

[dependencies] 
terminal = "0.4"

Implementation:

extern crate pcap; 
extern crate pnet; 
extern crate terminal; 
 
use std::collections::HashMap; 
use std::thread::sleep; 
use std::time::Duration; 
use pnet::packet::ethernet::EthernetPacket; 
use terminal::{Clear,ClearType}; 
struct IpStats { 
    sent: u64, 
    received: u64, 
} 
fn main() { 
    let interface = "eth0"; 
     
    let mut cap = pcap::Capture::from_device(interface).unwrap() 
        .promisc(true) 
        .snaplen(5000) 
        .open().unwrap(); 
    let mut ip_map: HashMap<String, IpStats> = HashMap::new(); 
    loop { 
        for _ in 0..10 {  // Collect data from 10 packets at a time 
            if let Ok(packet) = cap.next() { 
                if let Some(ethernet_packet) = EthernetPacket::new(&packet.data) { 
                    let src_ip = ethernet_packet.get_source().to_string(); 
                    let dst_ip = ethernet_packet.get_destination().to_string(); 
                    update_ip_stats(&mut ip_map, src_ip, true, packet.header.len); 
                    update_ip_stats(&mut ip_map, dst_ip, false, packet.header.len); 
                } 
            } 
        } 
        display_summary(&ip_map); 
        sleep(Duration::from_millis(500)); 
    } 
} 
fn update_ip_stats(ip_map: &mut HashMap<String, IpStats>, ip: String, is_source: bool, packet_size: u32) { 
    let stats = ip_map.entry(ip).or_insert(IpStats { sent: 0, received: 0 }); 
    if is_source { 
        stats.sent += packet_size as u64; 
    } else { 
        stats.received += packet_size as u64; 
    } 
} 
fn display_summary(ip_map: &HashMap<String, IpStats>) { 
    terminal::clear(ClearType::All); 
    println!("IP Address        | Packets Sent | Packets Received"); 
    println!("------------------+--------------+-----------------"); 
    for (ip, stats) in ip_map { 
        println!("{:<18} | {:<12} | {}", ip, stats.sent, stats.received); 
    } 
}
  1. We maintain a HashMap (ip_map) where the key is an IP address and the value is its associated IpStats.
  2. For every packet processed, we update the sent or received packet count based on whether the IP was a source or destination.
  3. In our main loop, we gather data from 10 packets and then update the display.
  4. The display_summary function prints a table of IP addresses and their associated sent and received packet counts.
  5. Using terminal::clear(ClearType::All), we clear the console each time before rendering the new summary.

To spawn the summary display as a separate thread, we can use Rust’s standard library threading capabilities. This way, the packet capturing loop and the summary display loop run concurrently without blocking each other.

Let’s integrate this threading mechanism into our code:

extern crate pcap; 
extern crate pnet; 
extern crate terminal; 
 
use std::collections::HashMap; 
use std::thread; 
use std::sync::{Arc, Mutex}; 
use std::time::Duration; 
use pnet::packet::ethernet::EthernetPacket; 
use terminal::{Clear, ClearType}; 
struct IpStats { 
    sent: u64, 
    received: u64, 
} 
fn main() { 
    let interface = "eth0"; 
     
    let mut cap = pcap::Capture::from_device(interface).unwrap() 
        .promisc(true) 
        .snaplen(5000) 
        .open().unwrap(); 
    let shared_ip_map = Arc::new(Mutex::new(HashMap::<String, IpStats>::new())); 
    let ip_map_for_thread = Arc::clone(&shared_ip_map); 
    // Spawn a thread to handle the display 
    thread::spawn(move || { 
        loop { 
            display_summary(&ip_map_for_thread.lock().unwrap()); 
            thread::sleep(Duration::from_millis(500)); 
        } 
    }); 
    loop { 
        if let Ok(packet) = cap.next() { 
            if let Some(ethernet_packet) = EthernetPacket::new(&packet.data) { 
                let src_ip = ethernet_packet.get_source().to_string(); 
                let dst_ip = ethernet_packet.get_destination().to_string(); 
                update_ip_stats(&mut shared_ip_map.lock().unwrap(), src_ip, true, packet.header.len); 
                update_ip_stats(&mut shared_ip_map.lock().unwrap(), dst_ip, false, packet.header.len); 
            } 
        } 
    } 
} 
fn update_ip_stats(ip_map: &mut HashMap<String, IpStats>, ip: String, is_source: bool, packet_size: u32) { 
    let stats = ip_map.entry(ip).or_insert(IpStats { sent: 0, received: 0 }); 
    if is_source { 
        stats.sent += packet_size as u64; 
    } else { 
        stats.received += packet_size as u64; 
    } 
} 
fn display_summary(ip_map: &HashMap<String, IpStats>) { 
    terminal::clear(ClearType::All); 
    println!("IP Address        | Packets Sent | Packets Received"); 
    println!("------------------+--------------+-----------------"); 
    for (ip, stats) in ip_map { 
        println!("{:<18} | {:<12} | {}", ip, stats.sent, stats.received); 
    } 
}

Note: You might need root access to run the application properly. If permission-denied errors occur, grant access by doing:

sudo setcap cap_net_raw=eip ./target/debug/{your_project_name}

Putting it all together

Let’s have a recap of our journey:

1. Network Monitoring in Rust:

We began by diving into the world of network monitoring using Rust. We explored how to capture packets using the pcap library, ensuring that we could listen to traffic and interpret it.

2. Packet Parsing with pnet:

To make sense of the packets, we incorporated the pnet library. This allowed us to dissect the packet data, identifying details such as source and destination IP addresses, ports, and more.

3. Applying Filters:

A crucial addition was the ability to set filters, letting our tool focus only on specific types of traffic. This made the tool more flexible, ensuring that we could narrow our observations to just the traffic patterns of interest.

4. Alerting Mechanism:

We then introduced alerting capabilities. Whenever traffic from a specific IP address was detected on a certain port, our tool could trigger an alert. This was done using both console messages and system notifications via the notify-rust crate.

5. Flow Analysis:

To provide a more holistic view of network traffic, we ventured into flow analysis. By analyzing sequences of packets, our tool could infer broader patterns and behaviors in the network, grouping traffic based on source/destination pairs and tracking stats.

6. Live-updating Summary Mode:

To enhance user experience, we implemented a live-updating summary mode. Every 500 milliseconds, a refreshed chart displayed the total packets sent and received by each IP address. For this live update, we employed the terminal crate (and later transitioned to the crossterm crate for better terminal interactions).

7. Multi-threading:

Understanding the importance of real-time performance, we made the summary display independent of the packet-capturing process. By spawning it as a separate thread, we ensured that the packet capture loop wasn’t disrupted by display updates.


Download Now!


🌟 Developing a Fully Functional API Gateway in Rust — Discover how to set up a robust and scalable gateway that stands as the frontline for your microservices.

🌟 Implementing a Network Traffic Analyzer — Ever wondered about the data packets zooming through your network? Unravel their mysteries with this deep dive into network analysis.

🌟 Building an Application Container in Rust — Join us in creating a lightweight, performant, and secure container from scratch! Docker’s got nothing on this. 😉

🌟 Crafting a Secure Server-to-Server Handshake with Rust & OpenSSL — 
If you’ve been itching to give your servers a unique secret handshake that only they understand, you’ve come to the right place. Today, we’re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.

🌟Building a Function-as-a-Service (FaaS) in Rust: If you’ve been exploring cloud computing, you’ve likely come across FaaS platforms like AWS Lambda or Google Cloud Functions. In this article, we’ll be creating our own simple FaaS platform using Rust.

🌟 Rusting Up Your Own Self-Signed Certificate Generator — Let’s walk through the process of crafting your very own self-signed certificate generator, all using the versatile Rust programming language and the rust-openssl crate.

If you’ve been itching to give your servers a unique secret handshake that only they understand, you’ve come to the right place. Today, we’re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.

🌟 Rusting Up Your Own Self-Signed Certificate Generator — Let’s walk through the process of crafting your very own self-signed certificate generator, all using the versatile Rust programming language and the rust-openssl crate.

Dive into the code, play around with it, and perhaps even contribute!

Stay tuned, and we’ll catch you in the next part of our Rust-powered journey.

Thanks for sticking around, and happy coding!

There we have it, folks!

You can check out the full (basic version) implementation in my GitHub repository at https://github.com/luishsr/rust-network-monitor.

Dive into the code, play around with it, and perhaps even contribute!

Stay tuned, and we’ll catch you in the next part of our Rust-powered journey.

Thanks for sticking around, and happy coding!


Read more articles about Rust in my Rust Programming Library!


Visit my Blog for more articles, news, and software engineering stuff!

Follow me on Medium, LinkedIn, and Twitter.

Leave a comment, and drop me a message!

All the best,

Luis Soares

CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more