All articles
Implementing a Firewall in Rust
Strings & Text

Implementing a Firewall in Rust

In this article, we will implement a basic yet fully functional Firewall in Rust! 🦀

By Luis SoaresJanuary 15, 2024Original on Medium

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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:

// 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();

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises
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:

```rust
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.

Practice what you learned

Reinforce this article with hands-on coding exercises and AI-powered feedback.

View all exercises

Want to practice Rust hands-on?

Go beyond reading — solve interactive exercises with AI-powered code review on Rust Lab.