nftables multi network (home) router primer
Introduction
I have been curious about nftables
for a while, but I haven't been
able to locate any beginner-friendly tutorial like I'm used to with
other firewalls (hello there, pf). But, after hours of trying to
understand how chains and rulesets work I stumbled upon a great
resource on the nftables wiki - Classic perimetral firewall example. I
should probably finish this article with that link but here it goes!
My goal is to setup a new router for my home network. My home network
have multiple subnets that I'd like to limit and control communication
between, and I'd like to allow some external traffic as well. This is
pretty standard stuff with any firewall, but as I hadn't really
understood iptables
getting started with nftables
was tricky.
But given the structure provided by the article in the nftables
wiki
it became manageable for me. And since I don't hate myself, I'm using
the ruleset format rather than just running each and every command
like with iptables.
Complete ruleset
Here's a long example ruleset for a router/firewall with multiple subnets. I've added a few comments that I hope might be helpful, and I've used a few fancy new features like named sets and verdict maps due to reasons.
I'm intentionally using drop as my default policy everywhere. This can be tedious, but I try to keep my network somewhat secure. I don't want my IoT or $DAYJOB networks to interfere with my precious client network! (or vice versa)
I might be worth to note that I don't run DHCP or DNS on the router in
this example. If you intend to do so, allow these ports in the
incoming
chain.
I'm using a convention of function_name
for my variables. if_
is for
my interfaces, net_
is for my network definitions, port_
for ports
(used in sets) and host_
is for host definitions. You get the idea.
For chains I've tried to go with origin_target_whatever
so that
firewall_out
is what my firewall is allowed out.
# /etc/nftables.conf
flush ruleset
# replace these
define if_wan = eth0
define if_iot = eth1
define if_clients = eth2
define if_services = eth3
define net_iot = 192.168.255.0/24
define net_clients = 192.168.254.0/24
define net_services = 192.168.253.0/24
define host_server = 192.168.253.254
# Covers IPv4 and IPv6
table inet filter {
# A named set
set ports_mqtt {
type inet_service; flags interval;
elements = { 1883,8883 }
}
# Allow DNSSEC, HTTP(s) and DoT out from our firewall
set firewall_out_tcp_accepted {
type inet_service; flags interval;
elements = { 53, 80, 443, 853 }
}
# Allow plain DNS & NTP from our firewall
set firewall_out_udp_accepted {
type inet_service; flags interval;
elements = { 53, 123 }
}
# This is due to one of the quirks with netfilter (same applies
# for iptables), you have to accept established and related
# connections explicitly. Making it a separate chain like this
# will allow us to quickly jump to it.
#
# We also allow ICMP for both v4 and v6.
chain global {
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
}
chain reject_politely {
reject with icmp type port-unreachable
}
# Control what is allowed into the iot network, if any
chain iot_in {}
# ...and what is allowed out
chain iot_out {
# Accept MQTT traffic to our internal MQTT tracker
tcp dport @ports_mqtt ip daddr $host_server ip saddr $net_iot ct state new accept
}
# Control what is allowed into our services network
chain services_in {
# Allow forwarded MQTT traffic from our IOT net to our server
tcp dport @ports_mqtt ip daddr $host_server ip saddr $net_iot ct state new accept
}
# ...and control what is allowed out from our services network
chain services_out {
# Allow NTP out on the internet
udp dport 123 ip saddr $net_services ct state new accept
# Allow HTTP/HTTPS out as well, but use an anonymous set for this
tcp dport { 80, 443 } ip saddr $net_services ct state new accept
}
# Nothing accepted into our clients network
chain clients_in {}
chain clients_out {
# yolo
accept
}
# repeat this for your subnets
# Here's where some interesting things happen. This is where we
# control what is forwarded between subnets, including using the
# chains we defined previously. Our default policy is drop.
chain forward {
type filter hook forward priority 0; policy drop;
# First accept established & related traffic, by jumping to
# our global chain
jump global
# Verdict maps! This saves me _several lines_ of rules!!11
# This could have been written line for line as well, I guess.
# Map the output interface to a chain. So if traffic has been
# forwarded to this interface this is what we allow in, if
# that makes sense?
oifname vmap { $if_services : jump services_in,
$if_iot : jump iot_in,
$if_clients : jump clients_in }
# If the output interface is our external, what is allowed out
# from each subnet?
oifname $if_wan iifname vmap { $if_services : jump services_out,
$if_iot : jump iot_out,
$if_clients : jump clients_out }
}
# Control what is allowed on our firewall
chain incoming {
type filter hook input priority 0; policy drop;
jump global
iif lo accept
# Allow SSH but rate limit on our external interface
iifname $if_wan tcp dport 22 ct state new flow table ssh-ftable { ip saddr limit rate 2/minute } accept
# Allow SSH from our clients
iifname $if_clients tcp dport 22 ct state new accept
# Rejections should be nice
jump reject_politely
}
# Control what is allowed out from our firewall itself.
chain outgoing {
type filter hook output priority 100; policy drop;
jump global
# What should be allowed out from your firewall itself? If
# anything is acceptable, change the policy or just write:
# accept
# Otherwise, specify what is allowed, some examples below
udp dport @firewall_out_udp_accepted ct state new accept
tcp dport @firewall_out_tcp_accepted ct state new accept
jump reject_politely
}
}
# Finally, NAT!
table ip firewall {
chain prerouting {
type nat hook prerouting priority 0;
# Port forward tcp 80/443 to our internal webserver
iifname $if_wan tcp dport { http, https } dnat to "192.168.0.100" comment "DNAT to webserver"
}
#### POSTROUTING
chain postrouting {
type nat hook postrouting priority 100;
# Here you can specify which nets that are allowed to do NAT. For
# my own network I'm not allowing my IoT or management networks to
# reach the internet.
ip saddr $net_clients oifname $if_wan masquerade
}
}
More sets
Sometimes I want to refer to multiple interfaces or subnets in several rules, so I use more sets. The documentation is great but here are two more examples:
set if_all_clients {
type ifname; flags constant;
elements = { $if_office, $if_wifi }
}
set net_clients {
type ipv4_addr; flags interval;
elements = { $net_wifi, $net_office }
}
Concatenations
Now this is really pushing what is necessary, but I liked this feature and it has saved me several lines so… :-)
# https://wiki.nftables.org/wiki-nftables/index.php/Concatenations
set services_in_tcp_ip_port {
type ipv4_addr . ipv4_addr . inet_service;
elements = {
$host_app . $host_db . 5432,
$host_app . $host_tsdb . 8086,
$net_iot . $host_mqtt . 1883
}
}
chain services_in {
ip saddr . ip daddr . tcp dport @services_in_tcp_ip_port counter ct state new accept
}
So now we need one line in our services_in
chain to allow our app server to talk to our database server & time series database servers, and let out IoT network talk to our MQTT server. There's something nice with this, and apparently it's also pretty fast?
Conclusion
I started writing this months ago before realizing that would end up just poorly rewriting the official nftables wiki. I do hope that the content above might come in handy for someone else, tho.