Implementing a Firewall in Rust
In this article, we will implement a basic yet fully functional Firewall in Rust! 🦀
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 implementingserde
'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. Thetoml
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 theserde
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 thatserde
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. Theuuid
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
:
// 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();
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");
// IMPORTANT: Update Linux IP Tables
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; // Dropping the packet
},
_ => (),
}
}
}
log_packet_action(tcp_packet, "Allowed");
// Further processing or forwarding the packet
}
fn packet_matches_rule(packet: &TcpPacket, rule: &Rule) -> bool {
// First, extract the IPv4 packet from the TCP packet
if let Some(ipv4_packet) = Ipv4Packet::new(packet.packet()) {
// Check protocol (assuming TCP, as we are working with TcpPacket)
if rule.protocol.to_lowercase() != "tcp" {
return false;
}
// Check source IP
if let Some(ref rule_src_ip) = rule.source_ip {
if ipv4_packet.get_source().to_string() != *rule_src_ip {
return false;
}
}
// Check destination IP
if let Some(ref rule_dst_ip) = rule.destination_ip {
if ipv4_packet.get_destination().to_string() != *rule_dst_ip {
return false;
}
}
// Check source port
if let Some(rule_src_port) = rule.source_port {
if packet.get_source() != rule_src_port {
return false;
}
}
// Check destination port
if let Some(rule_dst_port) = rule.destination_port {
if packet.get_destination() != rule_dst_port {
return false;
}
}
// If all checks pass, the packet matches the rule
return true;
}
false
}
Step 8: Logging
Logging is essential for monitoring firewall activities. We’ll implement a function to log accepted and dropped packets:
// Log packet action (either to console or to a file)
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.
🚀 Explore a Wealth of Resources in Software Development and More by Luis Soares
đź“š Learning Hub: Expand your knowledge in various tech domains, including Rust, Software Development, Cloud Computing, Cyber Security, Blockchain, and Linux, through my extensive resource collection:
- Hands-On Tutorials with GitHub Repos: Gain practical skills across different technologies with step-by-step tutorials, complemented by dedicated GitHub repositories. Access Tutorials
- In-Depth Guides & Articles: Deep dive into core concepts of Rust, Software Development, Cloud Computing, and more, with detailed guides and articles filled with practical examples. Read More
- E-Books Collection: Enhance your understanding of various tech fields with a series of free e-Books, including titles like “Mastering Rust Ownership” and “Application Security Guide” Download eBook
- Project Showcases: Discover a range of fully functional projects across different domains, such as an API Gateway, Blockchain Network, Cyber Security Tools, Cloud Services, and more. View Projects
- LinkedIn Newsletter: Stay ahead in the fast-evolving tech landscape with regular updates and insights on Rust, Software Development, and emerging technologies by subscribing to my newsletter on LinkedIn. Subscribe Here
đź”— Connect with Me:
- Medium: Read my articles on Medium and give claps if you find them helpful. It motivates me to keep writing and sharing Rust content. Follow on Medium
- Personal Blog: Discover more on my personal blog, a hub for all my Rust-related content. Visit Blog
- LinkedIn: Join my professional network for more insightful discussions and updates. Connect on LinkedIn
- Twitter: Follow me on Twitter for quick updates and thoughts on Rust programming. Follow on Twitter
Conclusion
Congratulations! You’ve now built a simple firewall application in Rust. While this guide provides a high-level overview of each function and step, you can dive deeper into the implementation details for each function based on your specific requirements.
Remember that this is a basic firewall, and real-world firewall implementations can be much more complex and sophisticated. You can further enhance this firewall by adding more rule criteria, optimizing the iptables interactions, and improving the user interface.
Building a firewall from scratch is a challenging but rewarding project that allows you to gain a deeper understanding of network security and Rust programming.
Wanna talk? Leave a comment or drop me a message!
All the best,
Luis Soares
luis.soares@linux.com
Senior Software Engineer | Cloud Engineer | SRE | Tech Lead | Rust | Golang | Java | ML AI & Statistics | Web3 & Blockchain