Simple Firewall with Rust and Aya
Tags: ebpf, linux, rust, networking
Published: 2024-06-19 06:03:54

<h1>Simple Firewall with Rust and Aya</h1>
<h1>Other Parts in this Series</h1>
<ul>
<li><a href="https://medium.com/@stevelatif/aya-rust-tutorial-part-5-using-maps-4d26c4a2fff8">Part 1 Introduction</a></li>
<li><a href="https://medium.com/@stevelatif/aya-rust-tutorial-part-two-setting-up-33b1e489cb93">Part 2 Setting Up</a></li>
<li><a href="https://medium.com/@stevelatif/aya-rust-tutorial-part-three-xdp-pass-c9b8e6e4baac">Part 3 XDP Pass</a></li>
<li><a href="https://medium.com/@stevelatif/aya-rust-tutorial-part-four-xdp-hello-world-c41abf76c353">Part 4 XDP Hello World</a></li>
<li><a href="https://medium.com/@stevelatif/aya-rust-tutorial-part-5-using-maps-4d26c4a2fff8">Part 5 XDP Using Maps</a></li>
<li><a href="https://medium.com/@stevelatif/simple-firewall-with-rust-and-aya-b56373c8bcc6">Part 6 Simple Firewall</a></li>
</ul>
<h1>Part 6 Creating a Simple Firewall</h1>
<p>Welcome to Part 6. In this chapter we will extend the work we did in part 5
where we looked at a simple PerCpuArray map to count packets.</p>
<p>Using eBPF we can create a simple firewall/router. With a small amount of code we can 
drop or redirect packets based on the source and destination addresses. 
We will implement this in several stages using a hashmap to store the 
configuration. 
The initial version will load the IP addresses from user space and to the eBPF kernel code,
and with each iteration we can add more functionality.</p>
<p>As before, generate the code 
using `</p>
<pre><code class="language-shell">cargo generate https://github.com/aya-rs/aya-template
</code></pre>
<p>I called the project <code>firewall-001</code></p>
<h1>Modify the generated source code</h1>
<p>Modify ebpf firewall-001-ebpf/Cargo.toml to include a dependency 
for the network-types crate:</p>
<pre><code class="language-cargo">[dependencies]
aya-ebpf = &quot;0.1.0&quot;
aya-log-ebpf = &quot;0.1.0&quot;
firewall-001-common = { path = &quot;../firewall-001-common&quot; }
network-types = &quot;0.0.5&quot;
</code></pre>
<p>Then modify the ebpf code in <code>firewall-001-ebpf/src/main.rs</code>
so we can add HashMap map </p>
<p>In the eBPF code <code>firewall-001-ebpf/src/main.rs</code>
the header section should look like this:</p>
<pre><code class="language-rust">    use aya<em>ebpf::{bindings::xdp</em>action,
               macros::{xdp, 
               map }, // &lt;---- added map macro
               programs::XdpContext,
               maps::HashMap // &lt;--- added hashmaps
               };
    use aya<em>log</em>ebpf::info;
    use core::mem;    // &lt;--- added memory crate
    
    use network_types::{ // Added
        eth::{EthHdr, EtherType}, 
        ip::{IpProto, Ipv4Hdr},
        tcp::TcpHdr,
        udp::UdpHdr,
    };
</code></pre>
<p>Add the map definition, as in Part 5 we define the map in the ebpf code in 
<code>firewall-001/firewall-001-ebpf/src/main.rs</code></p>
<pre><code class="language-rust">    #[map(name = &quot;SRC<em>IP</em>FILTER&quot;)]
    static mut SRC<em>IP</em>FILTER: HashMap&lt;u32, u8&gt; =
        HashMap::&lt;u32, u8&gt;::with<em>max</em>entries(1024, 0);
</code></pre>
<p>As we are working with the eBPF subsystem in the kernel we 
will need to work directly with raw pointers. This is where
will use the <code>core::mem crate</code>. We need to check the size 
of data or the verifier will complain</p>
<pre><code class="language-rust">    fn ptr_at&lt;T&gt;(ctx: &amp;XdpContext, offset: usize) -&gt; Result&lt;*const T, ()&gt; {
        let start = ctx.data();
        let end = ctx.data_end();
        let len = mem::size_of::&lt;T&gt;();
        if start + offset + len &gt; end {
            return Err(());
        }
        Ok((start + offset) as *const T)
    }
</code></pre>
<p>The packet parsing will be done in the try<em>firewall</em>001 function. We will peel off 
the layers of each packet till we match the rules passed in by the map IP</p>
<pre><code class="language-rust">    let ethhdr: *const EthHdr = ptr_at(&amp;ctx, 0)?; // 
    match unsafe { (*ethhdr).ether_type } {
        EtherType::Ipv4 =&gt; {
            info!(&amp;ctx, &quot;received IPv4 packet&quot;);
        }
        EtherType::Ipv6 =&gt; {
            info!(&amp;ctx, &quot;received IPv6 packet&quot;);
            return Ok(xdp<em>action::XDP</em>DROP);
        }
    
        <em> =&gt; return Ok(xdp_action::XDP</em>PASS),
    }
</code></pre>
<p>We pass all IPv4 packets but drop any IPv6 packets, in the next section 
we start to unpack the IPv4 header, first we get the port </p>
<pre><code class="language-rust">    let source_port = match unsafe { (*ipv4hdr).proto } {
        IpProto::Tcp =&gt; {
            let tcphdr: *const TcpHdr =
                ptr_at(&amp;ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
            u16::from_be(unsafe { (*tcphdr).source })
        }
        IpProto::Udp =&gt; {
            let udphdr: *const UdpHdr =
                ptr_at(&amp;ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
            u16::from_be(unsafe { (*udphdr).source })
        }
        _ =&gt; return Err(()),
    };
</code></pre>
<p>Then we check if the ip address is one in our list of blocked ip addresses</p>
<pre><code class="language-rust">    if unsafe { SRC<em>IP_FILTER.get(&amp;source_addr).is</em>some() } {
        info!(&amp;ctx, &quot;dropping packet ...&quot;);
        return Ok(xdp<em>action::XDP</em>DROP);
    }
</code></pre>
<p>The user space code reads a YAML config file that contains a list of IP addresses and
an instruction as to what to do to the packets coming from that address. </p>
<pre><code class="language-shell">    ---
    &quot;127.0.0.1&quot; : &quot;block&quot;
    &quot;10.0.0.1&quot;  : &quot;block&quot;
    &quot;10.0.0.2&quot;  : &quot;block&quot;
</code></pre>
<p>We will use the figment crate to parse the YAML config file into a 
hashmap that can be loaded into the eBPF map. </p>
<p>Modify the Cargo.toml file in firewall-001/Cargo.toml to include
the dependency:</p>
<pre><code class="language-cargo">    figment = { version = &quot;0.10.18&quot;, features = [&quot;yaml&quot;, &quot;env&quot;] }
</code></pre>
<p>And then add the following to the user space rust code in firewall-001/src/main.rs</p>
<pre><code class="language-rust">    use std::net::Ipv4Addr;
    use figment::{Figment, providers::{Yaml, Format}};
    ...
    #[tokio::main]
    async fn main() -&gt; Result&lt;(), anyhow::Error&gt; {
        let opt = Opt::parse();
        let config: HashMap&lt;String,String&gt; = Figment::new()
            .merge(Yaml::file(&quot;config.yaml&quot;))
            .extract()?;
</code></pre>
<p>Here we extract the config file into a <code>HashMap&lt;String,String&gt;</code>
Once we have the entries from our config file in the a HashMap 
we can load them into the hashmap created in the ebpf code. </p>
<p>This is the opposite of what we did in the Part 5 where
we data was stored in the map on the eBPF side and passed 
to the user space program. Here we load the data from user space
and pass it to the eBPF using the map.</p>
<pre><code class="language-rust">    let mut src<em>ip_filter : ayaHashMap&lt;</em>,  u32, u8&gt; =
            ayaHashMap::try<em>from( bpf.map_mut(&quot;SRC_IP</em>FILTER&quot;).unwrap())?;
    ...
        for (k, v)  in config {
            if v == &quot;block&quot; {
                let addr : Ipv4Addr  = k.parse().unwrap();
                println!(&quot;addr {:?}&quot; , addr);
                let <em> = src_ip</em>filter.insert(u32::from(addr), 1, 0);
            }
        }
</code></pre>
<p>The IP addresses get loaded into the map and are then visible in the
eBPF code running in the kernel.</p>
<p>We can use the loopback address 127.0.0.1 to test whether the firewall works
First load the eBPF program and attach it to the loopback interface</p>
<pre><code class="language-shell">    RUST_LOG=info cargo xtask run -- -i lo 
</code></pre>
<p>We can check that it is loaded using bpftool</p>
<pre><code class="language-shell">    $ sudo bpftool prog list | grep -A 5 firewall
    5118: xdp  name firewall_002  tag 64a3874abd9070d2  gpl
            loaded_at 2024-05-01T23:27:54-0700  uid 0
            xlated 7008B  jited 3759B  memlock 8192B  map_ids 1532,1534,1533,1535
</code></pre>
<p>We can use the netcat program to test it. 
In one terminal start a server listening on port 9090</p>
<pre><code class="language-shell">    nc -l 9090
</code></pre>
<p>In another terminal send data to the server:</p>
<pre><code class="language-shell">    echo &quot;the quick brown fox jumped over the lazy dog&quot; |  nc 127.0.0.1 9090
</code></pre>
<p>In the terminal running the cargo command:</p>
<pre><code class="language-shell">    2024-05-02T06:37:27Z INFO  firewall_002] received IPv4 packet
    [2024-05-02T06:37:27Z INFO  firewall_002] dropping packet ...
    ...
</code></pre>
<p>In the netcat server window there will no output showing receipt of a packet</p>