RTFM · Networking
Protecting Saphira with nftables
A router rule only delivers traffic. nftables decides whether traffic reaches a local service or crosses from one network to another.

Start with policy
List interfaces and zones first: WAN, LAN, server VLAN, management VLAN, and VPN. Permit established traffic, loopback, required ICMP/ICMPv6, management from trusted sources, and only the public services you intend. IPv6 Neighbor Discovery and Path MTU messages are functional traffic, not noise to discard blindly.
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iifname lo accept
ct state established,related accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
tcp dport 22 ip saddr 192.168.30.0/24 accept
tcp dport { 80, 443 } accept
tcp dport { 25, 465, 587, 993 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
# Add explicit LAN/VPN/service-zone rules here.
}
chain output { type filter hook output priority filter; policy accept; }
}Begin with Saphira's safe example, then harden deliberately
The Saphira-supplied nftables example is intentionally safe when nftables is first enabled: its input, forward, and output chain policies begin as accept. It contains commented examples for an IPv4 routed switch, HTTP/HTTPS/DNS/SSH DNAT, IPv4 masquerading, temporary IPv4/IPv6 block sets, ICMPv6 handling, and IPVS. This prevents an installation from unexpectedly losing its network on first enablement. It is a starting configuration, not a finished public-edge policy.
1. Read the names at the top
Set the WAN interface, routed-switch interface, networks, and service host addresses to match the topology you drew. Interface names are operational values; prove them with ip link before editing a rule.
2. Uncomment only the forwarding/NAT path you need
For example, an IPv4 web forward requires both a prerouting DNAT rule and an allowed matching forward path. DNS requires both UDP and TCP port 53. Do not enable the SSH example merely because it is there.
3. Retain connection tracking and IPv6 control traffic
Keep loopback, established/related, invalid-drop, and the relevant ICMP/ICMPv6 provisions. IPv6 Router Advertisements, Neighbour Discovery, and Packet Too Big are networking functions.
4. Only then change the chain policy
After the required accept rules exist and are tested from console-safe paths, change the relevant policy from accept to drop. A public edge commonly needs input and forward default-drop, while an application host may not route at all.
# The Saphira example's temporary block sets have timeout semantics.
# Add a short-lived block while investigating a hostile source.
nft add element inet security badip4 '{ 198.51.100.44 timeout 24h }'
nft add element inet security badip6 '{ 2001:db8:bad::44 timeout 24h }'
# Inspect the active set and its counters.
nft list table inet security
# Validate the complete candidate before applying it.
nft -c -f /etc/nftables.confA temporary source block is an incident-control tool, not a replacement for fixing an exposed service, weak authentication, an over-broad forward rule, or a missing rate limit. Record why it was added and let its timeout expire unless there is a documented reason to renew it.
Persist and operate it
Validate a ruleset before loading it. Keep a recovery console or out-of-band path when changing a remote firewall. Saphira's default service model is OpenRC; Saphira-D may use systemd. Use the service names actually installed on the machine.
# Parse the exact file without applying it.
nft -c -f /etc/nftables.conf
# OpenRC
rc-service nftables start
rc-update add nftables default
rc-service nftables reload
# On a systemd-selected installation, first prove the unit exists.
systemctl status nftables.service
systemctl enable --now nftables.service
systemctl reload nftables.service
journalctl -u nftables.serviceHost traffic, routed traffic, and NAT are three different decisions
The input chain controls packets addressed to Saphira itself: SSH to the gateway, HAProxy on the edge, or a DNS daemon. The forward chain controls packets passing through Saphira between networks: a VPN client reaching a server VLAN, or a LAN host going to the Internet. NAT changes addresses or ports; it does not grant permission. A port-forwarded packet still needs a forward rule at a routing firewall or an input rule if the service lives on that host.
Internet ──> Saphira HAProxy
│
└── input chain: is TCP/443 to this host allowed?
VPN client ──> Saphira gateway ──> server VLAN
│
└── forward chain: is wg0 → servergw0 TCP/22 allowed?
IPv4 source NAT/masquerade
│
└── changes the return address; it is not an allow rule.table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iifname lo accept
ct state established,related accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
iifname "wg0" ip saddr 10.44.0.0/24 tcp dport 22 accept
iifname "internet0" tcp dport { 25, 80, 443, 465, 587, 993 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
iifname "wg0" oifname "servergw0" ip daddr 192.168.20.0/24 tcp dport { 22, 443 } accept
iifname "wg0" oifname "servergw0" ip6 daddr 2a02:8012:bc57:fead::/64 tcp dport { 22, 443 } accept
}
chain output { type filter hook output priority filter; policy accept; }
}This is a teaching policy, not a file to paste over an existing firewall. Adapt interface names, source networks, service ports, and any existing NAT table. In particular, the staged /etc/nftables.conf begins from a permissive safe baseline; adopting default-drop is an intentional production policy change that requires console recovery access.
Prove, count, diagnose, and recover
Firewall diagnosis is fastest when you follow one attempted connection through the boundary that owns it. Verify the process listens, then check whether packets arrive, then inspect the relevant chain counter, then test the return path. Do this for IPv4 and IPv6 independently. A browser's generic error does not identify which boundary denied the packet.
# 1. Is the service listening on the expected local address and port?
ss -lntup
# 2. Did a test packet reach the right interface?
tcpdump -ni internet0 'tcp port 443'
tcpdump -ni wg0 'tcp port 22'
# 3. Inspect the active policy and counters.
nft list ruleset
nft -a list chain inet filter input
nft -a list chain inet filter forward
# 4. Run one allowed and one intentionally denied test from the correct network.
nc -vz edge.example.test 443
nc -vz edge.example.test 221. Keep recovery out of band
Before a remote firewall change, have a provider console, physical console, or already-functioning management VPN reachable by a separate path.
2. Validate, then use a timed change window
Run nft -c -f on the candidate file. Apply in a controlled window and keep the prior known-good file available for a console rollback.
3. Check existing connections and new connections
Established traffic can continue because conntrack permits it even if new traffic is now denied. Test a new connection from the intended source.
Do not solve a failed IPv6 connection by disabling all filtering or by dropping all ICMPv6. Preserve the ICMPv6 types required for neighbour discovery, Router Advertisements where appropriate, and path MTU discovery; then limit service ports and source zones precisely.
Edge-router pattern: named zones, forwarding, and return traffic
A real edge router normally has more than a WAN and one LAN: server, data, DNS, guest, management, virtual-machine, and VPN networks may all be routed through it. Name those zones once, then write narrow rules from source zone to destination zone and service. This is easier to audit than repeating anonymous addresses throughout the ruleset. The example below uses documentation values and deliberately shows separate allow, deny, and NAT decisions.
#!/usr/sbin/nft -f
flush ruleset
define WAN = "ppp0"
define INSIDE = { "servergw0", "datagw0", "ns1gw0", "wg0" }
define SERVER4 = 192.0.2.50
define PUBLIC4 = 203.0.113.25
table inet filter {
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# PPPoE/VPN encapsulation can make the usable path smaller.
tcp flags syn tcp option maxseg size set rt mtu
# Public web reaches only the web host.
iifname $WAN oifname "servergw0" ip daddr $SERVER4 tcp dport { 80, 443 } accept
# Explicitly prevent public management access to the server zone.
iifname $WAN oifname "servergw0" tcp dport { 22, 3306, 9100 } reject with icmpx type admin-prohibited
# A management VPN can reach selected server administration ports.
iifname "wg0" oifname "servergw0" ip saddr 10.44.0.0/24 ip daddr $SERVER4 tcp dport { 22, 443 } accept
# Internal zones may leave through the edge only where their policy permits.
iifname $INSIDE oifname $WAN accept
}
}
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname $WAN ip daddr $PUBLIC4 tcp dport { 80, 443 } dnat to $SERVER4
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname $WAN ip saddr 192.0.2.0/24 snat to $PUBLIC4
}
}DNAT changes a public destination into the internal server address. The forward rule then authorises that new path. SNAT gives private IPv4 clients a public source address for replies from the Internet. Conntrack lets the return packets back through because they are established. IPv6 normally needs the filter portion but not these IPv4 NAT translations: it uses globally routed addresses and an explicit forward/input policy.
| Symptom | Likely missing or wrong boundary | First evidence |
|---|---|---|
| Public IPv4 TCP/443 times out | Router DNAT, forward chain, server listener, or host firewall | tcpdump on WAN and server interface; nft counters; ss -lntp on the server. |
| Public IPv6 TCP/443 times out while IPv4 works | IPv6 route, edge forward policy, or host IPv6 policy—not IPv4 DNAT | ip -6 route get, tcpdump ip6, nft IPv6 rule/counter. |
| Public connection reaches backend but replies disappear | Missing SNAT/hairpin translation or an asymmetric return route | conntrack state, server ip route get to client, postrouting rule counters. |
| Small VPN packets work but HTTPS stalls | Path MTU/MSS through PPPoE or tunnel | tracepath, SYN MSS capture, and the router's rt mtu clamp. |
Hairpin NAT is a separate convenience: internal clients use the public IPv4 name/address and the router translates it back to an internal server. It needs carefully scoped DNAT and source NAT so the server replies through the router. Prefer internal DNS that returns the private service address where that is easier to maintain; document whichever choice you make.
Test the rule, not just the file
A successful nftables syntax check proves only that the file parses. Check listening sockets, counters, logs, and a connection from the correct network. Test IPv4 and IPv6 separately and test a denied port as well as an allowed one.