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
pnet crate 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
serde crate 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_derive provides 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 implementing serde's traits for complex data structures.
- toml: The
toml crate 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. The toml crate 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:
dialoguer is 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
console crate 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_static crate 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_json crate is an extension of the serde framework 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 that serde is known for.
- uuid: The
uuid crate 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. The uuid crate 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:
#[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,
}
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())
}
}
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" };
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);
println!("Executing command: {}", 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 {
let stderr_output = String::from_utf8_lossy(&output.stderr);
eprintln!("Failed to update rule in iptables. Error: {}", stderr_output);
}
}
fn remove_rule() {
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();
let selected_rule_id = rules[selection].id.clone();
(selected_rule_id, selection)
};
remove_iptables_rule(&selected_rule_id);
let mut rules = RULES.lock().unwrap();
rules.remove(selection);
println!("Rule removed.");
}
fn remove_iptables_rule(rule_id: &str) {
let iptables_command = format!(
"sudo iptables -L INPUT --line-numbers | grep -E '{}' | awk '{{print $1}}' | xargs -I {{}} sudo iptables -D INPUT {{}}",
rule_id
);
println!("Executing command: {}", iptables_command);
let output = Command::new("sh")
.arg("-c")
.arg(&iptables_command)
.output()
.expect("Failed to execute iptables command");
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();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a network interface to monitor")
.default(0)
.items(&interface_names)
.interact()
.unwrap();
let selected_interface = interface_names.get(selection).unwrap().clone();
println!("Starting firewall on interface: {}", selected_interface);
FIREWALL_RUNNING.store(true, Ordering::SeqCst);
thread::spawn(move || {
process_packets(selected_interface);
});
}
fn clean_logs() {
match File::create("firewall.log") {
Ok(_) => println!("Logs have been cleaned."),
Err(e) => eprintln!("Failed to clean logs: {}", e),
}
}
fn stop_firewall() {
FIREWALL_RUNNING.store(false, Ordering::SeqCst);
println!("Firewall stopped.");
}
fn check_firewall_status() {
if FIREWALL_RUNNING.load(Ordering::SeqCst) {
println!("Firewall status: Running");
} else {
println!("Firewall status: Stopped");
}
}
fn display_menu() {
let items = vec![
"View Rules", "Add Rule", "Remove Rule", "View Logs", "Clean Logs",
"Start Firewall", "Stop Firewall", "Check Firewall Status",
"Exit"
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose an action")
.default(0)
.items(&items)
.interact()
.unwrap();
match items[selection] {
"View Rules" => view_rules(),
"Add Rule" => add_rule(),
"Remove Rule" => remove_rule(),
"View Logs" => view_logs(),
"Clean Logs" => clean_logs(),
"Start Firewall" => start_firewall(),
"Stop Firewall" => stop_firewall(),
"Check Firewall Status" => check_firewall_status(),
"Exit" => std::process::exit(0),
_ => (),
}
}
fn view_rules() {
let rules = RULES.lock().unwrap();
for (index, rule) in rules.iter().enumerate() {
println!("{}: {:?}", index, rule);
}
}
fn add_rule() {
let protocol: String = Input::new()
.with_prompt("Enter protocol (e.g., 'tcp', 'udp')")
.interact_text()
.unwrap();
let source_ip: String = Input::new()
.with_prompt("Enter source IP (leave empty if not applicable)")
.default("".into())
.interact_text()
.unwrap();
let destination_ip: String = Input::new()
.with_prompt("Enter destination IP (leave empty if not applicable)")
.default("".into())
.interact_text()
.unwrap();
let source_port: u16 = Input::new()
.with_prompt("Enter source port (leave empty if not applicable)")
.default(0)
.interact_text()
.unwrap();
let destination_port: u16 = Input::new()
.with_prompt("Enter destination port (leave empty if not applicable)")
.default(0)
.interact_text()
.unwrap();
let actions = vec!["Allow", "Block"];
let action = Select::new()
.with_prompt("Choose action")
.default(0)
.items(&actions)
.interact()
.unwrap();
let new_rule = Rule {
id: Uuid::new_v4().to_string(),
protocol,
source_ip: if source_ip.is_empty() { None } else { Some(source_ip) },
destination_ip: if destination_ip.is_empty() { None } else { Some(destination_ip) },
source_port: if source_port == 0 { None } else { Some(source_port) },
destination_port: if destination_port == 0 { None } else { Some(destination_port) },
action: actions[action].to_lowercase(),
};
let mut rules = RULES.lock().unwrap();
rules.push(new_rule.clone());
save_rules(&rules).expect("Failed to save rules");
update_iptables(&new_rule.clone(), &new_rule.clone().action);
println!("Rule added.");
}
Step 7: Processing Packets
Our firewall should have the ability to process incoming packets and determine whether to accept or drop them based on the defined rules. Implement the process_packets function:
fn process_packets(interface_name: String) {
let interfaces = datalink::interfaces();
let interface = interfaces.into_iter()
.find(|iface| iface.name == interface_name)
.expect("Error finding interface");
let (_, mut rx) = match datalink::channel(&interface, Default::default()) {
Ok(Ethernet(_, rx)) => ((), rx),
Ok(_) => panic!("Unsupported channel type"),
Err(e) => panic!("Error creating datalink channel: {}", e),
};
while FIREWALL_RUNNING.load(Ordering::SeqCst) {
match rx.next() {
Ok(packet) => {
if let Some(tcp_packet) = TcpPacket::new(packet) {
process_tcp_packet(&tcp_packet);
}
},
Err(e) => eprintln!("An error occurred while reading packet: {}", e),
}
}
}
fn process_tcp_packet(tcp_packet: &TcpPacket) {
let rules = RULES.lock().unwrap();
for rule in rules.iter() {
if packet_matches_rule(tcp_packet, rule) {
println!("Rule matched");
match rule.action.as_str() {
"block" => {
log_packet_action(tcp_packet, "Blocked");
return;
},
_ => (),
}
}
}
log_packet_action(tcp_packet, "Allowed");
}
fn packet_matches_rule(packet: &TcpPacket, rule: &Rule) -> bool {
if let Some(ipv4_packet) = Ipv4Packet::new(packet.packet()) {
if rule.protocol.to_lowercase() != "tcp" {
return false;
}
if let Some(ref rule_src_ip) = rule.source_ip {
if ipv4_packet.get_source().to_string() != *rule_src_ip {
return false;
}
}
if let Some(ref rule_dst_ip) = rule.destination_ip {
if ipv4_packet.get_destination().to_string() != *rule_dst_ip {
return false;
}
}
if let Some(rule_src_port) = rule.source_port {
if packet.get_source() != rule_src_port {
return false;
}
}
if let Some(rule_dst_port) = rule.destination_port {
if packet.get_destination() != rule_dst_port {
return false;
}
}
return true;
}
false
}
Step 8: Logging
Logging is essential for monitoring firewall activities. We’ll implement a function to log accepted and dropped packets:
fn log_packet_action(packet: &TcpPacket, action: &str) {
let log_message = format!("{} packet: {:?}, action: {}\n", action, packet, action);
let mut file = OpenOptions::new()
.create(true)
.write(true)
.append(true)
.open("firewall.log")
.unwrap();
if let Err(e) = writeln!(file, "{}", log_message) {
eprintln!("Couldn't write to log file: {}", e);
}
}
Step 9: Visualization
To make it easy for users to visualize the logs, we’ll provide a function to display the logs:
fn view_logs() {
println!("Firewall Logs:");
match fs::read_to_string("firewall.log") {
Ok(contents) => println!("{}", contents),
Err(e) => println!("Error reading log file: {}", e),
}
}
Step 10: Putting It All Together
In your main.rs, create the main function to orchestrate the firewall operations. This function should call the main menu and allow users to interact with the firewall:
fn main() {
let loaded_rules = load_rules().unwrap_or_else(|e| {
eprintln!("Failed to load rules: {}", e);
Vec::new()
});
*RULES.lock().unwrap() = loaded_rules;
loop {
display_menu();
}
}
You can find the complete implementation over at my GitHub repository: https://github.com/luishsr/rustfirewall.