Decide once per connection.
Let the kernel carry the rest.
Gatekeep is a stateful firewall for Linux, written in C++. It reads the first packets of every new connection, checks them against its rules and blocklist, and stores the verdict in the kernel. Every packet after that is forwarded by the kernel alone.
1,451,414packets forwarded by the kernel lane
6packets that needed the engine
Measured during a 3-second, 91.8 Gbit/s transfer through the firewall, recorded in the demo.
What happens to a new connection
A client opens https://shop.test. Four moments decide where its packets go.
- In the engine
SYN arrives
The connection has no verdict yet, so the kernel queues the packet to the engine. The engine checks the rate limit and the rules: allow-web matches. It lets the packet through but keeps watching.
- In the kernel
Handshake completes
The server's reply belongs to a tracked connection, so the kernel accepts it on its own. The client's ACK goes back to the engine, which is still waiting for data.
- In the engine
First data packet
The TLS ClientHello names the site in plain text: shop.test. Not on the blocklist. Had it been evil.test, the connection would be reset here.
- In the kernel
Verdict cached
The engine marks the connection as allowed in the kernel's connection table. Every remaining packet, in both directions, is forwarded without leaving the kernel.
What the engine checks
- Addresses and ports
Rules are read top to bottom and the first match wins: allow, drop, or reject with a TCP reset. Anything no rule allows is dropped.
{"dst": "10.10.2.10", "dst_port": 23, "proto": "tcp", "reason": "policy", "rule": "reject-telnet", "src": "10.10.1.10", "verdict": "reject_flow"}- The site a connection is for
The name in a TLS ClientHello, an HTTP Host header or a DNS query is checked against a blocklist, subdomains included. TLS is recognised by its bytes, so moving to another port doesn't hide it.
{"app": "tls", "dst": "10.10.2.10", "dst_port": 8443, "host": "cdn.evil.test", "proto": "tcp", "reason": "domain_blocked", "rule": "allow-web", "src": "10.10.1.10", "verdict": "reject_flow"}- Connection floods
Each source gets a token bucket of new connections. A source that keeps exceeding it is banned in the kernel for 30 seconds, and its open connections are cut.
{"ban_seconds": 30, "dst": "10.10.2.10", "dst_port": 80, "proto": "tcp", "reason": "rate_limit_ban", "src": "10.10.1.10", "verdict": "drop_packet"}- Its own failure
If the engine stops, falls behind, or its table of connections under inspection fills up, packets are dropped. Traffic is never let through unchecked.
Why the kernel lane matters
Small packets per second through the firewall, with the engine on the fast path and with every packet sent through it.
On a 92 Gbit/s TCP stream the engine used about 0% of a CPU core with the fast path, and 80% when it saw every packet.
Show as a table
| Mode | 64-byte packets/s | Of kernel alone | Engine CPU on a TCP stream |
|---|---|---|---|
| Kernel alone (engine bypassed) | 1,445,000 | 100% | n/a |
| Gatekeep, fast path | 1,376,000 | 95% | about 0% |
| Every packet to the engine | 180,000 | 12% | 80% |
See it run
The demo page replays a real session: eleven scenarios, from an ordinary page load to a SYN flood, with every decision the engine made.
Open the demoRun it yourself
Three containers: a client, the firewall between two networks, and a server.
docker compose up -d --build
./scripts/demo.shSource and setup on GitHub