I believe in a zero trust defense in depth approach to security.
Every network segment has a firewall (mostly OpenBSD) that controls
ingress and egress traffic and every machine that can also runs a
local firewall that further limits ingress (and in some cases
egress) traffic. This makes everything for an attacker harder.
It limits lateral movement, complicates data exfiltration, and
in some cases foils entire classes of attacks.
For my Linux servers I have been using a custom wrapper around iptables for more than 15 years now. It leverages run-parts(8) to allow for complex rule sets to be constructed out of pieces put in place by disparate sources. This was done specifically to allow multiple Puppet modules or Ansible roles to configure their own firewall rules. Linux is starting to move away from iptables and is moving to a new toolset for interacting with the kernel firewall called nftables and my preferred distribution (Debian) started the process of migrating last release so I figured it was time I looked into stepping into the present.
firewalld
The Debian nftables wiki article
explicitly suggests firewalld as the
recommended solution so that is where I started. Initial setup was
reasonably easy, I installed the firewalld package and dropped some
XML files in /etc/firewalld
and had a simple host firewall up in
a few short minutes. The problems started when trying to translate
that into something I could manage centrally. I just don't see a
good way to provide to build up a complex rule set without mixing
XML fragments and command line invocations and I am not sure how
well behaved that would be with bi-hourly Puppet agent runs. Also,
the XML format and rule syntax feels very clunky and verbose. It
seems like this is really designed for a mobile workstation where
you can bind different zones (groups of rules) to different interfaces
and change them on the fly (say 'public' when you are out on public
WiFi and 'private' when you are at home. Similar to the Windows
Firewall zone settings). I imagine if you're using NetworkManager
and all the other Linux desktop pieces this makes a ton of sense. It
would be nice to be able to change firewall profiles based on WiFi
SSID or WiFi versus Ethernet in that case but I am trying to manage
a server firewall here.
nftables
Underpinning firewalld is the nftables interface into the Linux firewall.
The userspace command nft(8) compiles rule sets provided either
directly on the command line, via stdin, or via a set of files into
the byte code for the firewall and ships it into the kernel. This feels
very much like the iptables system with a slightly more flexible rule
grammar. Debian ships nftables in a way that you can just plop a rule set
in /etc/nftables.conf
and it will load at boot. I did a little
experimenting and it looks like by shipping fragments and using a globbed
include
statement or two in the main config I can allow Puppet and Ansible
to build up complex configurations based on the roles of the machine.
Example Rulesets
I chose an internal Icecast streaming server as a testbed for all of this as it is pretty simple. It only needs one additional port outside of the 'standard' ports open and so here is what I came up with.
iptables
As a baseline, here is a simple server running my iptables ruleset.
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:6666
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:6789
ACCEPT udp -- 192.168.42.0/28 0.0.0.0/0 udp dpt:7004
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:7080
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpts:7442:7443
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpts:7445:7447
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:8080
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:8443
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:8880
ACCEPT tcp -- 192.168.42.0/28 0.0.0.0/0 tcp dpt:8843
ACCEPT udp -- 192.168.42.0/28 0.0.0.0/0 udp dpt:3478
ACCEPT udp -- 192.168.42.0/28 0.0.0.0/0 udp spt:10001
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:6666
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:6789
ACCEPT udp -- 192.168.196.0/23 0.0.0.0/0 udp dpt:7004
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:7080
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpts:7442:7443
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpts:7445:7447
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:8080
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:8443
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:8880
ACCEPT tcp -- 192.168.196.0/23 0.0.0.0/0 tcp dpt:8843
ACCEPT udp -- 192.168.196.0/23 0.0.0.0/0 udp dpt:3478
ACCEPT udp -- 192.168.196.0/23 0.0.0.0/0 udp spt:10001
ACCEPT all -- 192.168.196.2 0.0.0.0/0
ACCEPT udp -- 0.0.0.0/0 0.0.0.0/0 udp spt:53
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp spt:53
ACCEPT udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:5353
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5353
ACCEPT udp -- 0.0.0.0/0 0.0.0.0/0 udp spt:123
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpts:1024:65535 flags:!0x17/0x02
ACCEPT all -- 127.0.0.0/8 127.0.0.0/8
ACCEPT all -f 0.0.0.0/0 0.0.0.0/0
ACCEPT icmp -- 0.0.0.0/0 0.0.0.0/0
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:5678
DROP udp -- 0.0.0.0/0 255.255.255.255 udp dpt:10001
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:57621
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpts:161:162
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:113
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:110
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp spt:67 dpt:68
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp spt:68 dpt:67
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:8763
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8763
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:2967
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5900
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:2222
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpts:137:139
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpts:137:139
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:445
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:1433
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:1434
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:1026
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:17500
DROP all -- 0.0.0.0/0 224.0.0.1
DROP 59 -- 0.0.0.0/0 0.0.0.0/0
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:631
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:1900
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:6969
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpts:27020:27050
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpts:27020:27050
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:1228
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:5060
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:585
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:50010
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:50020
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:50070
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:50075
DROP tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:50090
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:51729
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:1534
DROP udp -- 0.0.0.0/0 0.0.0.0/0 udp dpt:34196
LOG_AND_REJECT all -- 0.0.0.0/0 0.0.0.0/0
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Chain LOG_AND_REJECT (1 references)
target prot opt source destination
LOG all -- 0.0.0.0/0 0.0.0.0/0 limit: avg 1/sec burst 5 LOG flags 0 level 4 prefix "Packet log: "
REJECT all -- 0.0.0.0/0 0.0.0.0/0 reject-with icmp-port-unreachable
firewalld
This is much simpler, it lacks the DROP rules that provide cleaner logs but is essentially the minimum viable.
<?xml version="1.0" encoding="utf-8"?>
<zone target="%%REJECT%%">
<short>ub3rgeek</short>
<description>Standard server configuration for ub3rgeek.net</description>
<!-- Some people (who are wrong) think that not responding to ICMP
is 'safer'. I am not one of those people. -->
<rule family="ipv4">
<protocol value="icmp"/>
<accept/>
</rule>
<rule family="ipv6">
<protocol value="ipv6-icmp"/>
<accept/>
</rule>
<!-- You don't seem to be able to use prefix lists in ipsets so
we have to duplicate each rule for each prefix. You also cannot
have more than one service in a rule.
-->
<rule family="ipv4">
<source address="192.168.42.0/28"/>
<service name="icecast2"/>
<accept/>
</rule>
<rule family="ipv4">
<source address="192.168.42.0/28"/>
<service name="ssh"/>
<accept/>
</rule>
<rule family="ipv4">
<source address="192.168.196.0/23"/>
<service name="icecast2"/>
<accept/>
</rule>
<rule family="ipv4">
<source address="192.168.196.0/23"/>
<service name="ssh"/>
<accept/>
</rule>
<rule family="ipv6">
<source address="2606:c380:c:1::fffc/128"/>
<service name="icecast2"/>
<accept/>
</rule>
<rule family="ipv6">
<source address="2606:c380:c:1::fffc/128"/>
<service name="ssh"/>
<accept/>
</rule>
<rule family="ipv6">
<source address="2606:c380:c001:3::/64"/>
<service name="icecast2"/>
<accept/>
</rule>
<rule family="ipv6">
<source address="2606:c380:c001:3::/64"/>
<service name="ssh"/>
<accept/>
</rule>
</zone>
nftables
This is nearly the same as what would be done by the iptables rule set. It only lacks some of the extra logic for services like NFS that are accomplished by the fact that I have access to the full shell scripting environment.
define BLOCKED_TCP_PORTS = {
pop3,
auth,
netbios-ns,
netbios-dgm,
netbios-ssn,
microsoft-ds,
ms-sql-s,
ms-sql-m,
585,
2967,
5900,
8763,
27020-27050,
50010,
50020,
50070,
50075,
50090
}
define BLOCKED_UDP_PORTS = {
netbios-ns,
netbios-dgm,
netbios-ssn,
snmp,
snmptrap,
ipp,
1026,
1228,
ms-sql-s,
ms-sql-m,
1534,
1900,
2222,
sip,
5678,
6969,
8763,
10001,
17500,
20720-27050,
34196,
51729,
57621
}
define MY_V4_NETWORKS = {
192.168.42.0/28,
192.168.196.0/23
}
define MY_V6_NETWORKS = {
2606:c380:c001:3::/64,
2606:c380:c:1::fffc/128
}
table inet filter {
counter c_rejected {
comment "Rejected packets"
}
counter c_dropped {
comment "Dropped packets"
}
chain drop-no-log {
counter name c_dropped
drop
}
chain log-and-reject {
counter name c_rejected
log prefix "[nftables REJECT]: " \
limit rate 5/second \
reject
}
chain input {
type filter hook input priority 0; policy drop;
ct state vmap {
established: accept,
related: accept,
invalid: drop
} counter
ip protocol icmp counter accept
ip6 nexthdr icmpv6 counter accept
ip6 nexthdr ipv6-nonxt counter drop
iifname lo counter accept
# Add any local overrides here.
include "ruleset.input*"
# All servers are allowed a few things.
# ssh
ip saddr $MY_V4_NETWORKS tcp dport { 22 } \
counter accept comment "SSH"
ip6 saddr $MY_V6_NETWORKS tcp dport { 22 } \
counter accept comment "SSH"
# dns
tcp sport 53 counter accept comment "DNS"
udp sport 53 counter accept comment "DNS"
# mdns
tcp sport 5353 counter accept comment "mDNS"
udp sport 5353 counter accept comment "mDNS"
# ntp
ip saddr $MY_V4_NETWORKS tcp sport 123 \
counter accept comment "NTP"
ip6 saddr $MY_V6_NETWORKS tcp sport 123 \
counter accept comment "NTP"
# Accept SYNs on high ports
tcp dport 1024-65535 tcp flags syn \
counter accept
# Drop known noisy traffic so it does not get logged.
tcp dport $BLOCKED_TCP_PORTS goto drop-no-log
udp dport $BLOCKED_UDP_PORTS goto drop-no-log
ip daddr 224.0.0.1/32 goto drop-no-log
goto log-and-reject
}
chain forward {
type filter hook forward priority 0; policy drop;
goto log-and-reject
}
chain output {
type filter hook output priority 0; policy accept;
# Add any local overrides here.
include "rulset.output*"
}
}
Conclusion
It looks like I'm going to move to nftables, either as a ruleset with includes or directly using run-parts like I am today. For a server use case firewalld is just too complex. You should understand your use case (and threat profile) when making a similar choice but in the case of a server you're almost always best served by simplicity. I'm going to start converting my Ansible managed systems (simple, single purposet cloud VMs and Raspberry Pis mostly) and see how that goes. If all goes well I'll eventually migrate my more complex Puppet managed environment.
Oh, and as a parting shot, pf.conf(5) still 1000% better than even the new hotness in Linux. 🤷