Tutorial in English
This tutorial addresses the configuration of reaction. See the releases page for the installation part.
Three configuration languages are available:
- JSON,
- YAML,
- JSONnet (❤️)
I won't present the first two, but I do speak of the last at the end.
What do we want to protect?
First, we need to identify a service that we want to protect. Any network-facing service can possibly be protected by reaction in some ways.
In this tutorial, we'll take a look at SSH, the main door to enter most Linux and *BSD servers.
Retrieving logs
reaction protects a service by looking at its logs. On Debian-based distributions, the SSH daemon logs to journald.
The command that permits to follow the logs, as root, is: journalctl -f ssh
(or journalctl -f sshd, or tail -F /var/log/auth.log, etc. It depends on your distribution).
Let's try a few unsuccessful connections to ssh, from another server:
ssh invalid@myip # using a bad username
ssh ppom@myip # providing an invalid password with an existing username
Those two commands fail to login. Then we look for the corresponding logs generated by the SSH daemon:
[...]
Invalid user invalid from 198.51.100.23 port 60316
[...]
Failed password for ppom from 198.51.100.23 port 56952 ssh2
[...]How to protect the service
The most effective and simple way to protect the service is to ban the IP in the server's firewall.
There is documentation available for different actions, covering most common firewalls.
But in this tutorial, we'll assume that the server is running iptables, and we'll use ipset sets to store IPs.
Configuration
Now that we have gathered all those informations, let's see how it fits in reaction's configuration.
Patterns
reaction will need to extract the IP from the relevant logs. It does so with patterns, that can be used when specifying a log line regex, and then be reused to take action.
So let's first specify how reaction must recognize an IP.
We define a pattern, of type ipv4. We'll call it myip.
patterns:
myip:
type: 'ipv4'
Patterns have special types:
ipv4for IPv4 addresses,ipv6for IPv6 addresses, andipfor both IPv4 and IPv6.If you want to create patterns that capture something else than IPs, you can give your own regex:
patterns: # A pattern called name that recognizes an ASCII word # beginning with an uppercase letter. name: regex: '[A-Z][a-z]*'
ℹ️ Configuration reference for Patterns
Streams
As a replacement of fail2ban jails, reaction has streams, which define a data source.
It's just a UNIX command. reaction will run it and read its output.
Examples:
- nginx:
tail -F -n0 /var/log/nginx/access.log - ssh via journald:
journalctl -f -n0 ssh.service
Learn more about good practices when writing streams, and why we use
-n0: ℹ️ Stream good practices
So we'll add to our configuration a stream named ssh, with the correct log command:
patterns:
myip:
type: 'ipv4'
# New lines ↓
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
ℹ️ Configuration reference for Streams
Filters
We attach one or more filters to those streams. Filters are groups of regular expressions.
Let's look at our log lines again:
Invalid user invalid from 198.51.100.23 port 60316 Failed password for ppom from 198.51.100.23 port 56952 ssh2The username and the port number are irrelevant, so let's abstract them:
Invalid user .* from 198.51.100.23 port Failed password for .* from 198.51.100.23 port(The regex must match anywhere on a log line, so we can simply ignore the end if it's not useful.)
Now we will replace the actual IPs by the pattern. We use its name, and enclose it with those symbols:
<>.Invalid user .* from <myip> port Failed password for .* from <myip> portWe just discard the non variable parts of those log lines, capturing the only variable part that we want to keep: the IP.
Here we define a filter named authfail that will be triggered as soon as a line matches one of the regexes:
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
# New lines ↓
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
Often, we don't want reaction to ban the IP on the first error.
We want to avoid blocking real users, so we'll only ban after a few attempts in defined time range.
reaction supports the retry and retryperiod options, that will delay the triggering of the filter.
Let's ask that the authfail filter must be triggered only after a given myip pattern is found 3 times in a 3-hour range in one of the 2 configured regexes.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
# New lines ↓
retry: 3
retryperiod: '3h'
ℹ️ Configuration reference for Filters
Actions
Okay, we told reaction how to spot attacks, and when to react to those attacks.
We'll now tell it what to do! The concept for this is actions.
We add one or more actions to a filter, which will be executed when the filter is triggered.
Here we create an action simply named ban, that insert the IP in an IPset set called reactionv4.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
# New lines ↓
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
Actions can be executed right now, or can be delayed with after. This permits to ban an IP now, and to unban it later.
Here we add an action to unban the IP 24h later. We choose to call that action unban.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
# New lines ↓
unban:
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
after: '24h'
ℹ️ Configuration reference for Actions
Start / Stop
Those ipset commands need the existence of the reactionv4 set in the firewall.
On startup, we ask reaction to create it. Then we add it to the INPUT chain, which controls incoming connections.
We add it also to the FORWARD chain, which controls forwarded connections (useful if you're on a router, or if the server has containers or VMs).
start:
- ['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet']
- ['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
We also ask reaction to delete it and remove associated rules when quitting:
stop:
- ['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['ipset', '-X', 'reactionv4' ]Recap
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
unban:
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
after: '24h'
start:
- ['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet']
- ['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
stop:
- ['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['ipset', '-X', 'reactionv4' ]
Et voilà. With 27 lines of configuration, no hidden defaults, reaction will watch SSH connections and ban malicious connections for 24h, after 3 failed attempts.
Additional notes:
-
For simplicity's sake, we only handled IPv4. But it's possible to define different actions for IPv4 and for IPv6.
-
Still for simplicity's sake, we only defined one regex for SSH logs. But OpenSSH also logs other lines that indicate a connection error. See Filter SSH doc.
-
It's possible to do whatever action you like: launch commands for any firewall, send a mail, etc. See actions examples to look for inspiration.
-
It's advised to take a look at the Stream chapter to understand why you should use
tail -Finstead oftail -f, why do we use-n0, etc. -
Read also the Security chapter to avoid mistakes. We're here to enhance server security, not to worsen it!
-
Plugins permit new types of Streams and Actions, that we didn't see here.
JSONnet
It is a simple and flexible language, with a syntax close to JavaScript and JSON. By default, it's just a more flexible JSON:
// We can put comments
{
// No need to put quotes everywhere
streams: {
ssh: {
// We even can add commas after the last element ↓
cmd: [' journalctl', '-fu', 'ssh.service'],
}
}
}
To avoid repetitions, we write variables and functions.
local hour2second(i) = i * 60 * 60;
{
seconds: [
hour2second(1),
hour2second(3),
hour2second(5),
],
}
JSONnet works as a preprocessor. It generates a JSON-compatible data structure. This result will be handed to reaction.
{
"seconds": [ 3600, 10800, 18000 ]
}
It resembles the Nix language, but with a more pleasant syntax.
Now that JSONnet is presented, let's use the previous example written in YAML. We can rewrite it in JSONnet, adding a second stream to protect another service.
We want to avoid repeating the ipset commands (once is enough 😆).
So we write a banFor() function, which takes as an argument the duration the IPs should be banned for, and returns a set of actions.
We can then reuse it on each stream.
local banFor(time) = {
ban: {
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
},
unban: {
after: time,
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
},
};
{
patterns: {
myip: {
type: 'ipv4',
},
},
streams: {
ssh: {
cmd: ['journalctl', '-n0', '-fu', 'ssh.service'],
filters: {
authfail: {
regex: [
'Invalid user .* from <myip> port'
'Failed password for .* from <myip> port'
],
retry: 3,
retryperiod: '3h',
actions: banFor('24h'),
},
},
},
nginx: {
cmd: ['tail', '-f', '/var/log/nginx/access.log'],
filters: {
directus: {
regex: [ @'^<myip> .* "POST /auth/login HTTP/..." 401', ],
retry: 6,
retryperiod: '4h',
actions: banFor('4h'),
},
},
},
},
start: [
['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet'],
['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
],
stop: [
['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['ipset', '-X', 'reactionv4' ],
],
}
Et voilà ! We wrote the few abstractions needed. We can now define in 8 lines how to protect a new service.
Here, it's Directus, which I advice to easily construct tailored user interfaces and databases.
Separate config in multiple files.
There are good reasons to separate config in multiple files.
Every pattern, stream, filter, start/stop, can be defined in a separate file.
Here's an example that spread the config we just saw in multiple files:
/etc/reaction/startstop.jsonnet
{
start: [
['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet'],
['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
],
stop: [
['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['ipset', '-X', 'reactionv4' ],
],
}
I renamed the pattern myip to ip here.
/etc/reaction/ip.yaml
patterns:
ip:
type: 'ipv4'
Files whose name begins with _ can contain JSONnet definitions.
They won't directly be loaded by reaction.
They'll only be loaded if explicitly imported in another file:
/etc/reaction/_ban.jsonnet
local banFor(time) = {
ban: {
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<ip>']
},
unban: {
after: time,
cmd: ['ipset', '-D', 'reactionv4', '<ip>']
},
};
banFor
/etc/reaction/ssh.jsonnet
local banFor = import '_ban.jsonnet';
{
streams: {
ssh: {
cmd: ['journalctl', '-n0', '-fu', 'ssh.service'],
filters: {
authfail: {
regex: [
'Invalid user .* from <myip> port'
'Failed password for .* from <myip> port'
],
retry: 3,
retryperiod: '3h',
actions: banFor('24h'),
},
},
},
},
}
/etc/reaction/nginx.jsonnet
local banFor = import '_ban.jsonnet';
{
streams: {
nginx: {
cmd: ['tail', '-f', '/var/log/nginx/access.log'],
filters: {
directus: {
regex: [ @'^<ip> .* "POST /auth/login HTTP/..." 401', ],
retry: 6,
retryperiod: '4h',
actions: banFor('4h'),
},
},
},
},
}Usage
- Read the logs with
journalctl -f -u reaction.service. - Read current ban state with
reaction show$ reaction show ssh: login: 4.3.2.1: actions: unban: - "2025-11-01 22:00:00" 112.113.114.115: actions: unban: - "2025-10-29 00:16:16" - Remove a ban with
reaction flush ip=4.3.2.1 - Test your regexes with
reaction test-regex - Look at the help for all those commands with
reaction --help
Conclusion
That's it! You can now explore the rest of the documentation to search for what you're missing.
If you write new filters and actions for reaction, please contribute them here!