nftables multi network (home) router primer

Updated

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.