firewalld Rich Rules

Writing source-qualified, priority-ordered rich rules: family, ports, services, forward-port, masquerade, log, audit, rate limits, and real patterns.

Rich-rule heuristics
  • 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 --permanent and then --reload. Alternatively, tune at runtime with --add-rich-rule then commit with firewall-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=-100 runs before priority=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:

  1. Is the source address bound to a specific zone? If yes, that zone applies.
  2. Otherwise, is the input interface bound to a zone? If yes, that zone applies.
  3. Otherwise, the default zone applies.
  4. 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

GoalCommands
Try a change, keep on successfirewall-cmd --add-rich-rule='...'; test; firewall-cmd --runtime-to-permanent
Persistent change, apply nowfirewall-cmd --permanent --add-rich-rule='...'; firewall-cmd --reload
Revert an accidental permanent changefirewall-cmd --permanent --remove-rich-rule='...' (same string); --reload
See what is actually loaded right nowfirewall-cmd --zone=public --list-rich-rules (no --permanent)
See what will be loaded after reloadfirewall-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
Kernel forwarding. NAT only works if the kernel forwards. Make sure 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
Stateful tools do it better. For real brute-force resistance use 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:

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

SymptomCauseFix
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