Filters & actions
How to write a custom fail2ban action
An action is a few shell commands fail2ban runs on ban and unban. This guide covers the file format, every tag you can use, working webhook examples for ntfy, Discord and Telegram, and how to test them safely.
To create a custom fail2ban action, save a file such as /etc/fail2ban/action.d/ntfy.conf with a [Definition] section whose actionban (and optionally actionunban, actionstart, actionstop) contains shell commands. Use tags like <ip>, <name> (the jail) and <failures>, which fail2ban fills in. Add parameters in [Init], then add the action to a jail with action = %(action_)s plus a line ntfy[url="…"].
[Definition]
norestored = 1
actionban = curl -fsS -m 10 -H "Title: fail2ban <name>: banned <ip>" -d "<ip> banned in jail <name> after <failures> failures on <fq-hostname>" "<url>"
actionunban =
[Init]
url = https://ntfy.sh/my-secret-topic
timeout = 15[sshd]
enabled = true
action = %(action_)s
ntfy[url="https://ntfy.sh/my-secret-topic"]Keep %(action_)s as the first line so the IP is still blocked by your banaction; the custom action runs in addition.
Anatomy of an action file#
[INCLUDES]
before = helpers-common.conf ; optional shared definitions
[Definition]
actionstart = <commands run when the jail starts>
actionstop = <commands run when the jail stops>
actioncheck = <commands run before each ban to verify the setup>
actionban = <commands run for each ban>
actionunban = <commands run when a ban ends>
actionflush = <commands to remove all bans at once (on stop)>
[Init]
# parameters, overridable from the jail: example[param1=value]
param1 = default
timeout = 60| Key | When it runs |
|---|---|
actionstart |
When the jail starts (firewall actions with IPv4/IPv6 variants start on demand, at the first ban). Create chains, sets, files. |
actionstop |
When the jail stops or fail2ban shuts down. Remove what actionstart created. |
actioncheck |
Before each ban. A non-zero exit means the environment broke (e.g. a firewall reload wiped the chain): fail2ban re-runs actionstop + actionstart (or actionrepair if defined). |
actionban |
For every new ban, with the IP and ticket tags filled in. |
actionunban |
When a ban expires or you unban manually. |
actionflush |
On stop, to remove all bans at once instead of unbanning one by one. |
actionreban |
To re-apply an active ban after the action was restarted or repaired; defaults to actionban. |
actionprolong |
When bantime.increment extends a ban that's already active. |
actionrepair, actionreload |
Repair after a failed actioncheck; run on reload. |
Flags in [Definition]:
norestored = 1: skip this action for bans restored from the database after a restart. Every notification action should set it, or you get a flood of messages on each restart. The stock mail actions do.actionstart_on_demand = true|false: runactionstartat the first ban instead of at jail start.
Rules for the commands:
- They run through
/bin/shas root. Treat any value from logs as hostile. - Multiple commands: one per line, continuation lines indented, no blank lines. They run as one shell script.
- Exit code 0 means success; anything else is logged as an error.
- Each command is killed after
timeoutseconds (default 60). Always give network calls their own shorter timeout (curl -m 10). %must be written%%(config interpolation).
Tags you can use#
Tags are written <tag> and replaced before the command runs. The list was checked against fail2ban/server/actions.py in 1.1.1.
Ticket tags (actionban, actionunban, actioncheck)#
| Tag | Value |
|---|---|
<ip> |
The banned IP address, e.g. 203.0.113.42 or 2001:db8::25 |
<family> |
inet4 or inet6 |
<fid> |
Failure ID: the IP, or the <F-ID> value for ID-based filters |
<ip-rev> |
Reverse-DNS name of the IP (PTR), empty if none |
<ip-host> |
Host name resolved from the IP |
<failures> |
Number of failures that led to this ban |
<time> |
Unix time of the ban |
<bantime> |
Ban duration in seconds for this ban (grows with incremental bans) |
<bancount> |
How many times this IP has been banned |
<restored> |
1 if the ban was restored from the database, else 0 |
<matches> |
The matched log lines (up to maxmatches) |
<ipmatches>, <ipjailmatches> |
Matched lines for this IP from the database, all jails / this jail |
<ipfailures>, <ipjailfailures> |
Failure counts for this IP from the database, all jails / this jail |
<F-USER>, <F-anything> |
Values captured by the filter with <F-…> groups, e.g. the user name |
<raw-ticket> |
The raw ticket, for debugging |
Jail and static tags#
| Tag | Value |
|---|---|
<name> |
The jail name (unless the jail passes name=) |
<actname> |
The action's name in the jail |
<port>, <protocol>, <chain> |
Passed by action_ from the jail's settings |
<jail.name>, <jail.banned>, <jail.banned_total>, <jail.found>, <jail.found_total> |
Jail statistics at the time of the action |
<fq-hostname>, <sh-hostname> |
This server's full and short host name |
<br> / <sp> |
A newline / a space |
any [Init] key |
Your parameters, e.g. <url> |
Quoting and escaping#
Tags whose values come from logs (<matches>, <F-USER>, <ip-host>…) can contain anything. When a value contains shell special characters, fail2ban doesn't paste it into the command; it passes it as an environment variable ($f2bV_matches) and substitutes the variable reference. So:
- Put such tags in double quotes:
"<matches>". In single quotes the variable isn't expanded and you get the literal text$f2bV_matches. - Never build JSON by pasting
<matches>into a string. A quote in the log line breaks the JSON. Usejqor a small script (examples below).
Example: ntfy push notification#
The action at the top of this page posts a message to an ntfy topic. On your phone, subscribe to the same topic. For a self-hosted ntfy with access control, add an Authorization header:
[Definition]
norestored = 1
actionban = curl -fsS -m 10 -H "Authorization: Bearer <token>" -H "Title: fail2ban <name>: banned <ip>" -d "<ip> banned in jail <name> after <failures> failures" "<url>"
actionunban =
[Init]
url = https://ntfy.example.com/fail2ban
token =
timeout = 15Example: Discord or Slack webhook#
Both take a JSON body. jq builds it safely, even with log lines inside:
[Definition]
norestored = 1
actionban = jq -n --arg msg "**<name>** banned <ip> after <failures> failures on <fq-hostname>" '{content: $msg}' | curl -fsS -m 10 -H "Content-Type: application/json" -d @- "<webhook>"
actionunban =
[Init]
webhook =
timeout = 15[sshd]
enabled = true
action = %(action_)s
discord[webhook="https://discord.com/api/webhooks/ID/TOKEN"]For Slack incoming webhooks, use '{text: $msg}' instead of '{content: $msg}'. Install jq first (sudo apt install jq).
Example: Telegram bot#
[Definition]
norestored = 1
actionban = curl -fsS -m 10 -X POST "https://api.telegram.org/bot<bot_token>/sendMessage" -d chat_id="<chat_id>" --data-urlencode text="[<name>] banned <ip> after <failures> failures on <fq-hostname>"
actionunban =
[Init]
bot_token =
chat_id =
timeout = 15[DEFAULT]
action = %(action_)s
telegram[bot_token="123456789:ABCdef", chat_id="987654321"]The bot token is a parameter, so <bot_token> is filled in from the jail. Don't paste the token into the URL as bot123:ABC/<…>: anything in angle brackets is treated as a tag. To reach Telegram, Discord and many other services without writing actions, the stock apprise action is an alternative.
Example: write bans to a file#
A simple audit log, one line per ban and unban:
[Definition]
actionstart = touch <logfile>
actionban = printf '%%s ban %%s %%s %%s\n' "$(date -u +%%FT%%TZ)" "<name>" "<ip>" "<failures>" >> <logfile>
actionunban = printf '%%s unban %%s %%s\n' "$(date -u +%%FT%%TZ)" "<name>" "<ip>" >> <logfile>
[Init]
logfile = /var/log/fail2ban-bans.logNote the doubled %% for printf and date formats.
Example: call your own API#
Push bans to a central blocklist service, with an API key and both ban and unban:
[Definition]
actionban = curl -fsS -m 10 -X POST -H "Authorization: Bearer <apikey>" -d ip="<ip>" -d jail="<name>" -d seconds="<bantime>" "<api>/bans"
actionunban = curl -fsS -m 10 -X DELETE -H "Authorization: Bearer <apikey>" "<api>/bans/<ip>"
[Init]
api = https://blocklist.example.com/v1
apikey =
timeout = 20This one deliberately has no norestored: after a restart it re-sends active bans, which keeps the remote list in sync.
Python actions#
For logic that's awkward in shell, write a Python action, action.d/<name>.py, with a class assigned to Action. Jail parameters arrive as keyword arguments (strings):
from fail2ban.server.action import ActionBase
class CSVLog(ActionBase):
def __init__(self, jail, name, path="/var/log/fail2ban-bans.csv"):
super(CSVLog, self).__init__(jail, name)
self.path = path
self.norestored = 1 # skip restored bans
def ban(self, aInfo):
with open(self.path, "a") as f:
f.write("%s,%s,%s\n" % (self._jail.name, aInfo["ip"], aInfo["failures"]))
def unban(self, aInfo):
pass
Action = CSVLog[sshd]
enabled = true
action = %(action_)s
csvlog.py[path="/var/log/ssh-bans.csv"]aInfo supports the same keys as the tags (ip, failures, matches, bantime…). The stock smtp.py is a complete example.
Test your action#
1. Check the parsed commands. After sudo fail2ban-client reload, ask fail2ban what it will run, with parameters filled in:
sudo fail2ban-client get sshd actions
sudo fail2ban-client get sshd action ntfy actionban2. Trigger a ban by hand. Manual bans run every action of the jail. Use a documentation address so you don't block anyone real (ban an IP manually):
$ sudo fail2ban-client set sshd banip 192.0.2.15 192.0.2.16
2sudo fail2ban-client set sshd unbanip 192.0.2.15 192.0.2.163. Read the errors. A failing command is logged with its exit code and output:
sudo grep -A3 "ntfy" /var/log/fail2ban.log | tail -20For more detail, raise the log level temporarily (sudo fail2ban-client set loglevel DEBUG) and set it back to INFO when done.
4. Run the command yourself. Copy the output of step 1, replace <ip> with 192.0.2.15, and run it in a root shell. Most problems (missing curl/jq, wrong URL, quoting) show up immediately.
To try a new jail without touching the firewall, use the stock dummy action as the only action; it just writes bans to a file. All stock actions are listed in the action library.
FAQ#
How do I create a custom action in fail2ban?#
Create /etc/fail2ban/action.d/<name>.conf with a [Definition] section containing actionban (and usually actionunban) shell commands using tags like <ip> and <name>, plus defaults for your parameters in [Init]. Add it to a jail as an extra line under action, then reload and test with fail2ban-client set <jail> banip.
What variables can I use in a fail2ban action?#
Ticket tags such as <ip>, <family>, <failures>, <time>, <bantime>, <bancount>, <matches>, <ip-host> and filter captures like <F-USER>; jail tags <name>, <port>, <protocol>; host tags <fq-hostname>, <sh-hostname>; and any key from the action's [Init] section, which jails can override.
How do I send fail2ban notifications to a webhook?#
Write an action whose actionban calls curl with the webhook URL, for example posting <ip> and <name> to ntfy, Discord or Slack, and set norestored = 1 to avoid messages for restored bans on restart. Add it to the jail's action after %(action_)s so the IP is still banned.
What is actioncheck used for?#
actioncheck runs before each ban to confirm the action's environment still exists, for example that the firewall chain wasn't flushed by a firewall reload. If it fails, fail2ban restarts the action (stop and start) before banning. Notification actions usually leave it empty.
Why does my custom action not run?#
Common causes: the jail doesn't list it in action (check fail2ban-client get <jail> actions), the command fails (look for errors in /var/log/fail2ban.log), a program like curl isn't installed, a single % broke the config, or norestored = 1 skipped a restored ban.
Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.