Build a Network Firewall with Aya eBPF

Write a kernel-level XDP firewall in Rust using eBPF to filter packets at wire speed.

Introduction

30 Second Summary

Every time you load a webpage, packets of data travel through invisible gatekeepers that decide in microseconds whether to let them pass or drop them. The fastest firewalls in the world make this decision at the very edge of the operating system, intercepting threats before they even touch the network stack.

In this project, you will build a kernel-level network firewall using eBPF and Rust. Your firewall attaches directly to the network interface driver, parses raw packets, drops traffic from blocked IPs, and streams live traffic data to your terminal in real time.

What You'll Build

An XDP-based packet filter and traffic analyzer written in Rust using the Aya eBPF framework, capable of dropping packets at wire speed and monitoring network traffic in real time.

By the end of this project, you'll have:

  • An eBPF kernel-space program that parses Ethernet, IPv4, TCP, and UDP headers and enforces an IP blocklist before packets reach the network stack.
  • A user-space controller that loads the eBPF program, manages the blocklist dynamically via BPF HashMaps, and consumes traffic events through ring buffers.
  • A real-time traffic monitor that displays source IPs, ports, and protocols in your terminal as packets flow through your network interface.
  • Secret Mission: Add port-based filtering so you can block specific ports alongside IP-based blocking.

Are there any prerequisites?

This project requires a Linux environment with kernel 5.8 or higher and BTF support enabled. You should be comfortable with basic Rust (Cargo, structs, pattern matching) and basic networking concepts (IP addresses, ports, protocols).

Set Up Your eBPF Development Environment

eBPF programs run inside the Linux kernel, which means they need a specialized compilation toolchain. Rust's nightly compiler provides the no_std environment needed for kernel-space code, and a special linker targets the eBPF instruction set.

In this step, you will install the Rust toolchains, the eBPF-specific build tools, and confirm your Linux kernel is ready for eBPF development.

In this step, get ready to:
  • Install Rust stable and nightly toolchains with the rust-src component.
  • Install bpf-linker and cargo-generate for eBPF compilation and project scaffolding.
  • Verify your kernel supports BTF and install bpftool for eBPF inspection.
Install Rust toolchains

eBPF kernel programs compile to a special bytecode target that requires Rust's nightly compiler. The rust-src component provides the source code needed to cross-compile for the eBPF target.

  • Install the stable Rust toolchain by running this command:
rustup install stable
  • Install the nightly toolchain with the rust-src component by running:
rustup toolchain install nightly --component rust-src

Why do you need both toolchains?

The stable toolchain compiles your user-space application (the controller that loads and manages the firewall). The nightly toolchain compiles the eBPF kernel program, which requires unstable Rust features like no_std and no_main.

The rust-src component is essential because the eBPF target needs Rust's core library source to cross-compile for the BPF architecture.

  • Verify both toolchains are installed by running:
rustup toolchain list

✔️ I see both stable and nightly listed

You should see output similar to:

stable-x86_64-unknown-linux-gnu (default)
nightly-x86_64-unknown-linux-gnu

Both toolchains are ready. Move on to the next substep.

ⓧ I only see one toolchain

If you only see stable, run the nightly install command again:

rustup toolchain install nightly --component rust-src

If you only see nightly, install stable:

rustup install stable
  • Run rustup toolchain list again to confirm both appear.

ⓧ Command not found

Rust is not installed on your system. Install it using the official installer:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • Follow the on-screen prompts and accept the defaults.
  • Restart your terminal or run source $HOME/.cargo/env to load the new PATH.
  • Then install the nightly toolchain:
rustup toolchain install nightly --component rust-src
Install eBPF tooling

Two Cargo tools are needed for your eBPF workflow. bpf-linker links your compiled eBPF code into a format the kernel can load, and cargo-generate scaffolds new projects from templates.

  • Install bpf-linker by running:
cargo install bpf-linker

What does bpf-linker do?

When Rust compiles your eBPF program, it produces object files targeting the BPF instruction set. bpf-linker links these object files into a single .o file that the kernel's eBPF verifier can validate and load.

  • Install cargo-generate by running:
cargo install cargo-generate
  • Confirm both tools are available by running:
bpf-linker --version
  • Then check cargo-generate by running:
cargo generate --help | head -1

You should see a version number from bpf-linker and the first line of the cargo-generate help output.

Seeing compilation errors during install?

The bpf-linker crate depends on LLVM libraries. If you see errors about missing LLVM, install the development packages for your distribution:

sudo apt install llvm-dev libclang-dev

Then retry cargo install bpf-linker.

Verify your kernel supports BTF

Aya's eBPF programs require BTF (BPF Type Format) to be enabled in your kernel. BTF provides type information that lets your eBPF code work across different kernel versions without recompiling.

  • Check your kernel version by running:
uname -r

Your kernel version must be 5.8 or higher for ring buffer support.

  • Verify BTF is enabled by checking for the BTF data file:
ls /sys/kernel/btf/vmlinux

✔️ I see /sys/kernel/btf/vmlinux

Your kernel has BTF enabled. You are ready for eBPF development.

ⓧ No such file or directory

Your kernel does not have BTF enabled. You need a kernel compiled with CONFIG_DEBUG_INFO_BTF=y.

On Ubuntu 20.10+ and Fedora 33+, the default kernel includes BTF. Upgrade your kernel or distribution to get BTF support.

  • On Ubuntu, update to the latest kernel by running:
sudo apt update && sudo apt upgrade
  • Reboot after the upgrade and check again with ls /sys/kernel/btf/vmlinux.

ⓧ Kernel version is below 5.8

You need kernel 5.8 or higher for BPF ring buffer maps. Upgrade your kernel to a supported version.

  • On Ubuntu, install a newer kernel by running:
sudo apt update && sudo apt upgrade
  • Reboot your system and verify with uname -r that your kernel is now 5.8 or higher.
  • Install bpftool for inspecting BPF programs and maps on your system:
sudo apt install bpftool
  • Verify the installation by running:
bpftool version

You should see a version number printed, confirming bpftool is ready to use.

Package not found?

On some Ubuntu releases, bpftool is provided through the kernel tools package instead. Try installing it this way:

sudo apt install linux-tools-common linux-tools-$(uname -r)

On Fedora, use sudo dnf install bpftool instead.

What is bpftool?

bpftool lets you inspect loaded BPF programs, view map contents, and debug your firewall at runtime. You will use it later to verify your XDP program is attached correctly and to inspect the blocklist map.

Your development environment is fully set up. Next, you will scaffold the Aya XDP project and explore its workspace structure.

Scaffold and Explore the Aya XDP Project

Your development tools are ready. Now you need a project structure that separates kernel-space eBPF code from user-space Rust code.

Aya provides a cargo-generate template that scaffolds a workspace with the correct layout for XDP programs. In this step, you will scaffold the project, explore its three-crate structure, and verify the skeleton builds and attaches to your network interface.

In this step, get ready to:
  • Scaffold the XDP firewall project with cargo-generate.
  • Explore the generated three-crate workspace structure.
  • Build and run the skeleton to verify it attaches to your network interface.
Generate the project

The Aya template creates a complete workspace with separate crates for the eBPF kernel program, the user-space loader, and shared types between them.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the XDP firewall project by running this command:
cargo generate --name xdp-firewall -d program_type=xdp https://github.com/aya-rs/aya-template

What does this command do?

  • cargo generate uses the cargo-generate tool to scaffold a project from a template.
  • --name xdp-firewall sets the project name, which determines the workspace and crate names.
  • -d program_type=xdp tells the template to generate an XDP program (skipping the interactive prompt).
  • The URL points to the official Aya template repository.
  • Confirm the project was created by running:
ls xdp-firewall

You should see directories and files including xdp-firewall/, xdp-firewall-ebpf/, xdp-firewall-common/, and a top-level Cargo.toml.

Command not found?

  • Make sure you installed cargo-generate in Step 1. Try running cargo install cargo-generate again.
  • If you see a network error, check your internet connection. The template is fetched from GitHub.

help me troubleshoot cargo-generate failing to scaffold my Aya XDP project

Explore the generated structure

The template created a Cargo workspace with three crates that each serve a distinct purpose. Understanding this layout is key to working with eBPF programs.

  • Move into the project directory by running:
cd xdp-firewall
  • View the workspace structure by running:
ls

You should see three crate directories alongside the top-level Cargo.toml.

What are the three crates?

  • xdp-firewall-ebpf/ contains the kernel-space eBPF program. This code runs inside the Linux kernel at the XDP hook point. It is compiled to BPF bytecode using the nightly toolchain and bpf-linker.
  • xdp-firewall/ contains the user-space application. It loads the compiled eBPF program into the kernel, attaches it to your network interface, and manages runtime configuration.
  • xdp-firewall-common/ holds shared type definitions that both the kernel and user-space code need to agree on (like struct layouts for passing data between them).

Cargo build scripts in the user-space crate handle compiling the eBPF program and embedding it into the final binary automatically. You just run cargo build and everything compiles together.

Build and test the skeleton

The template generates a default eBPF program with logging and error handling. You will replace it with a minimal pass-all program to establish a clean baseline before adding firewall logic in the next step.

  • Open xdp-firewall-ebpf/src/main.rs in your text editor and replace its entire contents with this code:
#![no_std]
#![no_main]

use aya_ebpf::{macros::xdp, programs::XdpContext};
use aya_ebpf::bindings::xdp_action;

#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
    xdp_action::XDP_PASS
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}

What does this code do?

  • #![no_std] and #![no_main] tell Rust this program runs in a constrained environment without the standard library or a normal entry point.
  • The #[xdp] macro marks xdp_firewall as an XDP program entry point that the kernel will call for every incoming packet.
  • xdp_action::XDP_PASS tells the kernel to let every packet through. This is a safe starting point that does not disrupt your network.
  • The panic handler is required for no_std programs. It uses unreachable_unchecked() because eBPF programs cannot actually panic at runtime.
  • Save xdp-firewall-ebpf/src/main.rs.
  • Build the entire workspace by running:
cargo build

You should see output ending with a successful compilation message. The build scripts automatically compile the eBPF program and embed it into the user-space binary.

Build failing?

  • If you see errors about missing rust-src, confirm the nightly component is installed: rustup component add rust-src --toolchain nightly.
  • If bpf-linker is not found, reinstall it with cargo install bpf-linker.
  • If you see linker errors, make sure you are using Linux with a kernel that supports BTF.

help me fix build errors in my Aya eBPF project

  • Find your network interface name by running:
ip link show

Look for an interface like eth0, ens33, or enp0s3. Note the name. You will need it in the next command.

  • Run the firewall skeleton to verify it attaches to your network interface:
RUST_LOG=info cargo run --config 'target."cfg(all())".runner="sudo -E"' -- --iface [[IFACE="your network interface (e.g., eth0, ens33)"]]

What does this command do?

  • RUST_LOG=info enables informational log messages so you can see what the program is doing.
  • --config 'target."cfg(all())".runner="sudo -E"' tells Cargo to run the compiled binary with sudo. eBPF programs require root privileges (CAP_BPF, CAP_NET_ADMIN) to load into the kernel.
  • --iface specifies which network interface to attach the XDP program to. If your interface is eth0, you can omit this flag entirely since it is the default.

You should see a log message indicating the program attached and is waiting. The program passes all traffic through, so your network remains functional.

  • Press Ctrl+C to stop the program. Aya automatically detaches the XDP program from your interface on exit.
  • Verify the program is no longer loaded by running:
sudo bpftool prog list

You should see that xdp_firewall is no longer listed. This confirms Aya cleaned up properly on exit.

Program failed to attach?

  • If you see an error about attaching with default flags, your system may not have native XDP driver support (common in VMs). Open xdp-firewall/src/main.rs and change XdpFlags::default() to XdpFlags::SKB_MODE in the .attach() call, then rebuild.
  • If you see a permission denied error, make sure the sudo -E runner config is included in the command.
  • If the interface name is not found, double-check the output of ip link show and use the exact interface name.

help me attach my XDP program to a network interface

✔️ Awesome, I've got everything!

Great. Double check you have saved xdp-firewall-ebpf/src/main.rs with the simplified pass-all program.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

xdp-firewall-ebpf/src/main.rs

#![no_std]
#![no_main]

use aya_ebpf::{macros::xdp, programs::XdpContext};
use aya_ebpf::bindings::xdp_action;

#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
    xdp_action::XDP_PASS
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}

xdp-firewall-common/src/lib.rs

#![no_std]

xdp-firewall/src/main.rs remains as the template generated it. You will replace it fully in Step 4.

Your skeleton firewall compiles, loads into the kernel, and attaches to your network interface without dropping any traffic. Next up, you will write the packet parser that inspects Ethernet, IPv4, TCP, and UDP headers to build the actual firewall logic.

Write the Packet Parser in eBPF

Your eBPF skeleton currently passes every packet through without inspection. To build a firewall, the program needs to parse raw packet bytes and make filtering decisions at the kernel level.

In this step, you will write the core packet-parsing logic that reads Ethernet, IPv4, TCP, and UDP headers from each incoming packet. You will also define a shared data structure, set up a blocklist, and emit traffic events to a ring buffer for monitoring.

In this step, get ready to:
  • Parse Ethernet and IPv4 headers from raw packet data using bounds-checked pointer arithmetic.
  • Extract source and destination port information from TCP and UDP headers.
  • Implement an IP blocklist check that drops or passes packets and logs events to a ring buffer.
Parse Ethernet and IPv4 headers

Before the eBPF program can inspect packets, both the kernel-space and user-space code need to agree on a shared data format. You will define a PacketEvent struct in the common crate that both programs can reference.

  • Open xdp-firewall-common/src/lib.rs and replace its contents with this code:
#![no_std]

// Shared struct for packet event data between kernel and user-space
#[repr(C)]
#[derive(Clone, Copy)]
pub struct PacketEvent {
    pub src_addr: u32,
    pub src_port: u16,
    pub dst_port: u16,
    pub protocol: u8,
    pub action: u32,
}

What does this code do?

This struct defines the shape of every traffic event your firewall will emit. Each field captures one piece of packet metadata.

  • src_addr holds the source IPv4 address as a 32-bit integer.
  • src_port and dst_port hold the TCP or UDP port numbers.
  • protocol stores the IP protocol number (6 for TCP, 17 for UDP).
  • action records whether the packet was passed or dropped.

The #[repr(C)] attribute ensures the struct has a predictable memory layout so both the kernel-space eBPF program and user-space Rust code interpret the bytes identically.

  • Save xdp-firewall-common/src/lib.rs.
  • Verify it compiles by running:
cargo build -p xdp-firewall-common

You should see a successful compilation with no errors.

Seeing a compilation error?

  • Make sure you kept the #![no_std] attribute at the top of the file. The common crate must be no_std because it is shared with the eBPF kernel-space program.
  • Check that all fields use primitive types (u32, u16, u8) and not standard library types.

Help me fix a compilation error in my xdp-firewall-common crate

Now you are ready to write the full eBPF kernel-space program. This program parses incoming packets, checks a blocklist, and emits events.

  • Add the network-types crate to your eBPF package by running:
cargo add network-types --package xdp-firewall-ebpf

What is network-types?

The network-types crate provides Rust struct definitions for common network protocol headers (Ethernet, IPv4, TCP, UDP). It saves you from manually defining header layouts and calculating byte offsets.

  • Open xdp-firewall-ebpf/src/main.rs and replace the entire file contents with this code:
#![no_std]
#![no_main]

use aya_ebpf::{
    bindings::xdp_action,
    macros::{map, xdp},
    maps::{HashMap, RingBuf},
    programs::XdpContext,
};
use aya_log_ebpf::info;
use core::mem;
use network_types::{
    eth::{EthHdr, EtherType},
    ip::{Ipv4Hdr, IpProto},
    tcp::TcpHdr,
    udp::UdpHdr,
};
use xdp_firewall_common::PacketEvent;

// BPF map: IP blocklist (key = IP as u32, value = dummy)
#[map]
static BLOCKLIST: HashMap<u32, u32> = HashMap::with_max_entries(1024, 0);

// BPF map: ring buffer for streaming packet events to user-space
#[map]
static EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);

#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
    match try_xdp_firewall(ctx) {
        Ok(ret) => ret,
        Err(_) => xdp_action::XDP_ABORTED,
    }
}

// Bounds-checked pointer read - required by the eBPF verifier
#[inline(always)]
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
    let start = ctx.data();
    let end = ctx.data_end();
    let len = mem::size_of::<T>();

    if start + offset + len > end {
        return Err(());
    }

    Ok((start + offset) as *const T)
}

fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
    // Parse Ethernet header at offset 0
    let ethhdr: *const EthHdr = unsafe { ptr_at(&ctx, 0)? };
    match unsafe { (*ethhdr).ether_type } {
        EtherType::Ipv4 => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    // Parse IPv4 header right after Ethernet
    let ipv4hdr: *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)? };
    let source_addr = u32::from_be(unsafe { (*ipv4hdr).src_addr });
    let proto = unsafe { (*ipv4hdr).proto };

    // Extract source and destination ports from TCP or UDP header
    let (src_port, dst_port) = match proto {
        IpProto::Tcp => {
            let tcphdr: *const TcpHdr =
                unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? };
            (
                u16::from_be(unsafe { (*tcphdr).source }),
                u16::from_be(unsafe { (*tcphdr).dest }),
            )
        }
        IpProto::Udp => {
            let udphdr: *const UdpHdr =
                unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? };
            (
                u16::from_be(unsafe { (*udphdr).source }),
                u16::from_be(unsafe { (*udphdr).dest }),
            )
        }
        _ => (0, 0),
    };

    // Check blocklist: drop if source IP is present, pass otherwise
    let action = if unsafe { BLOCKLIST.get(&source_addr) }.is_some() {
        xdp_action::XDP_DROP
    } else {
        xdp_action::XDP_PASS
    };

    // Emit packet event to ring buffer for user-space monitoring
    if let Some(mut entry) = EVENTS.reserve::<PacketEvent>(0) {
        let event = PacketEvent {
            src_addr: source_addr,
            src_port,
            dst_port,
            protocol: proto as u8,
            action,
        };
        entry.write(event);
        entry.submit(0);
    }

    Ok(action)
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}

How does the Ethernet and IPv4 parsing work?

The ptr_at function is the foundation of safe packet parsing in eBPF. It checks that reading size_of::<T>() bytes at a given offset will not exceed the packet boundary. Without this bounds check, the eBPF verifier rejects your program.

  • ptr_at(&ctx, 0) reads the Ethernet header from the start of the packet.
  • The ether_type field is checked against EtherType::Ipv4. Non-IPv4 packets are immediately passed through.
  • ptr_at(&ctx, EthHdr::LEN) reads the IPv4 header starting right after the Ethernet header.
  • source_addr is extracted with u32::from_be() to convert from network byte order (big-endian) to host byte order.
Extract transport layer info

After parsing the IPv4 header, the program digs one layer deeper to extract port numbers from TCP or UDP headers.

How does TCP and UDP parsing work?

The proto field from the IPv4 header tells you which transport protocol the packet uses. The match expression handles each case differently.

  • IpProto::Tcp means the bytes after the IPv4 header are a TCP header. The code reads it at offset EthHdr::LEN + Ipv4Hdr::LEN and extracts source and dest port fields.
  • IpProto::Udp follows the same pattern but reads a UDP header instead.
  • Any other protocol gets (0, 0) as port values since protocols like ICMP do not use ports.
  • Each ptr_at call performs its own bounds check, so the verifier is satisfied at every access point.

All port values use u16::from_be() because network protocols store multi-byte values in big-endian order.

Implement the blocklist check

With the source IP and port information extracted, the program can now make a filtering decision and report what happened.

How do the blocklist and ring buffer work?

The BLOCKLIST is a BPF HashMap that maps source IP addresses (u32) to a dummy value. If the source IP exists in the map, the packet is dropped. Otherwise it passes through.

  • BLOCKLIST.get(&source_addr) performs a map lookup in O(1) time. A Some result means the IP is blocked.
  • xdp_action::XDP_DROP silently discards the packet before it reaches the kernel network stack.
  • xdp_action::XDP_PASS lets the packet continue normally.

The EVENTS RingBuf is a shared memory buffer between kernel and user-space. After every packet decision, the program reserves space, writes a PacketEvent, and submits it. The user-space application (built in the next step) will consume these events for real-time monitoring.

  • Save xdp-firewall-ebpf/src/main.rs.
  • Build the full workspace from the project root by running:
cargo build

You should see a successful build with no errors. The build scripts automatically compile the eBPF program targeting the bpfel-unknown-none architecture.

Seeing a build error?

  • If you see an unresolved import for network_types, confirm the cargo add command ran successfully by checking xdp-firewall-ebpf/Cargo.toml for a network-types entry under [dependencies].
  • If you see an unresolved import for xdp_firewall_common, verify that xdp-firewall-common is listed as a dependency in xdp-firewall-ebpf/Cargo.toml. The Aya template should have wired this up automatically.
  • If you see a verifier-style error about pointer bounds, double-check that the ptr_at function matches the code exactly. The bounds check (start + offset + len > end) is required for the eBPF verifier to accept the program.

Help me debug a build error in my Aya eBPF project

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete xdp-firewall-common/src/lib.rs:

#![no_std]

#[repr(C)]
#[derive(Clone, Copy)]
pub struct PacketEvent {
    pub src_addr: u32,
    pub src_port: u16,
    pub dst_port: u16,
    pub protocol: u8,
    pub action: u32,
}

And here is the complete xdp-firewall-ebpf/src/main.rs:

#![no_std]
#![no_main]

use aya_ebpf::{
    bindings::xdp_action,
    macros::{map, xdp},
    maps::{HashMap, RingBuf},
    programs::XdpContext,
};
use aya_log_ebpf::info;
use core::mem;
use network_types::{
    eth::{EthHdr, EtherType},
    ip::{Ipv4Hdr, IpProto},
    tcp::TcpHdr,
    udp::UdpHdr,
};
use xdp_firewall_common::PacketEvent;

#[map]
static BLOCKLIST: HashMap<u32, u32> = HashMap::with_max_entries(1024, 0);

#[map]
static EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);

#[xdp]
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
    match try_xdp_firewall(ctx) {
        Ok(ret) => ret,
        Err(_) => xdp_action::XDP_ABORTED,
    }
}

#[inline(always)]
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
    let start = ctx.data();
    let end = ctx.data_end();
    let len = mem::size_of::<T>();

    if start + offset + len > end {
        return Err(());
    }

    Ok((start + offset) as *const T)
}

fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
    let ethhdr: *const EthHdr = unsafe { ptr_at(&ctx, 0)? };
    match unsafe { (*ethhdr).ether_type } {
        EtherType::Ipv4 => {}
        _ => return Ok(xdp_action::XDP_PASS),
    }

    let ipv4hdr: *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)? };
    let source_addr = u32::from_be(unsafe { (*ipv4hdr).src_addr });
    let proto = unsafe { (*ipv4hdr).proto };

    let (src_port, dst_port) = match proto {
        IpProto::Tcp => {
            let tcphdr: *const TcpHdr =
                unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? };
            (
                u16::from_be(unsafe { (*tcphdr).source }),
                u16::from_be(unsafe { (*tcphdr).dest }),
            )
        }
        IpProto::Udp => {
            let udphdr: *const UdpHdr =
                unsafe { ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? };
            (
                u16::from_be(unsafe { (*udphdr).source }),
                u16::from_be(unsafe { (*udphdr).dest }),
            )
        }
        _ => (0, 0),
    };

    let action = if unsafe { BLOCKLIST.get(&source_addr) }.is_some() {
        xdp_action::XDP_DROP
    } else {
        xdp_action::XDP_PASS
    };

    if let Some(mut entry) = EVENTS.reserve::<PacketEvent>(0) {
        let event = PacketEvent {
            src_addr: source_addr,
            src_port,
            dst_port,
            protocol: proto as u8,
            action,
        };
        entry.write(event);
        entry.submit(0);
    }

    Ok(action)
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    unsafe { core::hint::unreachable_unchecked() }
}

Your eBPF program now knows how to dissect every incoming packet down to the port level, filter by IP, and report its decisions. Next up, you will build the user-space controller that loads this program onto your network interface and populates the blocklist.

Build the User-Space Controller

Your eBPF program is compiled and ready to run in kernel-space. But it needs a partner on the other side: a user-space controller that loads the compiled eBPF bytecode into the kernel, attaches it to a network interface, and manages the blocklist.

In this step, you will write the user-space half of the firewall. This is the code that gives you runtime control over what the kernel-level program does.

In this step, get ready to:
  • Load and attach the XDP program to your network interface.
  • Populate the blocklist with IPs to block at runtime.
  • Build and verify the firewall drops blocked traffic.
Load and attach the XDP program

The user-space application uses Aya's Ebpf struct to load the compiled eBPF object file and attach the XDP program to a network interface. The build system compiles the eBPF crate automatically and embeds the bytecode into the userspace binary.

  • Replace the scaffolded code in xdp-firewall/src/main.rs with the following:
use anyhow::Context;
use aya::{
    maps::{HashMap, RingBuf},
    programs::{Xdp, XdpFlags},
    Ebpf,
};
use aya_log::EbpfLogger;
use clap::Parser;
use log::{info, warn};
use std::net::Ipv4Addr;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::signal;
use xdp_firewall_common::PacketEvent;

// CLI argument struct for specifying the network interface
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Opt {
    #[arg(short, long, default_value = "eth0")]
    iface: String,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let opt = Opt::parse();

    env_logger::init();

    // Load the compiled eBPF object embedded at compile time
    let mut bpf = Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/xdp-firewall"
    )))?;

    if let Err(e) = EbpfLogger::init(&mut bpf) {
        warn!("failed to initialize eBPF logger: {}", e);
    }

    // Retrieve the XDP program by name and load it into the kernel
    let program: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
    program.load()?;
    program
        .attach(&opt.iface, XdpFlags::default())
        .context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;

    info!("XDP firewall attached to {}", opt.iface);

    Ok(())
}

What does this code do?

This sets up the user-space controller for your eBPF firewall.

  • Ebpf::load() reads the compiled eBPF bytecode that was embedded at compile time via include_bytes_aligned!. The build scripts handle compiling the eBPF crate automatically.
  • program.load() sends the bytecode to the kernel's eBPF verifier, which checks it for safety (no infinite loops, valid memory access, bounded execution).
  • program.attach() hooks the verified program to the specified network interface at the XDP level. Every packet arriving on that interface now passes through your eBPF code.
  • The Opt struct uses clap to parse --iface from the command line, defaulting to eth0.

What is XdpFlags?

XdpFlags::default() tries native XDP mode, where the driver processes packets without allocating a socket buffer. This is the fastest mode.

If your system does not support native XDP (common in VMs and some cloud instances), the program will fail to attach. The .context() error message tells you to switch to XdpFlags::SKB_MODE as a fallback.

  • Save xdp-firewall/src/main.rs.
  • Confirm the project compiles by running:
cargo build

You should see a successful compilation with no errors. Warnings about unused imports are fine for now.

Build failing?

  • If you see an error about xdp_firewall_common not being found, make sure your xdp-firewall-common/src/lib.rs file is saved with the PacketEvent struct from the previous step.
  • If you see errors about missing crates like aya or tokio, check that your xdp-firewall/Cargo.toml lists them as dependencies.

Help me debug my eBPF user-space build errors

Populate the blocklist at runtime

BPF maps are the shared data structures between kernel-space and user-space. Your eBPF program reads from the BLOCKLIST HashMap to decide whether to drop packets. The user-space controller writes entries into that same map.

  • In xdp-firewall/src/main.rs, find the Ok(()) at the end of the main function.
  • Add the following code directly above Ok(()):
    // Populate the blocklist with test IPs
    let mut blocklist: HashMap<_, u32, u32> =
        HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;

    // Block 1.1.1.1 as a test
    let blocked_ip: u32 = Ipv4Addr::new(1, 1, 1, 1).into();
    blocklist.insert(blocked_ip, 0, 0)?;
    info!("Blocked IP: 1.1.1.1");

    // Set up ring buffer for traffic monitoring
    let ring_buf = RingBuf::try_from(bpf.map_mut("EVENTS").unwrap())?;
    let async_fd = AsyncFd::with_interest(ring_buf, Interest::READABLE)?;

What does this code do?

  • HashMap::try_from(bpf.map_mut("BLOCKLIST")) gets a typed handle to the same BPF HashMap your kernel-space program reads. The map lives in kernel memory, shared between both sides.
  • Ipv4Addr::new(1, 1, 1, 1).into() converts the IP address 1.1.1.1 into a u32 that matches the key type in your kernel-side HashMap.
  • blocklist.insert(blocked_ip, 0, 0) adds the IP to the blocklist. The value (0) is unused. The kernel program only checks if the key exists.
  • The RingBuf and AsyncFd setup prepares the event stream you will consume in the next step for real-time traffic monitoring.
  • Save xdp-firewall/src/main.rs.
  • Confirm the build still succeeds by running:
cargo build

The project should compile cleanly with no errors.

Seeing a type mismatch error?

  • Make sure the HashMap type annotation matches HashMap<_, u32, u32>. The underscore lets Rust infer the map definition type while you specify the key and value types.
  • If you see an error about EVENTS map not found, double-check that your eBPF program in xdp-firewall-ebpf/src/main.rs defines the EVENTS RingBuf map with the #[map] attribute.

Help me fix my BPF map access errors in user-space

Run and verify the firewall

Time to see your firewall in action. The program requires root privileges because attaching eBPF programs to network interfaces needs CAP_BPF and CAP_NET_ADMIN capabilities.

  • First, identify your network interface name by running:
ip link show

Look for your primary network interface (e.g., eth0, enp0s3, wlp2s0). You will pass this name with the --iface flag.

  • Build the release binary and run the firewall by running:
cargo build --release
sudo RUST_LOG=info ./target/release/xdp-firewall --iface [[IFACE="your-interface-name"]]

You should see output confirming the firewall is attached and the IP is blocked:

[INFO  xdp_firewall] XDP firewall attached to eth0
[INFO  xdp_firewall] Blocked IP: 1.1.1.1
  • Open a second terminal and test that packets to 1.1.1.1 are being dropped by running:
ping -c 4 1.1.1.1

You should see 100% packet loss. Replies from 1.1.1.1 are being dropped by your XDP program before they reach the network stack.

Why does ping fail?

When you ping 1.1.1.1, the reply packets arrive at your network interface with source IP 1.1.1.1. Your XDP program inspects the source address, finds it in the blocklist, and returns XDP_DROP. The kernel silently discards the packet before it ever reaches the networking stack or your ping process.

  • Stop the firewall by pressing Ctrl+C in the first terminal.

Program fails to attach?

  • If you see the error about default flags, your network driver does not support native XDP. Open xdp-firewall/src/main.rs and change XdpFlags::default() to XdpFlags::SKB_MODE in the .attach() call. Rebuild and try again.
  • If you get a permission denied error, make sure you are running with sudo. eBPF programs require root privileges to attach.
  • If the interface name is wrong, re-run ip link show and use the correct interface name with --iface.

Help me troubleshoot my XDP program attachment failure

✔️ Awesome, I've got everything!

Great. Double check you have saved xdp-firewall/src/main.rs and confirmed the ping test shows dropped packets.

ⓧ I'd like to double check the full code

Here is the complete xdp-firewall/src/main.rs at the end of this step:

use anyhow::Context;
use aya::{
    maps::{HashMap, RingBuf},
    programs::{Xdp, XdpFlags},
    Ebpf,
};
use aya_log::EbpfLogger;
use clap::Parser;
use log::{info, warn};
use std::net::Ipv4Addr;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::signal;
use xdp_firewall_common::PacketEvent;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Opt {
    #[arg(short, long, default_value = "eth0")]
    iface: String,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let opt = Opt::parse();

    env_logger::init();

    let mut bpf = Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/xdp-firewall"
    )))?;

    if let Err(e) = EbpfLogger::init(&mut bpf) {
        warn!("failed to initialize eBPF logger: {}", e);
    }

    let program: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
    program.load()?;
    program
        .attach(&opt.iface, XdpFlags::default())
        .context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;

    info!("XDP firewall attached to {}", opt.iface);

    let mut blocklist: HashMap<_, u32, u32> =
        HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;

    let blocked_ip: u32 = Ipv4Addr::new(1, 1, 1, 1).into();
    blocklist.insert(blocked_ip, 0, 0)?;
    info!("Blocked IP: 1.1.1.1");

    let ring_buf = RingBuf::try_from(bpf.map_mut("EVENTS").unwrap())?;
    let async_fd = AsyncFd::with_interest(ring_buf, Interest::READABLE)?;

    Ok(())
}

Your firewall is working. It loads the eBPF program, attaches to the network interface, and silently drops packets from blocked IPs at wire speed. Next up, you will add real-time traffic monitoring so you can see every packet flowing through the interface.

Add Real-Time Traffic Monitoring with Ring Buffers

Your firewall silently drops packets from blocked IPs, but you have no visibility into what's actually happening on the wire. Right now, events are being written to a ring buffer in kernel space, but nothing on the user-space side is reading them.

In this step, you'll complete the monitoring pipeline by consuming events from the BPF ring buffer using an async Tokio task. Each packet that passes through your XDP hook will appear as a row in your terminal with source IP, ports, protocol, and whether it was passed or dropped.

In this step, get ready to:
  • Review how the eBPF ring buffer streams events from kernel to user space.
  • Build the async event consumer that decodes and displays live traffic.
  • Test the complete firewall system with real-time monitoring.
Review how the ring buffer streams events

Before writing the consumer, take a moment to understand the data pipeline you've already built. Your eBPF program in xdp-firewall-ebpf/src/main.rs writes a PacketEvent struct to the EVENTS ring buffer every time a packet passes through the XDP hook.

How does the ring buffer pipeline work?

The EVENTS map is a 256 KB ring buffer shared between kernel and user space. In your eBPF code, EVENTS.reserve::<PacketEvent>(0) allocates a slot, entry.write(event) fills it, and entry.submit(0) publishes it to user space.

On the user-space side, the AsyncFd wrapper you created in the previous step makes the ring buffer's file descriptor compatible with Tokio's async I/O. When events arrive, Tokio wakes your task so you can drain and decode them.

Your user-space code already wraps the ring buffer in an AsyncFd. The missing piece is the actual consumption loop that reads, decodes, and prints each event.

Consume events in user-space with Tokio

The consumption loop runs as a spawned Tokio task that continuously waits for the ring buffer to become readable. Each time data arrives, it drains all available events and prints them as formatted table rows.

  • In xdp-firewall/src/main.rs, find the Ok(()) at the end of main.
  • Replace Ok(()) with the following code that prints a table header, spawns the monitor task, and waits for Ctrl+C:
    info!("Monitoring traffic... Press Ctrl+C to exit.");
    info!("{:<16} {:<8} {:<8} {:<8} {}", "SRC IP", "SRC PORT", "DST PORT", "PROTO", "ACTION");

    let monitor = tokio::spawn(async move {
        loop {
            // Wait until the ring buffer has data ready to read
            let mut guard = async_fd.readable_mut().await.unwrap();
            // Drain all available events from the ring buffer
            while let Some(event_bytes) = guard.get_inner_mut().next() {
                if event_bytes.len() >= core::mem::size_of::<PacketEvent>() {
                    // Decode the raw bytes into a PacketEvent struct
                    let event: PacketEvent =
                        unsafe { core::ptr::read_unaligned(event_bytes.as_ptr() as *const PacketEvent) };

                    // Convert the u32 source address back to a readable IP
                    let src_ip = Ipv4Addr::from(event.src_addr);
                    // Map protocol number to a human-readable name
                    let proto = match event.protocol {
                        6 => "TCP",
                        17 => "UDP",
                        _ => "OTHER",
                    };
                    // Map the XDP action to a status string
                    let action_str = match event.action {
                        1 => "DROP",
                        _ => "PASS",
                    };

                    println!(
                        "{:<16} {:<8} {:<8} {:<8} {}",
                        src_ip, event.src_port, event.dst_port, proto, action_str
                    );
                }
            }
            // Mark the fd as no longer ready so Tokio re-polls
            guard.clear_ready();
        }
    });

    // Block until the user presses Ctrl+C
    signal::ctrl_c().await?;
    info!("Exiting...");
    monitor.abort();

    Ok(())

What does this code do?

  • tokio::spawn creates a background task that runs the monitoring loop concurrently with the signal handler.
  • async_fd.readable_mut().await suspends the task until the kernel writes new events to the ring buffer.
  • guard.get_inner_mut().next() returns raw byte slices for each event. You use read_unaligned because the kernel may not align data to struct boundaries.
  • The protocol byte maps 6 to TCP and 17 to UDP (IANA protocol numbers). The action value 1 corresponds to XDP_DROP in the kernel.
  • guard.clear_ready() tells Tokio to re-register for readability notifications after draining.
  • signal::ctrl_c().await blocks the main task until the user presses Ctrl+C, then monitor.abort() stops the background loop.
  • Save xdp-firewall/src/main.rs.
  • Build the project to confirm there are no compilation errors by running:
cargo build --release
  • You should see a successful build with no errors in the output.

Build errors?

  • If you see an error about PacketEvent not implementing a required trait, make sure xdp-firewall-common/src/lib.rs has #[derive(Clone, Copy)] on the struct.
  • If async_fd.readable_mut() is not found, confirm you imported tokio::io::unix::AsyncFd and tokio::io::Interest at the top of the file.

Help me debug my build error

✔️ Awesome, I've got everything!

Great. Make sure you've saved xdp-firewall/src/main.rs and the build completed successfully.

ⓧ I'd like to double check the full code

use anyhow::Context;
use aya::{
    maps::{HashMap, RingBuf},
    programs::{Xdp, XdpFlags},
    Ebpf,
};
use aya_log::EbpfLogger;
use clap::Parser;
use log::{info, warn};
use std::net::Ipv4Addr;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::signal;
use xdp_firewall_common::PacketEvent;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Opt {
    #[arg(short, long, default_value = "eth0")]
    iface: String,
}

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let opt = Opt::parse();

    env_logger::init();

    let mut bpf = Ebpf::load(aya::include_bytes_aligned!(concat!(
        env!("OUT_DIR"),
        "/xdp-firewall"
    )))?;

    if let Err(e) = EbpfLogger::init(&mut bpf) {
        warn!("failed to initialize eBPF logger: {}", e);
    }

    let program: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
    program.load()?;
    program
        .attach(&opt.iface, XdpFlags::default())
        .context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;

    info!("XDP firewall attached to {}", opt.iface);

    let mut blocklist: HashMap<_, u32, u32> =
        HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;

    let blocked_ip: u32 = Ipv4Addr::new(1, 1, 1, 1).into();
    blocklist.insert(blocked_ip, 0, 0)?;
    info!("Blocked IP: 1.1.1.1");

    let ring_buf = RingBuf::try_from(bpf.map_mut("EVENTS").unwrap())?;
    let async_fd = AsyncFd::with_interest(ring_buf, Interest::READABLE)?;

    info!("Monitoring traffic... Press Ctrl+C to exit.");
    info!("{:<16} {:<8} {:<8} {:<8} {}", "SRC IP", "SRC PORT", "DST PORT", "PROTO", "ACTION");

    let monitor = tokio::spawn(async move {
        loop {
            let mut guard = async_fd.readable_mut().await.unwrap();
            while let Some(event_bytes) = guard.get_inner_mut().next() {
                if event_bytes.len() >= core::mem::size_of::<PacketEvent>() {
                    let event: PacketEvent =
                        unsafe { core::ptr::read_unaligned(event_bytes.as_ptr() as *const PacketEvent) };

                    let src_ip = Ipv4Addr::from(event.src_addr);
                    let proto = match event.protocol {
                        6 => "TCP",
                        17 => "UDP",
                        _ => "OTHER",
                    };
                    let action_str = match event.action {
                        1 => "DROP",
                        _ => "PASS",
                    };

                    println!(
                        "{:<16} {:<8} {:<8} {:<8} {}",
                        src_ip, event.src_port, event.dst_port, proto, action_str
                    );
                }
            }
            guard.clear_ready();
        }
    });

    signal::ctrl_c().await?;
    info!("Exiting...");
    monitor.abort();

    Ok(())
}
Test the complete system

With the monitoring loop in place, your firewall is now a full end-to-end system. You can run it and watch live traffic flow through your network interface.

  • Start the firewall with root privileges by running:
RUST_LOG=info sudo -E cargo run --release -- --iface [[IFACE="your network interface name e.g. eth0"]]

How do I find my interface name?

Run ip link show to list your network interfaces. Common names include eth0, ens33, enp0s3 (wired), or wlan0 (wireless). Pick the one with an IP address assigned.

  • You should see output confirming the firewall attached and the table header printed.
  • Open a second terminal and generate some traffic by running:
ping -c 3 8.8.8.8
  • In your firewall terminal, you should see rows appearing with source IPs, ports, and PASS in the action column.
  • Now test the blocklist by pinging the blocked address from another terminal:
ping -c 3 1.1.1.1
  • Any reply packets from 1.1.1.1 will show DROP in the action column, confirming your blocklist is working.
  • Press Ctrl+C in the firewall terminal to stop the program cleanly.

What should I see?

Your terminal output should look something like this:

[INFO] XDP firewall attached to eth0
[INFO] Blocked IP: 1.1.1.1
[INFO] Monitoring traffic... Press Ctrl+C to exit.
[INFO] SRC IP           SRC PORT DST PORT PROTO    ACTION
8.8.8.8          0        0        OTHER    PASS
1.1.1.1          0        0        OTHER    DROP

The exact IPs and ports will differ based on your network activity. The key signal is that traffic from 1.1.1.1 shows DROP while other traffic shows PASS.

Not seeing any traffic output?

  • Make sure you're running with sudo. eBPF programs require root privileges (CAP_BPF, CAP_NET_ADMIN).
  • Check that the --iface flag matches your actual network interface name. Run ip link show to confirm.
  • If you see an error about attaching with default flags, try changing XdpFlags::default() to XdpFlags::SKB_MODE in your code. Some virtual machines and containers don't support native XDP.

Help me debug why my XDP firewall isn't showing traffic output

Your firewall is fully operational. You've built a complete kernel-to-user-space pipeline: packets arrive at the NIC, your XDP program inspects and filters them at wire speed, and events stream to your terminal in real time. Next up, the Secret Mission challenges you to add port-based filtering rules on top of this foundation.

Secret mission

Add Port-Based Filtering

Your firewall blocks IPs, but real firewalls also block specific ports. Can you extend the eBPF program to drop traffic on dangerous ports like 22 (SSH) and 8080, creating a true multi-rule firewall?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The xdp-firewall project directory on your Desktop.
  • Rust nightly toolchain (installed for eBPF compilation).
  • bpf-linker binary (installed via cargo install).
  • cargo-generate binary (installed via cargo install).

Keep everything running

No action needed. Choose this if you plan to keep developing your firewall, adding more filtering rules, or experimenting with other eBPF program types.

  • Your XDP program only runs while the process is active. Once you press Ctrl+C or close the terminal, the program detaches automatically.
  • The Rust toolchains and cargo-installed binaries remain available for future eBPF projects.
  • Your xdp-firewall directory stays on your Desktop for reference.

Pause - I'll come back to this later

Stop any running processes to free up system resources. Your project files and toolchains stay installed for when you return.

  • If your firewall is still running, press Ctrl+C in the terminal to stop it and detach the XDP program from your network interface.
  • Close any terminal windows you opened for this project.

Delete - I don't want to use this again

Remove all project files and the tools you installed specifically for this project.

  • If your firewall is still running, press Ctrl+C to stop it first.
  • Delete the project directory by running:
cd ~/Desktop
rm -rf xdp-firewall
  • Remove the bpf-linker binary by running:
cargo uninstall bpf-linker
  • Remove cargo-generate by running:
cargo uninstall cargo-generate
  • Remove the nightly toolchain by running:
rustup toolchain remove nightly

What about the stable Rust toolchain?

The stable Rust toolchain is useful for many other Rust projects. Only remove it if you are certain you will not use Rust again. You can remove it with rustup self uninstall which removes rustup and all installed toolchains.

Nice Work!

Nice Work!

You just built a kernel-level network firewall from scratch using Aya eBPF and Rust. Your firewall intercepts packets at the earliest possible hook point in the Linux networking stack, filtering traffic before it ever reaches userspace.

You've learned how to:

  • Write an eBPF XDP program that parses raw Ethernet, IPv4, TCP, and UDP headers to inspect every incoming packet at the kernel level.
  • Build a dynamic IP blocklist using BPF HashMaps that can be updated at runtime without restarting the program.
  • Implement real-time traffic monitoring using BPF ring buffers and async Rust with Tokio to stream packet events from kernel-space to user-space.
  • Added port-based filtering to create a multi-rule firewall that blocks traffic by destination port alongside IP-based blocking.

Ready to quiz yourself?