firewalld, nftables and iptables
Open, forward, NAT, rate-limit and log traffic on Linux with firewalld zones and rich rules, native nftables rulesets, and iptables translation, without breaking containers.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Is firewalld running, which zones are active | firewall-cmd --state; firewall-cmd --get-active-zones |
| Everything in the default zone | firewall-cmd --list-all |
| Open a service now and permanently | firewall-cmd --add-service=https --permanent && firewall-cmd --reload |
| Open a port | firewall-cmd --add-port=8443/tcp --permanent |
| Remove it | firewall-cmd --remove-port=8443/tcp --permanent |
| Apply permanent config | firewall-cmd --reload |
| Copy runtime to permanent | firewall-cmd --runtime-to-permanent |
| Allow SSH from one subnet only | firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept' --permanent |
| Put an interface in a zone | firewall-cmd --zone=internal --change-interface=eth1 --permanent |
| NAT for a private network | firewall-cmd --zone=public --add-masquerade --permanent |
| Forward a port | firewall-cmd --add-forward-port=port=80:proto=tcp:toaddr=192.0.2.10:toport=8080 --permanent |
| Log denied packets | firewall-cmd --set-log-denied=all |
| The nftables rules firewalld generated | nft list ruleset |
| One nftables table | nft list table inet filter |
| Load an nftables file atomically | nft -f /etc/nftables/main.nft |
| Check a file without loading | nft -c -f /etc/nftables/main.nft |
| Flush every nftables rule (drops all filtering) | nft flush ruleset |
| Counters on rules | nft list ruleset -a (handles), nft list chain inet filter input |
| Translate an iptables rule | iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT |
| Legacy view of the rules | iptables -L -n -v --line-numbers |
| Which ports are listening | ss -tulpn |
| Watch what a rule matches | nft monitor trace with a meta nftrace set 1 rule |
Commands assume firewalld 2.x on Fedora 42 and 1.3 on RHEL 9, nftables 1.1 and iptables-nft 1.8.x, all of which program the same kernel nf_tables subsystem. References: the firewalld documentation, the nftables wiki and the Red Hat networking guide.
One kernel, three front ends#
Since RHEL 8 and Fedora 32 every packet filter on the box ends up as nf_tables rules in the kernel. firewalld is a daemon with a zone model that writes nftables rules in its own tables (firewalld in the inet, ip and ip6 families). nft is the native command that writes rules directly. iptables on these systems is iptables-nft, a compatibility binary that translates the old syntax into nf_tables rules in tables named filter, nat and mangle of the ip family. Docker and libvirt write their own rules through iptables-nft. Podman uses nftables or iptables-nft through netavark.
All of these rule sets are evaluated. A packet accepted by firewalld can still be dropped by a rule in another table, and vice versa: a drop in any base chain wins, and accept in one table only means “continue to the next table at the same hook”. That is the source of most confusion when firewalld and Docker coexist. nft list ruleset is the one place that shows the whole truth.
Pick one front end per purpose and do not mix them for the same job. On a workstation or a server with a simple policy, firewalld. On a router, a container host with custom NAT, or anywhere a reviewer wants to read one file, a native nftables ruleset with firewalld disabled. Legacy iptables only to read or translate what old tooling left behind.
firewalld#
firewalld assigns every interface and every source range to a zone, and each zone has a policy plus a set of allowed services, ports, rich rules, masquerade and forward-port settings. Traffic arriving on an interface is evaluated by that interface’s zone; if the source matches a zone’s source range that zone takes precedence over the interface zone. Runtime configuration lives in memory and is lost at restart; --permanent writes XML under /etc/firewalld/ and takes effect after --reload.
firewall-cmd --state # running
firewall-cmd --get-default-zone # public on a fresh install
firewall-cmd --get-active-zones # zones with interfaces or sources bound
firewall-cmd --get-zones # block dmz drop external home internal nm-shared public trusted work
firewall-cmd --list-all # default zone in full
firewall-cmd --list-all --zone=internal
firewall-cmd --list-all-zones # every zone; long
firewall-cmd --get-zone-of-interface=eth0
firewall-cmd --get-services # ~200 predefined services: http https ssh dns nfs samba ...
firewall-cmd --info-service=https # ports the service maps to
firewall-cmd --permanent --list-all # what is on disk, versus runtimeZones from most to least permissive: trusted accepts everything; home, internal, work accept a few services (ssh, mdns, samba-client, dhcpv6-client); public (default) accepts ssh and dhcpv6-client; external adds masquerade; dmz accepts ssh only; block rejects with ICMP; drop drops silently. The zone’s target decides what happens to traffic no rule matched: default (reject for most zones, accept for trusted), ACCEPT, REJECT or DROP.
firewall-cmd --set-default-zone=drop # new interfaces land here
firewall-cmd --zone=internal --change-interface=eth1 --permanent # NetworkManager-managed interfaces: also nmcli con mod eth1 connection.zone internal
firewall-cmd --zone=trusted --add-source=192.0.2.0/24 --permanent # source-based zone: this range is trusted on any interface
firewall-cmd --zone=public --add-service=https --permanent
firewall-cmd --zone=public --add-service={http,https} --permanent
firewall-cmd --zone=public --add-port=8443/tcp --permanent
firewall-cmd --zone=public --add-port=60000-61000/udp --permanent # port range
firewall-cmd --zone=public --remove-service=cockpit --permanent
firewall-cmd --zone=public --remove-service=ssh --permanent # locks you out if this is the interface you came in on
firewall-cmd --reload # apply permanent; drops runtime-only changes
firewall-cmd --runtime-to-permanent # save what you tested at runtime
firewall-cmd --add-port=8080/tcp --timeout=300 # runtime only, removed after 5 minutes: safe for testing remotely
firewall-cmd --zone=public --set-target=DROP --permanent # silent drop instead of reject for unmatched traffic
firewall-cmd --panic-on # drops every packet in and out, including your SSH session; --panic-offAdding a service without --permanent changes runtime only; adding with --permanent changes disk only. Do both, or add at runtime, test, then --runtime-to-permanent. Never --reload after untested permanent changes to a remote host without a --timeout fallback or a second session.
Custom services are XML files in /etc/firewalld/services/:
firewall-cmd --permanent --new-service=my-app
firewall-cmd --permanent --service=my-app --set-description='my-app API'
firewall-cmd --permanent --service=my-app --add-port=8080/tcp --add-port=8443/tcp
firewall-cmd --reload && firewall-cmd --zone=public --add-service=my-app --permanent && firewall-cmd --reloadRich rules#
Rich rules express what zones and services cannot: a source restriction on one service, logging, rate limits, rejects with a specific ICMP type. They are evaluated before the zone’s plain services and ports.
# SSH only from the management subnet, everything else to port 22 dropped by the zone target
firewall-cmd --zone=public --remove-service=ssh --permanent
firewall-cmd --zone=public --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept' --permanent
# Block one host entirely
firewall-cmd --add-rich-rule='rule family=ipv4 source address=203.0.113.7 drop' --permanent
# Reject with a proper ICMP message instead of silently dropping
firewall-cmd --add-rich-rule='rule family=ipv4 source address=198.51.100.0/24 reject type=icmp-admin-prohibited' --permanent
# Rate-limit new SSH connections and log the ones that get through
firewall-cmd --add-rich-rule='rule service name=ssh log prefix="ssh " level=info limit value=5/m accept' --permanent
# Allow a port only from one address, logging every hit
firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.10 port port=5432 protocol=tcp log prefix="pg " accept' --permanent
# Forward a port only for one source
firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 forward-port port=8080 protocol=tcp to-port=80 to-addr=10.0.0.5' --permanent
# Masquerade only for one source range
firewall-cmd --add-rich-rule='rule family=ipv4 source address=10.0.0.0/24 masquerade' --permanent
firewall-cmd --list-rich-rules
firewall-cmd --remove-rich-rule='rule family=ipv4 source address=203.0.113.7 drop' --permanent # exact text to removeRule elements in order: rule [family=ipv4|ipv6] [priority=N], then one source or destination, then one of service, port, protocol, icmp-block, icmp-type, masquerade, forward-port or source-port, then optional log and audit, then an action accept, reject, drop or mark. priority (firewalld 0.7+) runs from -32768 to 32767, lower first; without it rules are ordered by action (log, deny, allow).
Masquerade and port forwarding#
Masquerade is source NAT for traffic leaving a zone. Forward-port rewrites the destination of inbound traffic. Both require IP forwarding, which firewalld enables when masquerade is turned on.
firewall-cmd --zone=public --add-masquerade --permanent # VMs or containers on an internal zone reach the internet via this host
firewall-cmd --zone=public --add-forward-port=port=443:proto=tcp:toport=8443 --permanent # local redirect
firewall-cmd --zone=public --add-forward-port=port=80:proto=tcp:toaddr=192.0.2.10:toport=8080 --permanent # to another host; needs masquerade or a policy
firewall-cmd --zone=public --list-forward-ports
sysctl net.ipv4.ip_forward # 1 after masquerade; firewalld sets it, no sysctl.d entry neededPolicies#
Zones govern traffic to and from the host. Policies (firewalld 0.9+, RHEL 9) govern traffic forwarded between zones, which is what a router or a container host needs. The built-in allow-host-ipv6 policy exists on every install; a policy with --set-target=CONTINUE and explicit rules is the modern replacement for putting --add-masquerade on external and trusting the internal zone.
firewall-cmd --permanent --new-policy=lan-to-wan
firewall-cmd --permanent --policy=lan-to-wan --add-ingress-zone=internal
firewall-cmd --permanent --policy=lan-to-wan --add-egress-zone=public
firewall-cmd --permanent --policy=lan-to-wan --set-target=ACCEPT
firewall-cmd --permanent --policy=lan-to-wan --add-masquerade # or --add-masquerade on the egress zone
firewall-cmd --permanent --new-policy=wan-to-dmz
firewall-cmd --permanent --policy=wan-to-dmz --add-ingress-zone=public --add-egress-zone=dmz
firewall-cmd --permanent --policy=wan-to-dmz --add-rich-rule='rule family=ipv4 destination address=10.0.1.10 port port=443 protocol=tcp accept'
firewall-cmd --reload
firewall-cmd --info-policy=lan-to-wanA zone with forward enabled (--add-forward, default on since 1.0) allows traffic between interfaces in the same zone. Anything crossing zones needs a policy.
Logging denied packets#
firewall-cmd --set-log-denied=all # off, all, unicast, broadcast, multicast; runtime and permanent at once
firewall-cmd --get-log-denied
journalctl -k -g 'FINAL_REJECT|FINAL_DROP' -f # firewalld prefixes: FINAL_REJECT for reject zones, FINAL_DROP for dropEach log line names the interface, source and destination address, protocol and ports: IN=eth0 OUT= SRC=203.0.113.7 DST=192.0.2.1 ... PROTO=TCP SPT=51234 DPT=23. Turn it off after debugging on a busy host; every unsolicited packet becomes a journal line.
Direct rules and the nftables backend#
--direct rules insert raw iptables syntax and are deprecated; policies and rich rules cover what they were used for. firewalld’s own rules are in nft list table inet firewalld. Adding rules with nft to firewalld’s tables is undone at every reload; put your own rules in your own table, and keep in mind that both are evaluated.
nftables#
nftables replaces iptables, ip6tables, arptables and ebtables with one syntax and one kernel API. A ruleset is a set of tables; a table belongs to a family (ip, ip6, inet for both, arp, bridge, netdev) and holds chains, sets, maps and counters. A base chain hooks into the network stack at a point (prerouting, input, forward, output, postrouting) with a priority and a default policy; a regular chain runs only when jumped to. Rules are evaluated in order within a chain, and the first terminating verdict (accept, drop, reject, jump, goto, return) ends evaluation of that chain.
Everything is atomic when loaded from a file with nft -f: the kernel swaps the whole ruleset in one transaction, so a syntax error leaves the old rules in place and there is no half-loaded window. Sets replace long lists of near-identical rules and are updated without touching the rules that reference them.
nft list ruleset # everything, in the syntax you would write it in
nft list tables # table names and families
nft list table inet filter
nft list chain inet filter input
nft list ruleset -a # with rule handles, needed for delete and insert-at
nft list set inet filter admin_hosts
nft -c -f /etc/nftables/main.nft # -c: check syntax and semantics, load nothing
nft -f /etc/nftables/main.nft # load atomically; the file usually starts with flush ruleset
nft add rule inet filter input tcp dport 8080 accept # append to a chain at runtime
nft insert rule inet filter input position 0 tcp dport 8080 accept # at the top
nft add rule inet filter input handle 12 tcp dport 8080 accept # after handle 12
nft delete rule inet filter input handle 14
nft add element inet filter admin_hosts { 192.0.2.10, 192.0.2.11 } # extend a set live
nft delete element inet filter admin_hosts { 192.0.2.11 }
nft flush chain inet filter input # empty a chain (the base chain's policy then applies to everything)
nft flush ruleset # DESTRUCTIVE to filtering: removes every table including firewalld's and Docker's
nft list ruleset > /etc/nftables/backup-$(date +%F).nft # dump in loadable form
nft -j list ruleset | jq # JSON
nft monitor # print ruleset changes as they happenPersistence on Fedora and RHEL: nftables.service runs nft -f /etc/sysconfig/nftables.conf, which by default includes files from /etc/nftables/. Put the ruleset in /etc/nftables/main.nft, reference it from /etc/sysconfig/nftables.conf with include "/etc/nftables/main.nft", and systemctl enable --now nftables. Disable firewalld first if the nftables file is meant to be the full policy; running both means two rulesets are evaluated.
A complete host ruleset#
A stateful ruleset for a server that accepts SSH from a management network, HTTP and HTTPS from anywhere, rate-limits new SSH connections, drops everything else and logs what it drops.
#!/usr/sbin/nft -f
# /etc/nftables/main.nft
flush ruleset
define MGMT_NET6 = { 2001:db8:1::/64 }
table inet filter {
set admin_hosts {
type ipv4_addr
flags interval # allows CIDR ranges and ranges like 192.0.2.1-192.0.2.20
elements = { 192.0.2.0/24 }
}
set ssh_meter {
type ipv4_addr
flags dynamic, timeout # per-source rate counters, created on demand
timeout 1m
}
set blocklist {
type ipv4_addr
flags dynamic, timeout # elements expire; filled by the ssh_ratelimit chain below
timeout 1h
}
chain input {
type filter hook input priority filter; policy drop;
iif lo accept # loopback
ct state established,related accept # replies to our own connections and related ICMP
ct state invalid drop # packets no connection tracking entry explains
ip saddr @blocklist drop # anyone the rate limiter caught
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } limit rate 10/second accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept # IPv6 does not work without ND
tcp dport 22 ip saddr @admin_hosts ct state new jump ssh_ratelimit
tcp dport 22 ip saddr @admin_hosts accept
tcp dport 22 ip6 saddr $MGMT_NET6 accept
tcp dport { 80, 443 } accept
udp dport 443 accept # HTTP/3
meta l4proto { tcp, udp } th dport 33434-33534 reject with icmpx type port-unreachable # traceroute answers
limit rate 5/second log prefix "nft-input-drop " flags all counter drop # what the policy would drop; rate-limited so a flood cannot fill the journal
}
chain ssh_ratelimit {
# more than 4 new connections in a minute from one address adds it to the blocklist for an hour
add @ssh_meter { ip saddr limit rate over 4/minute burst 4 packets } add @blocklist { ip saddr } log prefix "nft-ssh-ratelimit " drop
return
}
chain forward {
type filter hook forward priority filter; policy drop;
# this host does not route; container and VM forwarding is added in the nat example below
}
chain output {
type filter hook output priority filter; policy accept;
}
}Notes on the constructs. ct state established,related accept near the top is what makes the ruleset stateful and cheap: only the first packet of a connection is evaluated by the rest of the chain. policy drop on input and forward with policy accept on output is the usual server posture. flags interval on a set permits CIDR elements; without it every element must be a single address. add @ssh_meter { ip saddr limit rate over ... } creates a per-source rate counter in a dynamic set and matches only when that source is over the rate, and the following add @blocklist { ip saddr } records the offender with the set’s default timeout; together they are the native replacement for fail2ban-style banning. inet tables see both IPv4 and IPv6, and ip saddr or ip6 saddr matches only the relevant family, so rules that mention neither apply to both. th dport matches the transport header port regardless of protocol. reject with icmpx type port-unreachable sends the correct ICMP or ICMPv6 message for the packet’s family. Anonymous sets in braces ({ 80, 443 }) are compiled into a single lookup.
Priorities: base chains at the same hook run in order of numeric priority, lowest first. filter is 0, mangle is -150, dstnat is -100, srcnat is 100, raw is -300, security is 50. firewalld and Docker’s iptables-nft tables also hook input and forward at priority 0; that is why a drop in any of them is final and an accept in one lets the next table decide.
NAT for VMs and containers#
A host with a bridge br0 for VMs on 10.0.0.0/24, masquerading out of eth0, forwarding port 443 to one VM, and a forward chain that lets the VMs out but not in.
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" tcp dport 443 dnat ip to 10.0.0.10:8443 # inbound 443 to the web VM
iifname "eth0" tcp dport 2222 dnat ip to 10.0.0.11:22 # SSH to the bastion VM on an alternate port
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.0.0.0/24 oifname "eth0" masquerade # VMs share the host's public address
ip saddr 10.0.0.0/24 ip daddr 10.0.0.10 tcp dport 8443 masquerade # hairpin: VMs reaching the public port get replies via the host
}
}
table inet filter {
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "br0" oifname "eth0" accept # VMs to the internet
iifname "eth0" oifname "br0" ct status dnat accept # only inbound traffic that a dnat rule chose
iifname "br0" oifname "br0" accept # VM to VM on the bridge (or drop for isolation)
log prefix "nft-forward-drop " limit rate 5/second counter drop
}
}ct status dnat matches only connections a DNAT rule rewrote, which is tighter than opening tcp dport 8443 on the forward chain. sysctl -w net.ipv4.ip_forward=1 (and net.ipv6.conf.all.forwarding=1) must be set separately and persisted in /etc/sysctl.d/; nftables does not enable forwarding for you. For bridges, net.bridge.bridge-nf-call-iptables decides whether bridged traffic between VMs on the same bridge is even seen by the inet forward chain (Docker and libvirt set it to 1); when it is 0 the br0 to br0 rule is irrelevant.
Maps turn repetitive DNAT rules into one lookup:
table inet nat {
map port_forwards {
type inet_service : ipv4_addr . inet_service
elements = { 443 : 10.0.0.10 . 8443, 2222 : 10.0.0.11 . 22, 8080 : 10.0.0.12 . 80 }
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" dnat ip to tcp dport map @port_forwards
}
}Sets, verdict maps and counters#
nft add set inet filter countries { type ipv4_addr\; flags interval\; }
nft add element inet filter countries { 198.51.100.0/24, 203.0.113.0/24 }
nft add rule inet filter input ip saddr @countries drop
# verdict map: one rule, different action per port
nft add rule inet filter input tcp dport vmap { 22 : jump ssh_chain, 80 : accept, 443 : accept, 3306 : drop }
# per-interface dispatch
nft add rule inet filter input iif vmap { "lo" : accept, "eth0" : jump wan_input, "br0" : jump lan_input }
# named counters that survive rule edits
nft add counter inet filter ssh_accepted
nft add rule inet filter input tcp dport 22 counter name ssh_accepted accept
nft list counters
# concatenations: match address and port together
nft add set inet filter allowed { type ipv4_addr . inet_service\; }
nft add element inet filter allowed { 192.0.2.10 . 5432, 192.0.2.11 . 5432 }
nft add rule inet filter input ip saddr . tcp dport @allowed accept
# reset counters
nft reset counters table inet filterSet types: ipv4_addr, ipv6_addr, ether_addr, inet_proto, inet_service (port), mark, ifname. Flags: interval for ranges and prefixes, timeout for expiring elements, dynamic for elements added from rules, constant for read-only sets the kernel can optimise.
Tracing a packet#
nftrace marks packets so nft monitor trace prints every rule they hit in every table, which is the fastest way to find out which rule (and whose) is dropping something.
nft insert rule inet filter input position 0 tcp dport 8443 meta nftrace set 1 # mark matching packets at the very top
nft monitor trace # in another terminal, then send a test packet
nft delete rule inet filter input handle N # remove the trace rule afterwards; find N with nft list ruleset -aOutput shows trace id ... inet filter input packet: ..., then rule ... (verdict accept) lines per rule, then policy drop or the verdict that ended it, per table. A verdict drop in ip filter DOCKER-USER while your own table said accept is the classic result.
iptables and translation#
iptables on a modern system is iptables-nft; iptables -V prints (nf_tables). It still works, still uses the filter, nat and mangle table names in the ip family, and is what Docker, libvirt and many installers speak. iptables-legacy uses the old x_tables kernel modules, and rules in the two backends do not see each other, so a system with both installed can have rules in three places. update-alternatives --display iptables (Debian) or alternatives --display iptables (RHEL) shows which one the iptables name resolves to.
iptables -L -n -v --line-numbers # filter table, numeric, counters
iptables -t nat -L -n -v # nat table
iptables -S # rules as add commands
iptables-save > /root/iptables-$(date +%F).rules # dump all tables in restorable form
iptables-restore < /root/iptables.rules # atomic load
iptables -A INPUT -p tcp --dport 22 -s 192.0.2.0/24 -j ACCEPT
iptables -I INPUT 1 -i lo -j ACCEPT # insert at position 1
iptables -D INPUT 3 # delete rule 3
iptables -P INPUT DROP # default policy
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.10:8443
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m limit --limit 4/min --limit-burst 4 -j ACCEPT
iptables -A INPUT -j LOG --log-prefix 'ipt-drop ' --log-level 4iptables-translate and iptables-restore-translate print the nftables equivalent of a rule or a whole saved ruleset. The output is a starting point: it lands in per-table ip and ip6 tables rather than one inet table, and hand-merging into a single file is worth the effort.
iptables-translate -A INPUT -p tcp --dport 22 -s 192.0.2.0/24 -j ACCEPT
# nft add rule ip filter INPUT ip saddr 192.0.2.0/24 tcp dport 22 counter accept
iptables-translate -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
# nft add rule ip nat POSTROUTING oifname "eth0" ip saddr 10.0.0.0/24 counter masquerade
iptables-restore-translate -f /root/iptables.rules > /etc/nftables/translated.nft
nft -c -f /etc/nftables/translated.nft| iptables | nftables |
|---|---|
-A INPUT | add rule inet filter input |
-I INPUT 1 | insert rule inet filter input |
-p tcp --dport 22 | tcp dport 22 |
-s 192.0.2.0/24 | ip saddr 192.0.2.0/24 |
-i eth0 / -o eth0 | iifname "eth0" / oifname "eth0" (iif for an index, faster but breaks if the interface is recreated) |
-m conntrack --ctstate ESTABLISHED,RELATED | ct state established,related |
-m multiport --dports 80,443 | tcp dport { 80, 443 } |
-m set --match-set x src | ip saddr @x |
-m limit --limit 4/min | limit rate 4/minute |
-j LOG --log-prefix "x " | log prefix "x " |
-j REJECT --reject-with icmp-port-unreachable | reject with icmp type port-unreachable |
-j MASQUERADE | masquerade |
-j DNAT --to 10.0.0.10:8443 | dnat ip to 10.0.0.10:8443 |
-j SNAT --to 192.0.2.1 | snat ip to 192.0.2.1 |
-j REDIRECT --to-ports 8080 | redirect to :8080 |
-m mark --mark 1 / -j MARK --set-mark 1 | meta mark 1 / meta mark set 1 |
-m comment --comment "x" | comment "x" |
-P INPUT DROP | policy drop in the chain definition |
iptables-save / iptables-restore | nft list ruleset / nft -f |
Common patterns#
SSH from one subnet only, with the rest rejected so legitimate clients fail fast:
# firewalld
firewall-cmd --permanent --zone=public --remove-service=ssh
firewall-cmd --permanent --zone=public --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept'
firewall-cmd --permanent --zone=public --add-rich-rule='rule service name=ssh reject'
firewall-cmd --reload
# nftables
nft add rule inet filter input ip saddr 192.0.2.0/24 tcp dport 22 accept
nft add rule inet filter input tcp dport 22 reject with tcp resetRate-limit new connections to a service and ban repeat offenders (nftables version is in the complete ruleset above):
firewall-cmd --permanent --add-rich-rule='rule service name=ssh limit value=4/m accept' # firewalld: 4 new per minute, rest hit the zone target
nft add rule inet filter input tcp dport 22 ct state new limit rate over 4/minute burst 4 packets drop # nftables, without the ban setAllow a service only from the container or VM network and the host itself:
nft add rule inet filter input ip saddr { 127.0.0.1, 10.0.0.0/24, 10.88.0.0/16 } tcp dport 5432 accept
firewall-cmd --permanent --zone=trusted --add-source=10.88.0.0/16 # firewalld: trust the Podman network entirelyRedirect a privileged port to an unprivileged one so a service can run without CAP_NET_BIND_SERVICE:
firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
nft add rule inet nat prerouting tcp dport 443 redirect to :8443
nft add rule inet nat output oif lo tcp dport 443 redirect to :8443 # local clients too; needs an output nat chainBlock outbound except what is needed, for a hardened host or a build box:
table inet filter {
chain output {
type filter hook output priority filter; policy drop;
oif lo accept
ct state established,related accept
udp dport 53 ip daddr { 192.0.2.53, 192.0.2.54 } accept # our resolvers only
tcp dport { 80, 443 } accept
udp dport 123 accept # NTP
log prefix "nft-output-drop " counter drop
}
}Docker, Podman and libvirt#
Docker writes iptables-nft rules in the ip filter, ip nat tables (DOCKER, DOCKER-USER, DOCKER-FORWARD, DOCKER-ISOLATION-STAGE-* chains), publishes ports with DNAT in PREROUTING, and sets the FORWARD policy to DROP while accepting its own bridges. A port published with -p 8080:80 is reachable from every interface regardless of firewalld, because Docker’s DNAT runs at prerouting and its FORWARD accept happens in its own table. Since Docker 20.10 on firewalld hosts, Docker also adds its bridge interfaces to a docker firewalld zone, which makes firewalld aware of them but does not restrict published ports.
The supported place for your own restrictions is the DOCKER-USER chain, which Docker jumps to first in FORWARD and never flushes:
iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -m conntrack --ctdir ORIGINAL -j DROP # published ports reachable only from the management net
iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 -j DROP # or block one published port from outside
iptables -L DOCKER-USER -n -vAlternatives: bind published ports to loopback (-p 127.0.0.1:8080:80) and put a reverse proxy in front, set "iptables": false in /etc/docker/daemon.json and manage NAT yourself (breaks port publishing until you write the rules), or set "ip": "127.0.0.1" as the default bind address. Docker 28 also honours "ip6tables" and tightens the default so containers are no longer reachable on unpublished ports from other hosts on the LAN. See Docker.
Podman with netavark (default since Podman 4.0) writes nftables rules in its own netavark table when the nftables firewall driver is selected (the default on Fedora 41+), or iptables-nft rules in a NETAVARK-* set of chains otherwise. Rootless Podman uses pasta or slirp4netns and no kernel rules at all; published ports are held open by the user process, so firewalld rules on the host port are the only filter. Rootful Podman on a firewalld host adds its bridge to the trusted zone by default (firewalld driver in containers.conf). See Podman.
libvirt’s default NAT network writes iptables-nft rules (LIBVIRT_INP, LIBVIRT_FWO, LIBVIRT_FWI, LIBVIRT_PRT chains) or, with the nftables firewall backend in libvirt 10.4+, a libvirt_network nftables table. On a firewalld host it puts virbr0 into the libvirt zone. Forwarding a port to a VM on the default network needs a rule in the forward path that libvirt does not provide; a libvirt network hook script or an nft rule in your own table at dstnat priority does it. See libvirt.
The rule for all three: check nft list ruleset before assuming a firewalld rule is what governs a container or VM port, and put host policy in DOCKER-USER, a policy object, or your own nftables table rather than editing the tool’s generated chains.
Oneliners#
# What is actually open: listening sockets against firewall rules
ss -tulpnH | awk '{print $1, $5}' | sort -u; firewall-cmd --list-ports --list-services
# Runtime and permanent firewalld config differ
diff <(firewall-cmd --list-all) <(firewall-cmd --permanent --list-all)
# Open a port for 10 minutes while testing, then it closes itself
firewall-cmd --add-port=9090/tcp --timeout=600
# Which zone will handle a source address
firewall-cmd --get-zone-of-source=192.0.2.10 || echo 'interface zone applies'
# Move all interfaces from public to drop, safely (source-zone the management net first)
firewall-cmd --permanent --zone=trusted --add-source=192.0.2.0/24 && firewall-cmd --permanent --set-default-zone=drop && firewall-cmd --reload
# Every service firewalld knows that maps to a given port
for s in $(firewall-cmd --get-services); do firewall-cmd --info-service="$s" | grep -q 'ports:.*\b8080/tcp' && echo "$s"; done
# Tail denied packets with a readable format
journalctl -kf -g 'FINAL_(REJECT|DROP)|nft-.*-drop' | grep -oE 'IN=\S+|SRC=\S+|DST=\S+|PROTO=\S+|DPT=\S+' | paste - - - - -
# Top sources being dropped in the last hour
journalctl -k --since -1h -g 'FINAL_|nft-.*-drop' | grep -oP 'SRC=\K\S+' | sort | uniq -c | sort -rn | head
# Dump the live nftables ruleset with counters and handles to a file
nft -a list ruleset > /root/nft-$(date +%FT%H%M).txt
# Reload an nftables file only if it validates
nft -c -f /etc/nftables/main.nft && nft -f /etc/nftables/main.nft
# Try a new ruleset and roll back automatically in 60 s unless cancelled (remote-safe)
nft list ruleset > /root/nft-rollback.nft; (sleep 60 && nft -f /root/nft-rollback.nft) & rb=$!; nft -f /etc/nftables/main.nft; echo "kill $rb to keep"
# Counters for the rules in one chain, sorted by packets
nft -a list chain inet filter input | grep -oE 'counter packets [0-9]+ bytes [0-9]+.*' | sort -k3,3nr | head
# Add an address to a blocklist set for an hour (dynamic set with timeout)
nft add element inet filter blocklist { 203.0.113.7 timeout 1h }
# List the contents of a set with expiry
nft list set inet filter blocklist
# Which table dropped a packet: trace one destination port
nft insert rule inet filter input position 0 tcp dport 8443 meta nftrace set 1; timeout 20 nft monitor trace
# Is IP forwarding on (needed for NAT and containers)
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
# Connection tracking table size and usage (drops appear as 'nf_conntrack: table full')
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
# Live connection tracking entries to a port
conntrack -L -p tcp --dport 443 2>/dev/null | head # conntrack-tools package
# Which backend the iptables command uses
iptables -V
# Docker: block a published port from anything but one subnet
iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 ! -s 192.0.2.0/24 -j DROP
# Docker: show the DNAT rules it wrote
iptables -t nat -S DOCKER
# Podman: the nftables table netavark manages
nft list table inet netavark 2>/dev/null || iptables -S | grep -i netavark
# Test a rule from outside without nmap
timeout 3 bash -c 'echo > /dev/tcp/192.0.2.1/8443' && echo open || echo 'closed or filtered'
# Check whether a port is filtered or closed (RST versus silence); see the nmap page for more
nc -zv -w3 192.0.2.1 8443
# Check for a second, legacy ruleset lurking
iptables-legacy -S 2>/dev/null | grep -v '^-P' || echo 'no legacy rules'
# Persist a firewalld set of changes as an idempotent script
firewall-cmd --permanent --list-all | sed 's/^/# /'; firewall-cmd --permanent --list-rich-rules | sed "s/.*/firewall-cmd --permanent --add-rich-rule='&'/"Scripts#
Audit a host’s exposure: listening sockets against firewalld’s open ports and services, flagging anything listening on all interfaces that no rule allows. Read-only.
#!/usr/bin/env bash
# usage: exposure-audit.sh (root, firewalld running)
set -euo pipefail
zone=$(firewall-cmd --get-default-zone)
allowed=$(firewall-cmd --zone="$zone" --list-ports | tr ' ' '\n')
for s in $(firewall-cmd --zone="$zone" --list-services); do
allowed+=$'\n'$(firewall-cmd --info-service="$s" | awk '/ports:/ {for (i = 2; i <= NF; i++) print $i}')
done
printf 'default zone: %s\n\n%-6s %-22s %-8s %s\n' "$zone" PROTO LISTEN ALLOWED PROCESS
ss -tulpnH | while read -r proto _ _ local _ proc; do
port=${local##*:}; addr=${local%:*}
case $addr in 127.*|\[::1\]) continue ;; esac # loopback is not exposed
p=${proto%6} # tcp6 -> tcp
if grep -qx "$port/$p" <<< "$allowed"; then ok=yes; else ok=NO; fi
printf '%-6s %-22s %-8s %s\n' "$proto" "$local" "$ok" "$(grep -oP 'users:\(\("\K[^"]+' <<< "$proc" | head -1)"
done | sort -k3Generate a firewalld configuration from a small declarative file, so a host’s policy lives in version control rather than in the order someone typed commands. Applies to the permanent configuration and reloads.
#!/usr/bin/env bash
# usage: apply-firewall.sh policy.conf
# policy.conf lines: zone public service https | zone public port 8443/tcp | zone trusted source 192.0.2.0/24 | rich public rule ... | masquerade public
set -euo pipefail
conf=${1:?policy file required}
run() { printf '+ firewall-cmd --permanent %s\n' "$*"; firewall-cmd --permanent "$@" >/dev/null; }
# reset the zones the file mentions to their shipped defaults so removed lines really go away
for z in $(awk '$1 == "zone" || $1 == "rich" || $1 == "masquerade" {print $2}' "$conf" | sort -u); do
run --load-zone-defaults="$z" 2>/dev/null || echo "zone $z has no defaults to load (custom zone), continuing" >&2
done
while read -r kind zone what value; do
case $kind in
''|'#'*) continue ;;
zone) run --zone="$zone" --add-"$what"="$value" ;; # service, port, source, interface
rich) run --zone="$zone" --add-rich-rule="$what $value" ;;
masquerade) run --zone="$zone" --add-masquerade ;;
*) echo "unknown line: $kind $zone $what $value" >&2; exit 2 ;;
esac
done < "$conf"
firewall-cmd --reload && firewall-cmd --list-all-zones | grep -B1 -A12 'active'Block-list synchroniser: loads a list of CIDRs from a file into an nftables set atomically, so a feed of bad addresses can be refreshed from a timer without touching any rule.
#!/usr/bin/env bash
# usage: sync-blocklist.sh /etc/nftables/blocklist.txt (one CIDR or address per line; # comments)
set -euo pipefail
src=${1:?list file required}
tmp=$(mktemp); trap 'rm -f -- "$tmp"' EXIT
mapfile -t cidrs < <(grep -Ev '^\s*(#|$)' "$src" | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$')
(( ${#cidrs[@]} )) || { echo 'no valid IPv4 entries' >&2; exit 1; }
{
printf 'table inet filter {\n set blocklist_v4 {\n type ipv4_addr\n flags interval\n }\n}\n' # ensure the set exists; a no-op if it does
printf 'flush set inet filter blocklist_v4\n'
printf 'add element inet filter blocklist_v4 { %s }\n' "$(IFS=,; echo "${cidrs[*]}")"
} > "$tmp"
nft -c -f "$tmp" && nft -f "$tmp" # one transaction: flush and refill
printf 'loaded %d entries into inet filter blocklist_v4\n' "${#cidrs[@]}"
nft list chain inet filter input | grep -q '@blocklist_v4' || echo 'note: no rule references @blocklist_v4 yet' >&2Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
| Rule added, still blocked | Added to runtime or permanent but not both; or wrong zone for that interface | firewall-cmd --list-all versus --permanent --list-all; --get-zone-of-interface=eth0 |
Rule works until reboot or --reload | Runtime only | firewall-cmd --runtime-to-permanent |
| Port open in firewalld, connection refused | Nothing listening, or bound to 127.0.0.1 | ss -tulpn | grep :PORT; fix the service’s bind address |
| Port open in firewalld, connection times out | Another table drops it (Docker FORWARD, a custom nft table), or an upstream network firewall | nft list ruleset, nft monitor trace with an nftrace rule, tcpdump -ni eth0 port PORT to confirm arrival |
| Container’s published port reachable despite firewalld rules | Docker DNATs in prerouting and accepts in its own FORWARD chain | Restrict in DOCKER-USER, bind to 127.0.0.1, or use a reverse proxy |
| VMs or containers cannot reach the internet | Forwarding off, no masquerade, or forward chain policy drop | sysctl net.ipv4.ip_forward, nft list table inet nat, firewall-cmd --list-all --zone=public | grep masquerade |
nft -f fails with Error: Could not process rule: No such file or directory | Referencing a table, chain or set that does not exist yet, or the wrong family | Declare the table first in the same file; check inet versus ip |
nft -f fails with Operation not supported | Kernel lacks the feature (old kernel, missing module) or flags interval needed for CIDR elements | uname -r, add flags interval, or modprobe nft_* |
| Locked out after a reload | Removed SSH access or changed default zone remotely | Console or provider recovery shell; use --timeout or the rollback oneliner next time |
Locked out after nft -f | Ruleset with policy drop and no established-state or SSH rule | Console; always include ct state established,related accept and test with a timed rollback |
firewalld logs FINAL_REJECT for traffic you allowed | Wrong zone matched (source-based zone overrides interface zone) | firewall-cmd --get-active-zones, check sources on each |
| Rich rule not removing | Text must match exactly, including family= | firewall-cmd --list-rich-rules and copy the line verbatim |
firewall-cmd says INVALID_SERVICE | Service not predefined | firewall-cmd --get-services, define with --new-service, or use --add-port |
iptables shows nothing but traffic is filtered | Rules are in nftables native tables, or in iptables-legacy | nft list ruleset, iptables-legacy -S |
nf_conntrack: table full, dropping packet in dmesg | Too many tracked connections | Raise net.netfilter.nf_conntrack_max, lower nf_conntrack_tcp_timeout_established, or notrack bulk flows |
| Asymmetric or hairpin NAT fails | Reply path bypasses the NAT host, or no masquerade for LAN clients hitting the public address | Add the hairpin masquerade rule; check routes with ip route get |
| ICMP or traceroute broken after hardening | ICMP types dropped, IPv6 ND blocked | Allow the ICMP types in the ruleset above; IPv6 needs nd-neighbor-solicit and nd-neighbor-advert |
Rules vanish after systemctl restart firewalld | Custom nft rules were added to firewalld’s table | Keep your rules in your own table; firewalld only flushes its own |
Rule matches nothing (counter packets 0) | Rule below a terminating rule, wrong interface name, wrong family | nft -a list chain, move with insert, use iifname not iif after interfaces are recreated |
Further reading#
- firewalld documentation: zones, policies, rich language and the man pages
firewall-cmd(1),firewalld.richlanguage(5),firewalld.policy(5). - nftables wiki: quick reference, examples, sets, maps and the netfilter hooks diagram.
- nft(8) man page: the complete grammar, expressions, statements and data types.
- Red Hat: Configuring firewalls and packet filters: firewalld and nftables on RHEL 9, including migration from iptables.
- Moving from iptables to nftables: the translation tools and the differences that matter.
- Docker: Packet filtering and firewalls:
DOCKER-USER, firewalld integration and the daemon options.