In this article, we will implement a basic yet fully functional Firewall in Rust! 🦀
Our journey begins at the very heart of network communication — packet capture. We explore how Rust’s performance-oriented features enable efficient monitoring of network traffic, ensuring that no malicious data slips through unnoticed.
Next, we shift our focus to the backbone of any firewall: its rule set. We dissect how Rust can be leveraged to define clear and concise rules for filtering or blocking IP addresses and ports. This section also provides insights into the dynamic application of these rules in a live network environment.
Logging is pivotal in tracking the activities of a firewall. We discuss how Rust’s powerful concurrency model and error handling capabilities can be utilized to create a robust logging system that not only records events but also aids in the analysis and troubleshooting of network security issues.
Lastly, we bridge the gap between Rust and the Linux environment, particularly focusing on IP tables. This part covers the integration of a Rust-based firewall with the Linux kernel’s native filtering and routing features, ensuring a seamless operation within the existing infrastructure.
Let’s get started! 🦀
The Crates we will use
- pnet: The
pnetcrate is integral to Rust's network programming capabilities. It offers extensive functionalities for low-level network operations, enabling the crafting, sending, and receiving of network packets. It's particularly suited for developing network utilities and applications that require direct manipulation of network packets, including both the transport (like TCP/UDP) and network (such as IP) layers. - serde: The
serdecrate is a powerful serialization and deserialization framework in Rust. It's designed for efficiently transforming data structures into a format that can be easily stored or transmitted and then reconstructed later. This crate is widely used for tasks like parsing JSON, XML, or binary data into Rust data structures, and vice versa. Its high performance and flexibility make it a cornerstone for any application involving data exchange or storage. - serde_derive: Working in tandem with
serde,serde_deriveprovides the necessary tools for automatically generating code to serialize and deserialize data structures. It allows developers to easily add serialization capabilities to custom data types with minimal boilerplate, using Rust's derive attribute. This greatly simplifies the process of implementingserde's traits for complex data structures. - toml: The
tomlcrate is focused on handling TOML (Tom's Obvious, Minimal Language) formatted files in Rust. TOML is a widely-used format for configuration files, known for its readability and simplicity. Thetomlcrate allows for easy parsing and generation of TOML files, making it an essential tool for applications that need to read or write configuration data. - dialoguer:
dialogueris a crate designed for creating interactive command line applications in Rust. It provides a variety of tools to prompt user input in a user-friendly manner. Features include password inputs, confirmation prompts, selection menus, and more. This crate is particularly useful for building CLI applications that require dynamic user interaction. - console: The
consolecrate offers a set of utilities for dealing with the console and terminal output. It includes features for text formatting, color output, progress bars, and other terminal-related functionalities. This crate is valuable for enhancing the user interface of command line applications, making them more interactive and visually appealing. - lazy_static: The
lazy_staticcrate provides a way to define static variables in Rust that are initialized lazily. This means the variables are not created until they are first accessed, which can be useful for expensive or resource-intensive initialization tasks. It's a common tool in Rust programming for managing global, mutable state in a thread-safe manner. - serde_json: The
serde_jsoncrate is an extension of theserdeframework specifically geared towards JSON data handling. It allows for seamless serialization and deserialization of JSON data to and from Rust data structures. This is particularly useful in web development, API interactions, and scenarios where JSON is the preferred data exchange format. The crate makes it straightforward to parse JSON strings or files into Rust types and to serialize Rust structures back into JSON, all while maintaining the efficiency and type safety thatserdeis known for. - uuid: The
uuidcrate is dedicated to the creation and manipulation of universally unique identifiers (UUIDs) in Rust. UUIDs are 128-bit numbers used for uniquely identifying information in computer systems. This crate provides the tools to generate UUIDs in various formats, parse them from strings, and serialize/deserialize them. It's particularly useful in applications where unique identifiers are required, such as in database key generation, session management, or file naming. Theuuidcrate ensures that these identifiers are generated following the standard UUID formats, thereby guaranteeing uniqueness and consistency across different systems and applications.
Step 1: Setting Up the Project
Let’s start by setting up our Rust project. Create a new directory for your project and initialize it as a Rust project using Cargo:
mkdir rust_firewall
cd rust_firewall
cargo init
Step 2: Setting Up Dependencies
In this step, we’ll set up the necessary dependencies for our Rust firewall project. These dependencies include libraries for networking, serialization, user interaction, and more. To add dependencies to your project, follow these steps:
Open your project’s Cargo.toml file. This file manages your project's dependencies. Add the required dependencies to the [dependencies] section of your Cargo.toml file:
[dependencies]
pnet = "0.34.0"
serde = "1.0"
serde_derive = "1.0"
toml = "0.5"
dialoguer = "0.8.0"
console = "0.14.1"
lazy_static = "1.4.0"
serde_json = "1.0"
uuid = { version = "0.8.2", features = ["v4"] }
Step 3: Defining the Rule Structure
In our firewall, we need to define the structure of a rule that represents the criteria for accepting or dropping packets. Create a Rule struct in your main.rs:
// Define fields for the rule criteria
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Rule {
id: String,
protocol: String,
source_ip: Option<String>,
destination_ip: Option<String>,
source_port: Option<u16>,
destination_port: Option<u16>,
action: String, // "allow" or "block"
}
Step 4: Implementing the Rule Management
Next, let’s implement functions to manage rules. These functions will allow us to add, remove, and list rules:
lazy_static! {
static ref RULES: Arc<Mutex<Vec<Rule>>> = Arc::new(Mutex::new(Vec::new()));
}
lazy_static! {
static ref FIREWALL_RUNNING: AtomicBool = AtomicBool::new(false);
}
const RULES_FILE: &str = "firewall_rules.json";
fn save_rules(rules: &Vec<Rule>) -> io::Result<()> {
let json = serde_json::to_string(rules)?;
fs::write(RULES_FILE, json)?;
Ok(())
}
fn load_rules() -> io::Result<Vec<Rule>> {
let path = Path::new(RULES_FILE);
if path.exists() {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let rules = serde_json::from_str(&contents)?;
Ok(rules)
} else {
Ok(Vec::new()) // Return an empty vector if the file does not exist
}
}
Step 5: Updating iptables
Our firewall will rely on iptables to manage network traffic. We'll need functions to update iptables based on our rules:
fn update_iptables(rule: &Rule, action: &str) {
let protocol = &rule.protocol;
let source_ip = rule.source_ip.as_ref().map_or("".to_string(), |ip| format!("--source {}", ip));
let destination_ip = rule.destination_ip.as_ref().map_or("".to_string(), |ip| format!("--destination {}", ip));
let source_port = rule.source_port.map_or("".to_string(), |port| format!("--sport {}", port));
let destination_port = rule.destination_port.map_or("".to_string(), |port| format!("--dport {}", port));
let target = if action == "block" { "DROP" } else { "ACCEPT" };
// Construct the iptables command as a string
let iptables_command = format!("sudo iptables -A INPUT -p {} {} {} {} {} -j {} -m comment --comment {}",
protocol, source_ip, destination_ip, source_port, destination_port, target, &rule.id);
// Print the executed command for debugging purposes
println!("Executing command: {}", iptables_command);
// Execute the iptables command
let output = Command::new("sh")
.arg("-c")
.arg(&iptables_command)
.stderr(Stdio::piped())
.output()
.expect("Failed to execute iptables command");
if output.status.success() {
println!("Rule updated in iptables.");
} else {
// Print the raw error message from stderr
let stderr_output = String::from_utf8_lossy(&output.stderr);
eprintln!("Failed to update rule in iptables. Error: {}", stderr_output);
}
}
fn remove_rule() {
// Get the rule descriptions and selection
let (selected_rule_id, selection) = {
let rules = RULES.lock().unwrap();
let rule_descriptions: Vec<String> = rules.iter().map(|rule| format!("{:?}", rule)).collect();
if rule_descriptions.is_empty() {
println!("No rules to remove.");
return;
}
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a rule to remove")
.default(0)
.items(&rule_descriptions)
.interact()
.unwrap();
// Clone the ID to use outside the lock scope
let selected_rule_id = rules[selection].id.clone();
(selected_rule_id, selection)
};
// Now we can remove the iptables rule outside the lock scope
remove_iptables_rule(&selected_rule_id);
// Now remove the rule from the application
let mut rules = RULES.lock().unwrap();
rules.remove(selection);
println!("Rule removed.");
}
fn remove_iptables_rule(rule_id: &str) {
// Construct the iptables command as a string
let iptables_command = format!(
"sudo iptables -L INPUT --line-numbers | grep -E '{}' | awk '{{print $1}}' | xargs -I {{}} sudo iptables -D INPUT {{}}",
rule_id
);
// Print the executed command for debugging purposes
println!("Executing command: {}", iptables_command);
// Execute the iptables command
let output = Command::new("sh")
.arg("-c")
.arg(&iptables_command)
.output()
.expect("Failed to execute iptables command");
// Print the output of the executed command for debugging
println!("Command output: {:?}", output);
if output.status.success() {
println!("Successfully removed iptables rule for rule ID: {}", rule_id);
} else {
eprintln!("Error removing iptables rule for rule ID: {}", rule_id);
}
}
Step 6: Interacting with the User
To make our firewall user-friendly, we’ll create a CLI (Command-Line Interface) for users to interact with. We’ll create a menu system to add, remove, and list rules. We’ll also include an option to start or stop the firewall:
fn start_firewall() {
let interfaces = datalink::interfaces();
let interface_names: Vec<String> = interfaces.iter()
.map(|iface| iface.name.clone())
.collect();
if interface_names.is_empty() {
println!("No available network interfaces found.");
return;
}
// Clean logs when starting the firewall
clean_logs();


