firewalld Rich Rules
- Use services and zones for the 80% case. Reach for rich rules only when you need a source/destination qualifier, rate limit, logging, or priority ordering.
- Always edit
--permanentand then--reload. Alternatively, tune at runtime with--add-rich-rulethen commit withfirewall-cmd --runtime-to-permanent. - Rich rules are per-zone. A rule that "isn't firing" is usually attached to a zone that the interface/source is not in.
- Priorities go negative-first (
priority=-100runs beforepriority=0). Use them for early allow-lists and early blocks. - If you find yourself writing ten rich rules with similar matches, you want an ipset and one rich rule referencing it.
Zones — a recap
firewalld binds each interface or source to exactly one zone. The zone's default target and its services/ports/rich-rules decide what happens to traffic. Rich rules live inside a zone.
firewall-cmd --get-default-zone
firewall-cmd --get-active-zones
firewall-cmd --zone=public --list-all
firewall-cmd --permanent --new-zone=dmz-mgmt
firewall-cmd --permanent --zone=dmz-mgmt --add-source=10.10.20.0/24
firewall-cmd --reload
Evaluation order per packet:
- Is the source address bound to a specific zone? If yes, that zone applies.
- Otherwise, is the input interface bound to a zone? If yes, that zone applies.
- Otherwise, the default zone applies.
- Within the zone, rich rules are evaluated by priority (low numbers first), then services, then ports, then the zone's default target (ACCEPT / REJECT / DROP).
Rich-rule syntax
Rich rules are a single string. Elements go in a fixed order: family → source/destination → match → action-specific → action.
rule [family="ipv4|ipv6"] [priority=NUMBER]
[source [address="CIDR"] [mac="xx:xx:..."] [ipset="name"] [invert="true"]]
[destination address="CIDR" [invert="true"]]
[service name="X" | port port="N" protocol="tcp|udp|sctp|dccp"
| protocol value="icmp|esp|..." | icmp-block name="X" | masquerade
| forward-port port="N" protocol="tcp|udp" to-port="M" [to-addr="IP"]]
[log [prefix="..."] [level="emerg|alert|crit|err|warning|notice|info|debug"] [limit value="1/m"]]
[audit [limit value="..."]]
[accept|reject [type="icmp-..."]|drop|mark set="VAL"]
Minimal examples:
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="198.51.100.0/24" service name="ssh" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" port port="8443" protocol="tcp" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="10.0.0.0/8" invert="true" service name="ssh" drop'
firewall-cmd --reload
Runtime vs permanent
| Goal | Commands |
|---|---|
| Try a change, keep on success | firewall-cmd --add-rich-rule='...'; test; firewall-cmd --runtime-to-permanent |
| Persistent change, apply now | firewall-cmd --permanent --add-rich-rule='...'; firewall-cmd --reload |
| Revert an accidental permanent change | firewall-cmd --permanent --remove-rich-rule='...' (same string); --reload |
| See what is actually loaded right now | firewall-cmd --zone=public --list-rich-rules (no --permanent) |
| See what will be loaded after reload | firewall-cmd --permanent --zone=public --list-rich-rules |
--reload vs --complete-reload. --reload keeps connection tracking; --complete-reload flushes state and typically drops in-flight connections. Prefer --reload unless you are redoing the whole ruleset.
Priorities and ordering
Each rich rule can carry priority=N where N is any integer, default 0. Smaller numbers match first. Same priority = implementation-defined order within the zone — don't rely on it.
# Early ACCEPT for admin network (runs before any later drops)
firewall-cmd --permanent --zone=public --add-rich-rule='
rule priority="-200" family="ipv4" source address="10.10.0.0/16" accept'
# Early DROP of a known-bad /24 (still before service rules)
firewall-cmd --permanent --zone=public --add-rich-rule='
rule priority="-100" family="ipv4" source address="203.0.113.0/24" drop'
# Specific allow for monitoring probe (priority 0 = default)
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="10.20.30.40/32" port port="9100" protocol="tcp" accept'
firewall-cmd --reload
Services, ports, and protocols
Prefer service name="...". Service definitions in /usr/lib/firewalld/services/ encode the right ports and helpers (e.g. FTP active-mode pinholes). Use port port="N" only for non-standard ports or ad hoc rules.
firewall-cmd --get-services | tr ' ' '\n' | grep -i postgres
firewall-cmd --info-service=postgresql
# Named service, source-restricted
firewall-cmd --permanent --zone=trusted --add-rich-rule='
rule family="ipv4" source address="10.0.0.0/8" service name="postgresql" accept'
# Port, source-restricted
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="198.51.100.0/24"
port port="9200" protocol="tcp" accept'
# Protocol match (ESP for IPsec tunnels)
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" protocol value="esp" accept'
firewall-cmd --reload
To add a reusable service:
firewall-cmd --permanent --new-service=myapp
firewall-cmd --permanent --service=myapp --set-short="MyApp"
firewall-cmd --permanent --service=myapp --add-port=8081/tcp
firewall-cmd --permanent --service=myapp --add-port=8443/tcp
firewall-cmd --reload
firewall-cmd --permanent --zone=public --add-service=myapp
Forward-port and masquerade
forward-port rewrites the destination of inbound packets (DNAT). masquerade applies source-NAT on the way out — needed when the forward target is on a network that does not route directly back to the client.
# Port translation on the same host (80 → local 8080)
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" forward-port port="80" protocol="tcp" to-port="8080"'
# Port translation to another host (needs masquerade enabled on the zone)
firewall-cmd --permanent --zone=public --add-masquerade
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4"
forward-port port="443" protocol="tcp" to-port="443" to-addr="10.0.10.20"'
firewall-cmd --reload
net.ipv4.ip_forward=1 (and the v6 equivalent) are set persistently in /etc/sysctl.d/. firewalld will enable forwarding when masquerade is turned on, but only while the rule is active.
Log and audit
log writes kernel log messages for matched packets; audit pushes records to auditd. Both support limit value="N/unit".
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="10.0.0.0/8" invert="true"
service name="ssh"
log prefix="fw-ssh-drop " level="info" limit value="1/s"
drop'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="203.0.113.0/24"
audit limit value="1/m"
drop'
firewall-cmd --reload
journalctl -k | grep fw-ssh-drop
ausearch -m NETFILTER_PKT -ts recent
Log lines look like:
kernel: fw-ssh-drop IN=eth0 OUT= MAC=... SRC=203.0.113.7 DST=198.51.100.10
LEN=60 TOS=0x00 PREC=0x00 TTL=52 ID=12345 DF PROTO=TCP SPT=45022 DPT=22
WINDOW=64240 RES=0x00 SYN URGP=0
Rate limiting
Any rich rule's action can carry a limit value="N/unit" (s, m, h, d). Packets that match the rule after the limit is exceeded fall through to the next rule. This is how you build sane "knock the brute-forcers down" policies without jumping to fail2ban for trivial cases.
# Accept up to 10 new SSH connections per minute per IP;
# excess falls through to a later drop rule.
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" service name="ssh"
log prefix="ssh-rl " level="info" limit value="10/m"
accept limit value="10/m"'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule priority="100" family="ipv4" service name="ssh" drop'
firewall-cmd --reload
fail2ban or sshd's MaxStartups/LoginGraceTime. Rich-rule rate limiting is fine for slowing opportunistic scans, not for replacing an IDS.
IPv6 examples
Set family="ipv6" and use v6 CIDRs. ICMPv6 is essential for PMTU discovery and neighbour discovery — don't block it wholesale.
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv6" source address="2001:db8:10::/48"
service name="ssh" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv6" protocol value="ipv6-icmp" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv6" source address="2001:db8:dead::/32" drop'
firewall-cmd --reload
Direct rules vs rich rules
--direct rules pass raw iptables/ip6tables/nftables lines through to the backend. They exist as an escape hatch; avoid them for anything a rich rule can express. firewalld can't reason about direct rules (priority, dependencies, removal).
# Example direct rule (last resort)
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 0 \
-p tcp --dport 4433 -m conntrack --ctstate NEW -j ACCEPT
firewall-cmd --reload
firewall-cmd --direct --get-all-rules
Prefer rich rules because they are:
- Zone-scoped — they follow the source/interface bindings.
- Priority-ordered — you can reason about evaluation.
- Idempotent via the CLI —
--add-rich-rulewith the same string is a no-op. - Safe to list and remove by string match.
Real-world patterns
DMZ zone with internet-facing web tier
firewall-cmd --permanent --new-zone=dmz-web
firewall-cmd --permanent --zone=dmz-web --set-target=DROP
firewall-cmd --permanent --zone=dmz-web --add-interface=eth1
# Public traffic: HTTP/HTTPS from anywhere
firewall-cmd --permanent --zone=dmz-web --add-service=http
firewall-cmd --permanent --zone=dmz-web --add-service=https
# Monitoring scrape from metrics server
firewall-cmd --permanent --zone=dmz-web --add-rich-rule='
rule family="ipv4" source address="10.20.30.40/32"
port port="9100" protocol="tcp" accept'
# SSH only from jump host
firewall-cmd --permanent --zone=dmz-web --add-rich-rule='
rule priority="-100" family="ipv4" source address="10.10.0.50/32"
service name="ssh" accept'
firewall-cmd --permanent --zone=dmz-web --add-rich-rule='
rule family="ipv4" service name="ssh" drop'
firewall-cmd --reload
Management network gets everything, everyone else gets nothing
firewall-cmd --permanent --new-zone=mgmt
firewall-cmd --permanent --zone=mgmt --set-target=ACCEPT
firewall-cmd --permanent --zone=mgmt --add-source=10.10.0.0/24
firewall-cmd --permanent --zone=mgmt --add-source=2001:db8:mgmt::/64
# Public zone stays restrictive; drop non-mgmt SSH explicitly
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" service name="ssh" drop'
firewall-cmd --reload
SSH allow-list for a bastion
# 'trusted-sysops' ipset kept in sync by Ansible
firewall-cmd --permanent --new-ipset=trusted-sysops --type=hash:net
firewall-cmd --permanent --ipset=trusted-sysops --add-entry=198.51.100.10/32
firewall-cmd --permanent --ipset=trusted-sysops --add-entry=198.51.100.11/32
firewall-cmd --permanent --zone=public --add-rich-rule='
rule priority="-50" family="ipv4"
source ipset="trusted-sysops"
service name="ssh" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule priority="50" family="ipv4"
service name="ssh"
log prefix="ssh-deny " level="info" limit value="5/m"
drop'
firewall-cmd --reload
App port via reverse proxy on the same host
# Expose 443 publicly; internal app listens on 127.0.0.1:8443
# (firewalld's role is only to guarantee 8443 is not world-exposed)
firewall-cmd --permanent --zone=public --add-service=https
# Belt-and-braces: deny 8443 from everywhere except localhost
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4" source address="127.0.0.1/32"
port port="8443" protocol="tcp" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4"
port port="8443" protocol="tcp" drop'
firewall-cmd --reload
DNAT to a backend (edge load balancer)
firewall-cmd --permanent --zone=public --add-masquerade
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4"
forward-port port="443" protocol="tcp" to-port="443" to-addr="10.0.10.20"'
firewall-cmd --permanent --zone=public --add-rich-rule='
rule family="ipv4"
forward-port port="80" protocol="tcp" to-port="80" to-addr="10.0.10.20"'
firewall-cmd --reload
Temporary maintenance window: one zone into "panic"
firewall-cmd --panic-on
# (everything dropped)
firewall-cmd --query-panic
firewall-cmd --panic-off
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Rich rule edited but nothing changed on the wire | Edited --permanent without --reload, or tested the wrong zone |
firewall-cmd --reload; check with firewall-cmd --zone=X --list-rich-rules (no --permanent) |
| Rule exists but not firing | Interface or source is in a different zone | firewall-cmd --get-active-zones; rebind interface with --change-interface or rebind source |
| ACCEPT rule "shadowed" by a later DROP | Priorities not set; default ordering bites | Give early ACCEPTs priority=-100 (or similar) and late DROPs priority=100 |
forward-port works locally but breaks return traffic |
Backend not masqueraded; backend sees client IP and has no route back | Enable --add-masquerade on the zone, or route return traffic via the LB |
| IPv6 peers fail to connect even though v4 works | No family="ipv6" equivalent of the rule; ICMPv6 blocked |
Mirror each v4 rule with a v6 version; allow protocol value="ipv6-icmp" |
Rule appears twice in --list-rich-rules |
Different whitespace/quoting creates a distinct canonical string | Remove both, re-add once with a fixed template (Ansible firewalld module helps) |
--runtime-to-permanent blew away hand-crafted permanent config |
Runtime and permanent were out of sync; commit overwrote permanent | Use one source of truth (Ansible / CM) and only edit there; never mix both habits |
| Docker/Podman exposes ports despite firewalld DROP | Container runtimes add their own nftables chains with higher priority | Understand the runtime's firewall integration; use --ip=127.0.0.1 binds or configure the runtime to respect firewalld |
| Log spam from rate-limit rules | No limit value= on the log action |
Add log limit value="1/s" (or coarser) to the rule |
firewall-cmd hangs on reload |
Very large ruleset on old iptables backend | Switch to nftables backend: FirewallBackend=nftables in /etc/firewalld/firewalld.conf; systemctl restart firewalld |
Cross-reference
- firewalld — zones, services, and baseline commands.
- Linux networking — interfaces, routes, and where firewalld sits in the stack.
- SELinux and SELinux debugging — when a blocked port is really a label problem.
- Nginx reverse proxy — pairs with
forward-portpatterns on the edge. - sysctl tuning —
net.ipv4.ip_forward, conntrack sizing, and related knobs. - Wireshark — confirming whether a packet reached the host at all before blaming firewalld.