Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

English tutorial

This tutorial addresses the configuration of reaction. See the releases page for the installation part.

Three configuration languages are available:

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: ipv4 for IPv4 addresses, ipv6 for IPv6 addresses, and ip for 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 -F instead of tail -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!