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.

Updated 9 min readChecked against fail2ban 1.1.1

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="…"].

/etc/fail2ban/action.d/ntfy.conf
[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
/etc/fail2ban/jail.local
[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#

/etc/fail2ban/action.d/example.conf
[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: run actionstart at the first ban instead of at jail start.

Rules for the commands:

  • They run through /bin/sh as 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 timeout seconds (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. Use jq or 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:

/etc/fail2ban/action.d/ntfy.conf
[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 = 15

Example: Discord or Slack webhook#

Both take a JSON body. jq builds it safely, even with log lines inside:

/etc/fail2ban/action.d/discord.conf
[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
/etc/fail2ban/jail.local
[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#

/etc/fail2ban/action.d/telegram.conf
[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
/etc/fail2ban/jail.local
[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:

/etc/fail2ban/action.d/banlog.conf
[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.log

Note 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:

/etc/fail2ban/action.d/myapi.conf
[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 = 20

This 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):

/etc/fail2ban/action.d/csvlog.py
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
/etc/fail2ban/jail.local
[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:

bash
sudo fail2ban-client get sshd actions
sudo fail2ban-client get sshd action ntfy actionban

2. 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):

output
$ sudo fail2ban-client set sshd banip 192.0.2.15 192.0.2.16
2
bash
sudo fail2ban-client set sshd unbanip 192.0.2.15 192.0.2.16

3. Read the errors. A failing command is logged with its exit code and output:

bash
sudo grep -A3 "ntfy" /var/log/fail2ban.log | tail -20

For 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.