English tutorial
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.
Patterns
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 (e.g. tail -F -n0 /var/log/nginx/access.log for nginx).
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'sshd.service']
ℹ️ Configuration reference for Streams
Filters
We attach one or more filters to those streams. They are groups of regular expressions. It’s on a filter that we define the number of bad retries (retry) we grant on an IP before reacting.
Here we name an authfail Filter that will be triggered if the myip pattern is found 3 times in a 3 hours range.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'sshd.service']
filters:
authfail:
regex:
- 'authentication failure;.*rhost=<myip>'
retry: 3
retryperiod: '3h'
ℹ️ Configuration reference for Filters
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', 'sshd.service']
filters:
authfail:
regex:
- 'authentication failure;.*rhost=<myip>'
retry: 3
retryperiod: '3h'
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', 'sshd.service']
filters:
authfail:
regex:
- 'authentication failure;.*rhost=<myip>'
retry: 3
retryperiod: '3h'
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
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 have 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', 'sshd.service']
filters:
authfail:
regex:
- 'authentication failure;.*rhost=<myip>'
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 28 lines of configuration, no hidden defaults, reaction will watch SSH connections and ban malicious connections for 24h, after 3 bad tries.
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', 'sshd.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', 'sshd.service'],
filters: {
authfail: {
regex: [
'authentication failure;.*rhost=<myip>'
],
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', 'sshd.service'],
filters: {
authfail: {
regex: [
'authentication failure;.*rhost=<ip>'
],
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!