Skip to content
Fondazione ITS INCOM

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

Exercise

All examples