Examples · Cloud & Cybersecurity
Firewall rules you can test
A model writes the nftables rules of a web server from a plain description. Two network namespaces on your Linux VM try sixteen connections and show what the rules really do. Of twelve files from four models, only one did everything it was asked.
Advanced · 60 min
Why
A firewall that looks right is not a firewall that works. You only know by trying connections from where they will really come: from the internet, from the office, from the application server, over IPv4 and over IPv6. On a real server a mistake locks you out, or leaves a door open that nobody notices.
So you test in a lab first: two network namespaces on your VM, two separate network stacks joined by a virtual cable. The rules go into one of them, and your VM's own firewall is never touched.
What you need
A Linux virtual machine for the lab (Ubuntu 24.04), where you can use sudo, with nftables and netcat:
sudo apt install nftables netcat-openbsd
The model
Qwen3.6 35B (qwen3.6-35b), in the chat. Through the API, we sent the same request to four models, and loaded every file they wrote into the lab:
| Model | Files | Loaded | All 16 tests passed |
|---|---|---|---|
qwen3.6-35b |
6 | 4 | 1 |
gemma-4-26b |
2 | 1 | 0 |
gpt-oss-120b |
2 | 2 | 0 |
qwen3-coder-30b |
2 | 0 | 0 |
Twelve files: five did not load, four cut off IPv6, two blocked the server's connections to itself, one did everything. Whichever model you use, the test decides, not the model.
1. The request
Write /etc/nftables.conf for web01, an Ubuntu 24.04 web server with nftables. Its addresses are 203.0.113.80 and 2001:db8:80::80. Incoming traffic:
- the website, ports 80 and 443: open to everyone, over IPv4 and IPv6;
- SSH, port 22: only from the office network, 198.51.100.0/24;
- PostgreSQL, port 5432: only from the application server, 192.0.2.20;
- ping: allowed;
- everything else: dropped.
web01 must still be able to connect to the outside, for updates and DNS. Only the file, with a short comment on each rule.
On the VM, save the file from the answer as nftables.conf, in a folder of its own. Do not put it in /etc and do not load it with nft -f outside the lab: flush ruleset, at the top of ten of our twelve files, deletes every rule of the machine, those written by iptables and Docker included, and then SSH lets in only the office network, which your laptop is not on.
2. The lab
web01 is the server. outside is everyone else: with four addresses on one cable, it can play someone on the internet, a PC in the office and the application server.
| Address | |
|---|---|
| web01 | 203.0.113.80 and 2001:db8:80::80 |
| outside, as someone on the internet | 203.0.113.99 and 2001:db8:80::99 |
| outside, as a PC in the office | 198.51.100.7 |
| outside, as the application server | 192.0.2.20 |
On web01, a small program answers on the ports of SSH, the website and PostgreSQL, and on 8080, as a test server that someone forgot to switch off. Save it as listen.py:
# Accepts connections on the ports given, over IPv4 and IPv6, and closes them at once.
import socket
import sys
import threading
def serve(port):
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) # IPv4 too, on the same socket
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("::", port))
s.listen()
while True:
conn, _ = s.accept()
conn.close()
for port in sys.argv[1:]:
threading.Thread(target=serve, args=(int(port),), daemon=True).start()
threading.Event().wait()
The lab itself: save it as lab.sh, in the same folder.
#!/usr/bin/env bash
# A lab in two network namespaces, joined by a virtual cable: web01 is the server, outside is
# everyone else. Nothing here touches the firewall of your own machine.
# sudo bash lab.sh up builds the lab sudo bash lab.sh down removes it
set -e
if [ "$1" = down ]; then
ip netns pids web01 2>/dev/null | xargs -r kill
ip netns pids outside 2>/dev/null | xargs -r kill
ip netns del web01 2>/dev/null || true
ip netns del outside 2>/dev/null || true
exit 0
fi
ip netns add web01
ip netns add outside
ip link add veth-web type veth peer name veth-out
ip link set veth-web netns web01
ip link set veth-out netns outside
ip -n web01 link set lo up
ip -n web01 link set veth-web up
ip -n web01 addr add 203.0.113.80/24 dev veth-web
ip -n web01 addr add 2001:db8:80::80/64 dev veth-web nodad
# The office and the application server are reached through the same cable
ip -n web01 route add 198.51.100.0/24 dev veth-web
ip -n web01 route add 192.0.2.0/24 dev veth-web
ip -n outside link set lo up
ip -n outside link set veth-out up
ip -n outside addr add 203.0.113.99/24 dev veth-out # someone on the internet
ip -n outside addr add 198.51.100.7/32 dev veth-out # a PC in the office
ip -n outside addr add 192.0.2.20/32 dev veth-out # the application server
ip -n outside addr add 2001:db8:80::99/64 dev veth-out nodad # someone on the internet, over IPv6
# Services on web01: SSH, web, PostgreSQL, and a test server someone forgot on 8080.
# On outside, port 9000 answers web01 when web01 goes out.
ip netns exec web01 python3 listen.py 22 80 443 5432 8080 &
ip netns exec outside python3 listen.py 9000 &
sleep 1
echo "Lab ready: web01 and outside"
sudo bash lab.sh up
Lab ready: web01 and outside
3. Check the syntax
sudo ip netns exec web01 nft -c -f nftables.conf
-c checks the file without loading it. No output means no errors. Five of our twelve files stopped here, for example:
nftables.conf:22:13-20: Error: syntax error, unexpected string
ip6 protocol icmpv6 icmpv6 type echo-request accept comment "Allow IPv6 ping"
^^^^^^^^
One of the files from Gemma allowed SSH from 2001:db8:office::/64, an office network over IPv6 that nobody had mentioned, with letters that cannot be in an address, and the comment “Assuming office has IPv6 range, or omit if not applicable”.
Paste the error into the chat and ask for the whole file again. When we sent the errors back, both new files loaded, and both failed other lines of the test: one closed the website, the other cut off IPv6. Every new version goes through the whole test again.
4. Load it in the lab and test it
sudo ip netns exec web01 nft -f nftables.conf
Then the test. Every line tries one connection and compares what happens with what you asked for. Save it as test.sh:
#!/usr/bin/env bash
# Tries every connection in the table and says whether web01's firewall did what was asked.
# sudo bash test.sh
ok=0; wrong=0
ip -n web01 neigh flush all; ip -n outside neigh flush all # every run starts from scratch
try() { # try NAME WANTED NAMESPACE COMMAND...
local name=$1 wanted=$2 ns=$3; shift 3
if ip netns exec "$ns" "$@" > /dev/null 2>&1; then got=open; else got=closed; fi
if [ "$got" = "$wanted" ]; then mark=OK; ok=$((ok + 1)); else mark=WRONG; wrong=$((wrong + 1)); fi
printf "%-34s %-7s %-7s %s\n" "$name" "$wanted" "$got" "$mark"
}
printf "%-34s %-7s %s\n" connection wanted got
try "internet -> web01:80" open outside nc -z -w 2 -s 203.0.113.99 203.0.113.80 80
try "internet -> web01:443" open outside nc -z -w 2 -s 203.0.113.99 203.0.113.80 443
try "internet -> web01:22 (SSH)" closed outside nc -z -w 2 -s 203.0.113.99 203.0.113.80 22
try "internet -> web01:5432" closed outside nc -z -w 2 -s 203.0.113.99 203.0.113.80 5432
try "internet -> web01:8080" closed outside nc -z -w 2 -s 203.0.113.99 203.0.113.80 8080
try "internet -> web01 ping" open outside ping -c 1 -W 2 203.0.113.80
try "office -> web01:22 (SSH)" open outside nc -z -w 2 -s 198.51.100.7 203.0.113.80 22
try "office -> web01:5432" closed outside nc -z -w 2 -s 198.51.100.7 203.0.113.80 5432
try "app server -> web01:5432" open outside nc -z -w 2 -s 192.0.2.20 203.0.113.80 5432
try "app server -> web01:22 (SSH)" closed outside nc -z -w 2 -s 192.0.2.20 203.0.113.80 22
try "internet IPv6 -> web01:80" open outside nc -z -w 2 2001:db8:80::80 80
try "internet IPv6 -> web01:22 (SSH)" closed outside nc -z -w 2 2001:db8:80::80 22
try "internet IPv6 -> web01:5432" closed outside nc -z -w 2 2001:db8:80::80 5432
try "internet IPv6 -> web01 ping" open outside ping -6 -c 1 -W 2 2001:db8:80::80
try "web01 -> outside:9000 (going out)" open web01 nc -z -w 2 203.0.113.99 9000
try "web01 -> itself, 127.0.0.1:5432" open web01 nc -z -w 2 127.0.0.1 5432
echo "$ok as wanted, $wrong wrong"
sudo bash test.sh
It takes about 15 seconds: a closed door makes nc wait two seconds for an answer that never comes.
What you should see
The most common result among the files that loaded:
connection wanted got
internet -> web01:80 open open OK
internet -> web01:443 open open OK
internet -> web01:22 (SSH) closed closed OK
internet -> web01:5432 closed closed OK
internet -> web01:8080 closed closed OK
internet -> web01 ping open open OK
office -> web01:22 (SSH) open open OK
office -> web01:5432 closed closed OK
app server -> web01:5432 open open OK
app server -> web01:22 (SSH) closed closed OK
internet IPv6 -> web01:80 open closed WRONG
internet IPv6 -> web01:22 (SSH) closed closed OK
internet IPv6 -> web01:5432 closed closed OK
internet IPv6 -> web01 ping open closed WRONG
web01 -> outside:9000 (going out) open open OK
web01 -> itself, 127.0.0.1:5432 open open OK
14 as wanted, 2 wrong
The rules look right, they load, and IPv6 is dead: no website, no ping. Note the two IPv6 lines that say OK: they are closed, as asked, but only because nothing at all gets through over IPv6. That is why the table tries what must be open, not only what must be closed.
When IPv6 is closed
Look at what outside knows about its neighbours:
sudo ip -n outside neigh
203.0.113.80 dev veth-out lladdr 52:69:2f:48:0f:30 REACHABLE
2001:db8:80::80 dev veth-out FAILED
outside never found web01's hardware address over IPv6. IPv6 has no ARP: neighbours find each other with ICMPv6 messages, neighbour solicitation and advertisement, and these rules let through only the ICMPv6 of ping. When we sent the table back to the model, the new file still lacked them: the model saw that IPv6 was closed, not why.
Add this line next to the ping rules, load the file again and repeat the test:
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept
If the server gets its IPv6 address automatically (SLAAC), it needs the router's advertisements too: nd-router-advert.
When the server cannot reach itself
Two files passed the first fifteen lines, and failed the last one: web01 -> itself, 127.0.0.1:5432 closed. Without iif "lo" accept, the server drops its own connections to itself, and a web application that talks to PostgreSQL on 127.0.0.1 stops working. We added that line to the table after our first round, in which these two files had passed everything. A test only checks what you thought of.
A file that passes
All sixteen lines OK:
#!/usr/sbin/nft -f
# /etc/nftables.conf of web01
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# Answers to the connections web01 opens itself: updates, DNS
ct state established,related accept
ct state invalid drop
# The server's own connections to itself
iif "lo" accept
# Ping, over IPv4 and IPv6
icmp type echo-request accept
icmpv6 type echo-request accept
# IPv6 neighbour discovery: without it, nobody finds web01 over IPv6
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert } accept
# The website, for everyone
tcp dport { 80, 443 } accept
# SSH from the office only
ip saddr 198.51.100.0/24 tcp dport 22 accept
# PostgreSQL from the application server only
ip saddr 192.0.2.20 tcp dport 5432 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
5. Take the lab down
sudo bash lab.sh down
The namespaces, the virtual cable and the programs that answered in them are gone, and with them the rules you loaded.
Good to know
table inetfilters IPv4 and IPv6 with the same rules. Withtable ip, IPv6 is not filtered at all: in the lab, SSH and PostgreSQL answered the whole internet over IPv6. The IPv6 lines of the table are there for that.ct state established,related acceptlets in the answers to the connections web01 opens itself. Without it, updates and DNS stop: that is thegoing outline.- On a real server, load the rules with a second SSH session already open, as in Harden SSH with Ansible, and keep the lab: every change goes through
test.shfirst. - nftables or ufw? ufw writes rules like these for you, with simpler commands. Whatever writes them, the test is the same: the connections that must pass, and those that must not.
Exercise
- The monitoring server, 192.0.2.50, must reach port 9100 on web01. Change the rules, add
192.0.2.50/32tooutsideand9100to the ports oflisten.pyinlab.sh, and add two lines totest.sh: open from the monitoring server, closed from the internet. - The office gets an IPv6 network,
2001:db8:100::/64: SSH must work from there too. Add an address tooutside, a route onweb01as the one for 198.51.100.0/24 inlab.sh, the rule, and the lines of the test. - Remove the line with
nd-neighbor-solicitfrom the file that passes, and watch the two IPv6 lines turn WRONG. - Ask the model to explain each line of the file that passes, and check two of its explanations in
man nft.
