TTR Networks

Intermediate · Debian as an office router: interfaces, VLANs and a firewall · section 5 of 8

The firewall and NAT

✓ Lab-tested 5 November 2026 on Debian 13.7, systemd 257, nftables 1.1.3

/etc/nftables.conf holds the whole policy. It's the router ruleset from nftables and iptables on Debian, adapted for VLANs.

The forward chain: a policy table in nftables

The forward chain starts with the usual pair: let replies to existing connections back in (ct state established,related), drop invalid packets. Then one verdict map says who may start connections to whom: it looks up the pair input interface . output interface and finds a verdict. Each line is one "yes" from the policy table: staff to the internet and to the servers, guests and servers to the internet. Any pair that isn't listed falls through to the final counter and the drop policy, including any VLAN you add later, so a new VLAN is isolated until you decide otherwise.

NAT

The nat table masquerades everything leaving through the WAN: the office's private addresses are replaced by the router's public one. The web server's log confirms it: every visitor is 203.0.113.2.

The test matrix

nft -c -f checks the file, then systemctl enable --now nftables loads it and loads it again at every boot. Then each VLAN tries everything:

  • Staff reach the internet and the server.
  • Guests reach the internet, but not the server or a staff PC.
  • Servers reach the internet, but can't start connections to staff.

The forward chain's counter shows the dropped attempts.

Protecting the router itself

The input chain is for traffic to the router, and it's stricter: replies, loopback, a few ICMP types, and SSH only from the staff VLAN. A guest can't reach SSH on its own gateway address, nor on the router's staff address: the rule matches the interface the packet arrived on, not the address it was sent to, so there's no way round it by picking another of the router's addresses. Guests can still ping their gateway, which is useful for troubleshooting.

/etc/nftables.conf on the router
#!/usr/sbin/nft -f
# /etc/nftables.conf on the office router.
# Check, then load:  nft -c -f /etc/nftables.conf && systemctl reload nftables
flush ruleset

define WAN = "eth0"

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif lo accept
    icmp type { echo-request, destination-unreachable, time-exceeded } accept
    iifname "staff" tcp dport 22 accept comment "SSH from staff only"
    counter comment "everything else to the router"
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    # Who may start connections to whom: one line per allowed pair.
    iifname . oifname vmap {
      "staff"   . $WAN      : accept,
      "staff"   . "servers" : accept,
      "guest"   . $WAN      : accept,
      "servers" . $WAN      : accept,
    }
    counter comment "every other pair is dropped"
  }
}

table ip nat {
  chain postrouting {
    type nat hook postrouting priority srcnat;
    oifname $WAN masquerade comment "the office shares the router's public address"
  }
}
  • Lines 9–17: The input chain: traffic to the router itself. Replies, loopback, some ICMP, and SSH from staff only.
  • Line 15: iifname matches the interface the packet arrived on, whichever of the router's addresses it was sent to.
  • Lines 21–22: Replies to allowed connections, and invalid packets, are dealt with first.
  • Lines 24–29: The policy table: each line is a pair (in . out) that may start connections. Every other pair falls through.
  • Line 30: Counts what the drop policy is about to drop.
  • Line 37: Everything leaving to the internet takes the router's public address.
Loading it (real output)
root@router:~# nft -c -f /etc/nftables.conf && systemctl enable --now nftables && nft list tables
Created symlink '/etc/systemd/system/sysinit.target.wants/nftables.service' → '/usr/lib/systemd/system/nftables.service'.
table inet filter
table ip nat
  • Lines 1–4: Checked first, then enabled now and at every boot: the filter and nat tables are loaded.
The test matrix (real output)
root@staff:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://10.10.30.10/
200
200

root@guest:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://10.10.30.10/; ping -c 1 -W 1 10.10.10.20 | tail -2
200
000
1 packets transmitted, 0 received, 100% packet loss, time 0ms


root@server:~# curl -s -o /dev/null -w "%{http_code}\n" -m 2 http://198.51.100.10/; ping -c 1 -W 1 10.10.10.20 | tail -2
200
1 packets transmitted, 0 received, 100% packet loss, time 0ms


root@web:~# tail -3 /tmp/web.log
203.0.113.2 - - [04/Nov/2026 21:59:31] "GET / HTTP/1.1" 200 -
203.0.113.2 - - [04/Nov/2026 21:59:31] "GET / HTTP/1.1" 200 -
203.0.113.2 - - [04/Nov/2026 21:59:34] "GET / HTTP/1.1" 200 -

root@router:~# nft list chain inet filter forward | grep -E "counter"
		counter packets 4 bytes 288 comment "every other pair is dropped"
  • Lines 1–3: Staff: the internet and the server, both 200.
  • Lines 5–8: Guest: the internet yes; the server and a staff PC no.
  • Lines 11–13: Server: the internet yes; it can't start a connection to a staff PC.
  • Lines 16–19: Every visitor arrives as 203.0.113.2: masquerade at work.
  • Lines 21–22: The drops, counted by the forward chain.
SSH to the router (real output)
root@staff:~# nc -zv -w 2 10.10.10.1 22
Connection to 10.10.10.1 22 port [tcp/ssh] succeeded!

root@guest:~# nc -zv -w 2 10.10.20.1 22; nc -zv -w 2 10.10.10.1 22; ping -c 1 -W 1 10.10.20.1 | tail -2
nc: connect to 10.10.20.1 port 22 (tcp) timed out: Operation now in progress
nc: connect to 10.10.10.1 port 22 (tcp) timed out: Operation now in progress
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.057/0.057/0.057/0.000 ms

root@router:~# nft list chain inet filter input | grep -E "SSH|everything"
		iifname "staff" tcp dport 22 accept comment "SSH from staff only"
		counter packets 4 bytes 240 comment "everything else to the router"
  • Lines 1–2: Staff may SSH to the router.
  • Lines 4–6: A guest can't, on its own gateway address or on the router's staff address.
  • Lines 7–8: Ping still works: useful when troubleshooting.

Check your understanding

You add a printers VLAN later. Without touching nftables.conf, what can the printers reach?

  • Nothing through the router: no pair includes printers, so the forward chain drops it.
  • Everything, because the router forwards by default.
  • The internet, through masquerade.
  • Whatever staff can reach.

Check your understanding

Why does a guest fail to SSH to 10.10.10.1, the router's address on the staff VLAN?

  • The SSH rule matches the interface the packet arrived on (staff), not the address it was sent to.
  • The router doesn't answer on 10.10.10.1 from other VLANs.
  • The forward chain blocks it.
  • sshd only listens on the staff address.