Direct.xml is the escape hatch you reach for only after zones, services, rich rules and policies have all failed to say what you actually mean. This walks through writing a permanent IPv4 deny rule in firewalld's direct configuration, checking its XML, loading it and verifying the result, in about ten minutes if you already know iptables syntax. This guide targets firewalld 2.1.1 and its firewalld.direct(5) format.
Direct rules are a fallback for cases that zones, services, rich rules, or policies cannot express. The interface is deprecated and scheduled for removal in a future release, so check first whether a normal firewalld rule does the job. Direct rules also require you to understand the table, chain, match options, and target you are using.
These commands do not change the firewall. The first prints the installed version; the second and third show the logging and backend settings when the daemon is available:
firewall-cmd --version
firewall-cmd --get-log-denied
grep '^FirewallBackend=' /etc/firewalld/firewalld.conf
On the reference system, the package reports 2.1.1. firewall-cmd needs a running firewalld daemon for most queries; if it reports that firewalld is not running, start or inspect the service before applying a live change:
sudo systemctl status firewalld
Warning: keep an existing administrative session open while testing. A bad firewall rule can interrupt SSH, and an ACCEPT direct rule has a special limitation when the nftables backend is active.
If you are allowing a standard service or port, prefer a zone command such as --add-service or --add-port. If the match is more specific, investigate a rich rule or policy. Direct configuration earns its keep only when those interfaces cannot express the required behaviour.
The example below blocks packets from the documentation network 192.0.2.0/24 before they reach the normal filter rules. Replace that address only after confirming the source range, and understand that a DROP rule changes live traffic the moment it loads.
The permanent file is /etc/firewalld/direct.xml. Make a dated backup before editing; if the file does not exist yet, the backup command will simply report that there was nothing to save.
sudo cp -a /etc/firewalld/direct.xml \
/etc/firewalld/direct.xml.before-direct-rule-$(date +%Y%m%d%H%M%S) 2>/dev/null || true
sudoedit /etc/firewalld/direct.xml
Enter this complete document. The XML declaration is optional for the parser, but keeping it makes the file's encoding explicit:
<?xml version="1.0" encoding="utf-8"?>
<direct>
<rule ipv="ipv4" table="filter" chain="INPUT" priority="0">-s 192.0.2.0/24 -j DROP</rule>
</direct>
The ipv attribute selects iptables for IPv4, ip6tables for IPv6, or ebtables for Ethernet bridges. The table and chain attributes select where the rule belongs. The text inside the element is the corresponding iptables argument list: do not put another table or chain selection in that text.
sudo xmllint --noout /etc/firewalld/direct.xml
No output and exit status 0 means the document is well-formed. That does not prove every iptables match is valid, so keep the source and target simple until the configuration is working.
This is the first command in the example that changes live packet filtering. Run it from a console or a connection that will remain available if the rule is broader than intended:
sudo firewall-cmd --reload
sudo firewall-cmd --direct --get-all-rules
The listing should contain a line equivalent to:
ipv4 filter INPUT 0 -s 192.0.2.0/24 -j DROP
Checkpoint: priority 0 puts the rule at the top of the direct chain. A larger priority places a rule further down. Rules with the same priority have no fixed order, so use distinct priorities when order matters.
A rule whose chain is a built-in chain such as INPUT is placed through firewalld's internal chain_direct arrangement, which avoids colliding with rules firewalld creates itself. A custom chain works differently: defining one does not connect it to traffic by itself. This structure creates a chain and jumps to it from INPUT:
<direct>
<chain ipv="ipv4" table="filter" chain="MY_DENYLIST"/>
<rule ipv="ipv4" table="filter" chain="INPUT" priority="0">-j MY_DENYLIST</rule>
<rule ipv="ipv4" table="filter" chain="MY_DENYLIST" priority="0">-s 192.0.2.0/24 -j DROP</rule>
</direct>
The jump is the connection: without it, the custom chain can exist while no packet ever reaches its rules. A passthrough rule is more hazardous, since firewalld adds its arguments directly and does not provide the same conflict protection, so use it only when you deliberately manage that interaction.
If the new rule caused trouble, restore the backup you made before editing. Replace the placeholder filename with the exact backup shown by your shell, then verify the XML and reload:
sudo cp -a /etc/firewalld/direct.xml.before-direct-rule-YYYYMMDDHHMMSS \
/etc/firewalld/direct.xml
sudo xmllint --noout /etc/firewalld/direct.xml
sudo firewall-cmd --reload
sudo firewall-cmd --direct --get-all-rules
Do not use a guessed backup name. List the candidates first if you have lost the filename:
sudo find /etc/firewalld -maxdepth 1 -type f -name 'direct.xml.before-direct-rule-*' -printf '%f\n'
For a deliberate empty configuration, replace the file with a document containing only <direct></direct>, validate it, and reload. Keep the old file until the new runtime listing confirms the rule is gone.
/etc/firewalld/direct.xml passes xmllint --noout.firewall-cmd --reload completed without an error.firewall-cmd --direct --get-all-rules shows the expected rule.