Welcome back to my series on ebpf. In the last post, I told you how TC and XDP hooks allow us to monitor incoming and outgoing packets. This week, we’re extending this to build a firewall in Java, including a small Spring-Boot-based web frontend, with hello-ebpf:
Before I start, here is a disclaimer: The details of eBPF are hard, so I could only get the filtering of incoming packets to work reliably. Can I still call it a firewall? I would say yes, but please help me filter the outgoing packets if you disagree. Also, it’s my first Spring-Boot-based application, so please don’t judge it too harshly. Lastly, we only focus on IPv4 packets, so adding support for IPv6 rules is left to the reader.
Is it fast? Probably. I didn’t do any measurements myself, but research by Cloudflare suggests that XDP is far faster at dropping packets than the standard firewall.
Libraries
As we saw in last week’s blog post and in Hello eBPF: XDP-based Packet Filter (9), the packet parsing code is rather complicated. So we don’t want to copy it into every program. For this reason, I added a library mechanism to hello-ebpf with which we can implement a packet parsing library (based on the samples from xdp-project):
@BPFInterface public interface BasePacketParser { int HTTP_PORT = 80; int HTTPS_PORT = 443; // ... @Type static class PacketInfo { public PacketDirection direction; public Protocol protocol; public IPAddress source; public IPAddress destination; public @Unsigned int destinationPort; public @Unsigned int sourcePort; public int length; } // ... @BPFFunction @AlwaysInline default boolean parsePacket2(@Unsigned int start, @Unsigned int end, Ptr<PacketInfo> info) { return parsePacket(Ptr.voidPointer(start), Ptr.voidPointer(end), info); } /** * Parse a packet and extract the source and destination IP address and the protocol * * @param start start of the packet data * @param end end of the packet data * @param info output parameter for the extracted information * @return true if the packet is an IP packet and could be parsed, false otherwise */ @BPFFunction @AlwaysInline default boolean parsePacket(Ptr<?> start, Ptr<?> end, Ptr<PacketInfo> info) { // ... if (ethType == XDPHook.ETH_P_IP) { return parseIPPacket( start.add(offset).<runtime.iphdr>cast(), end, info); } if (ethType == XDPHook.ETH_P_IPV6) { return parseIPv6Packet( start.add(offset).<runtime.ipv6hdr>cast(), end, info); } return false; } }
This library mechanism allows you to use the methods by simply implementing the interface in your BPF program, making it fairly easy to parse and filter packets (see BlockHTTP on GitHub):
@BPF(license = "GPL") public abstract class BlockHTTP2 extends BPFProgram implements XDPHook, BasePacketParser { @Override public xdp_action xdpHandlePacket(Ptr<xdp_md> packet) { PacketInfo info = new PacketInfo(); if (parsePacket2(packet.val().data, packet.val().data_end, Ptr.of(info))) { if (info.sourcePort == HTTP_PORT) { BPFJ.bpf_trace_printk("Dropping https packet\n"); return xdp_action.XDP_DROP; } } return xdp_action.XDP_PASS; } // ... }
The main advantage of implementing libraries as Java interfaces is that their properties align:
- Interfaces can’t have instance variables, so defining global variables or maps is impossible. This constraint simplifies the implementation of the library mechanism, as the Java compiler plugin has to deal with less complexity.
- A program can implement and, therefore, use multiple interfaces.
- Finding the used libraries is easy, as they are all declared initially.
We can extend this into a firewall with rules. The implementation is split into the base firewall (GitHub) and the Spring based frontend (GitHub).
Rule Data Structures
First, we define our data structures. Rules are based on the IP address and the source port:
/** A connection consists of an IP address and a port */ @Type record IPAndPort(int ip, int port) { } @Type record FirewallRule( @Unsigned int ip, /** * Ignore the low n bytes for matching, * these bytes have to be zero in the ip field */ int ignoreLowBytes, /* or -1 to match all ports */ int port) { } @Type enum FirewallAction implements Enum<FirewallAction> { ALLOW, DROP, /** No rule applies */ NONE }
The firewall rules are stored in a map:
@BPFMapDefinition(maxEntries = 1000) BPFHashMap<FirewallRule, FirewallAction> firewallRules;
Finding the matching Firewall Rule
Given a connection (IPAndPort
), how can we find the associated action? We can observe that an IP address W.X.Y.Z
is matched by the rules, where the most specific rule wins:
W.X.Y.Z
ignoring no bytesW.X.Y.0
ignoring the lowest byte- …
0.0.0.0
ignoring all bytes
We implement this directly in code:
@BPFFunction @AlwaysInline FirewallAction computeAction(Ptr<IPAndPort> info) { // for all possible ip address matching bytes for (int i = 0; i < 5; i++) { var action = computeSpecificAction(info, i); if (action != FirewallAction.NONE) { return action; } } return FirewallAction.NONE; }
The computeSpecificAction
method checks for a rule with a matching IP address, which ignores the specified lowest bytes. Each rule has a specified port, so we have to check for the two values, -1
matching every port, and the port of the connection:
// This method can be both used in the eBPF and the Java part @BPFFunction @AlwaysInline static int zeroLowBytes(int ip, int ignoreLowBytes) { return ip & (0xFFFFFFFF << (ignoreLowBytes * 8)); } @BPFFunction @AlwaysInline FirewallAction computeSpecificAction( Ptr<IPAndPort> info, int ignoreLowBytes) { int ip = info.val().ip; // first null the bytes that should be ignored int matchingAddressBytes = zeroLowBytes(ip, ignoreLowBytes); if (matchingAddressBytes == 100) { // don't ask, the code only works with this line // it's probably a compiler bug and this line adds // some memory dependencies, influencing the compiler // optimizations bpf_trace_printk("Checking rule for %d:%d\n", matchingAddressBytes, info.val().port); } var rule = new FirewallRule( matchingAddressBytes, ignoreLowBytes, info.val().port); var action = firewallRules.bpf_get(rule); if (action != null) { return action.val(); } rule = new FirewallRule( matchingAddressBytes, ignoreLowBytes, -1); action = firewallRules.bpf_get(rule); if (action != null) { return action.val(); } return FirewallAction.NONE; }
We’re querying the firewallRules
map between one and ten times for every connection. This amount of querying is quite a lot, so we cache this in the resolvedRules
map:
@BPFMapDefinition(maxEntries = 1000) BPFLRUHashMap<IPAndPort, FirewallAction> resolvedRules; @BPFFunction @AlwaysInline FirewallAction getAction(Ptr<PacketInfo> packetInfo) { IPAndPort ipAndPort = new IPAndPort( packetInfo.val().source.ipv4(), packetInfo.val().sourcePort); Ptr<FirewallAction> action = resolvedRules.bpf_get(ipAndPort); if (action != null) { return action.val(); } var newAction = computeAction(Ptr.of(ipAndPort)); resolvedRules.put(ipAndPort, newAction); return newAction; }
Firewall
With the ability to find a matching action, extending the previous XDP example into a proper firewall is pretty simple. The only two additions are code to parse firewall rules like:
// Drop all HTTP (port 80) packets from the first // IP address for google.com google.com:HTTP drop // Let all packets from the local network pass 192.168.0.0:-1/16 pass
And code that logs all dropped connections in a ring buffer:
@Type record LogEntry(IPAndPort connection, long timeInMs) { } @BPFMapDefinition(maxEntries = 1000 * 128) BPFRingBuffer<LogEntry> blockedConnections;
I spare you the details of both, but I show you the result:
> ./run.sh Firewall "google.com:HTTP drop" Rule: FirewallRule[ip=1857682062, ignoreLowBytes=0, port=80] action: DROP # run wget http://google.com in a separate shell Blocked packet from 142.250.185.110 port 80 Blocked packet from 142.250.185.110 port 80 Blocked packet from 142.250.185.110 port 80 Blocked packet from 142.250.185.110 port 80
This code is a basic firewall written directly in Java. You can find all the code on GitHub.
Spring-Boot based Frontend
Command-line tools are nice, so why write a web front end? It shows how to integrate eBPF with Spring-Boot, the most used Java web framework, using hello-ebpf, pushing data directly from the web into the kernel.
The Spring-Boot application is split into two classes: The application class FirewallSpring
which starts the Spring application and an instance of the Firewall
, and the FirewallController
class, which handles the web requests.
The controller has REST endpoints for adding rules, resetting all rules, obtaining the blocked connection log, and showing the website:
@RestController @RequestMapping("/") class FirewallController { // firewall set by FirewallSpring after // its creation static Firewall firewall; // log filled by the firewall log handler static List<LogEntry> log = new ArrayList<>(); /** Add a raw drop rule */ @PostMapping("/rawDrop") ResponseEntity<Void> rawDrop(@RequestBody FirewallRule rule) { addRule(rule, FirewallAction.DROP); return ResponseEntity.ok().build(); } /** Parse and add rule string */ @PostMapping("/add") ResponseEntity<Void> add(@RequestBody String rule) { var parsed = Firewall.parseRule(rule); addRule(parsed.rule(), parsed.action()); return ResponseEntity.ok().build(); } /** * Add the passed rule and accompanying action to the firewall. * Clears the resolvedRules cache afterwards. */ private void addRule(FirewallRule rule, FirewallAction action) { System.out.println("Adding rule: " + rule + " action: " + action); validateRule(rule); // put the rule directly in the kernel firewall.firewallRules.put(rule, action); firewall.resolvedRules.clear(); } private void validateRule(FirewallRule rule) { /* ... */} /** Drop all rules */ @PostMapping("/reset") ResponseEntity<Void> reset() { firewall.firewallRules.clear(); firewall.resolvedRules.clear(); return ResponseEntity.ok().build(); } /** Return the collected logs */ @GetMapping("/logs") ResponseEntity<List<LogEntry>> getLogs() { return ResponseEntity.ok(log); } /** * Trigger a request to the passed URL. * Quite useful for demos */ @PostMapping("/triggerRequest") ResponseEntity<Void> triggerRequest(@RequestBody String url) { // call 'wget url' in a new thread // ... return ResponseEntity.ok().build(); } /** * Return the front-end written with some JavaScript. * I spare you of any details, as I essentially told * ChatGPT how the REST API looks like and the web-page * that I wanted. The resulting web-page looks good * enough for a small demo after some tweaking. */ @GetMapping("/") @ResponseBody public String index() { return """ ... """; } }
The FirewallSpring
class creates a firewall and starts the controller, filling the log:
@SpringBootApplication(scanBasePackages = "me.bechberger.ebpf.samples") public class FirewallSpring { public static void main(String[] args) throws InterruptedException { System.setProperty("server.port", "8080"); // just to be sure System.setProperty("server.address", "0.0.0.0"); try (Firewall program = BPFProgram.load(Firewall.class)) { program.xdpAttach(); program.blockedConnections.setCallback( FirewallSpring::log); FirewallController.firewall = program; new Thread(() -> SpringApplication.run(FirewallSpring.class, args)).start(); while (true) { program.consumeAndThrow(); Thread.sleep(100); } } } /** * Log both to the command line and the log list * of Firewall controller, keep the last 1000 * log entries. */ static void log(LogEntry logEntry) { System.out.println(logEntry.timeInMs() + ": Blocked packet from " + NetworkUtil.intToIpAddress(logEntry.connection().ip()).getHostAddress() + " port " + logEntry.connection().ip()); FirewallController.log.add(logEntry); if (FirewallController.log.size() > 1000) { FirewallController.log.removeFirst(); } } }
You can find the complete code on GitHub. We can start this application via ./run.sh FirewallSpring
and get frontend that you saw at the top of this post. We can use this frontend to add a firewall rule to block all incoming HTTP traffic from google.com
:
After adding this rule, we can trigger a request to http://google.com
:
And observe the dropped packages in the auto-updated log list:
Conclusion
In this blog post, I showed you how to build a firewall in Java and directly integrate eBPF with a commonly used Java framework. This firewall application is also the main driver of my talk, Building a Lightning Fast Firewall with Java & eBPF, in which I give an introduction to eBPF and show how to use hello-ebpf in a live demo.
I’ll present this talk at conferences all over Europe: Copenhagen Developer Festival, JavaZone, InfoQ DevSummit Munich, Devoxx Belgium, Open Community Experience, and Oredev. If you have the chance to join me in any of these, come by and say hello. I’m always to talk to people who follow my hello-ebpf journey. Register for the eBPF summit on September 11th if you’re more into online conferences.
This article is part of my work in the SapMachine team at SAP, making profiling and debugging easier for everyone.