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
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
}
}
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()
);
}
},
_ => {}
}
}
}
}
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();
}
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:
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:
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.
Recommended by LinkedIn
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);
}
}
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.
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!
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
I had a go at this. It was fine with the first example, but when introducing pnet and attempting to determine tcp or udp, I hit a bit of a problem. The match get_ethertype call gives you an EtherType, which can be things like IPv4 or IPv6 or AppleTalk or suchlike (layer 3 protocols). The code seems to be trying to match it with layer 4 protocols (eg IpNextHeaderProtocols::Tcp). And the rust compiler barfs on this, saying it's a type mismatch. It was expecting to get an EtherType, and got an IpNextHeaderProtocol instead. I'm not entirely sure what needs to be done to get it working. I'm guessing we can either put in a check for ethertypes ipv4 or ipv6 (and ignore anything else), and then go and look for the transport mechanism in the packet.
looks really nice, going to check it out with my SMTP pet project 🤓 Thanks