reaction

A daemon that scans program outputs for repeated patterns, and takes action.

A common usage is to scan ssh and webserver logs, and to ban hosts that cause multiple authentication errors.

Welcome on reaction's Wiki!

Wiki rendered on https://reaction.ppom.me.

For an introduction to reaction, see:

This wiki is made of:

  • security: good practices to avoid giving arbitrary execution to attackers. A must read!
  • jsonnet: FAQ, help and good practices about JSONnet.
  • streams: good practices about stream sources
  • patterns: good practices and common defaults about patterns
  • filters: discover existing service configurations
  • actions: discover existing actions
  • configurations: discover real-world user configurations
  • migrate-to-v2: migration from reaction v1 to reaction v2 (smooth!)

❤️ Please enhance this wiki with your own discoveries! ❤️

Tutorials

Official tutorials of reaction:

⚠️ Important security notice

Be careful when writing regexes. Try to ensure no malicious input could be injected in your regexes. It's better if your actions are direct commands, and not inline shell scripts.

If you use products of regexes in shell scripts, double-check that all user input is correctly escaped. Make use of tools like shellcheck to analyze your code.

Avoid using this kind of commands that mix code and user input:

['sh', '-c', 'mycommand <pattern>']

Example of a configuration permitting remote execution:

insecure.jsonnet

{
  patterns: {
    user: {
      regex: @'.*',
    },
  },
  streams: {
    myservice: {
      cmd: ['tail', '-f', '/tmp/reaction-example'],
      filters: {
        myfilter: {
          regex: [
            @'Connection of <user> failed',
          ],
          // [...]
          actions: {
            myaction: {
              cmd: ['sh', '-c', 'echo "<user>"'],
            },
          },
        },
      },
    },
  },
}

Let's launch reaction in one terminal:

$ touch /tmp/reaction-example
$ reaction -c insecure.jsonnet

Then let's append malicious data to the tailed file in another terminal:

$ echo 'Connection of "; mkdir malicious-directory" failed' >> /tmp/reaction-example

We simulated an attacker supplying "; mkdir malicious-directory" as a username in a random service which doesn't check for non-alphanumeric characters in its usernames.

reaction will then launch the command:

myaction: {
  cmd: ['sh', '-c', 'echo "<user>"'],
},

Substituting <user> with the malicious input:

['sh', '-c', 'echo ""; mkdir malicious-directory""']

Of course, here it's a mkdir but it can be anything.

Summary

Regexes are powerful, and need to be carefully written. Avoid having too permissive regexes capturing user input.

When executing scripts, code and user input must be clearly separated. Save scripts in files and call them like this: ['/path/to/script', 'arg1', '...'] or ['bash', '/path/to/script', 'arg1', '...'] instead of having inline ['bash', '-c', 'command arg1 && command arg2'] when dealing with user input.

JSONnet FAQ

JSONnet already has a good tutorial to start with.

Here's a FAQ for reaction:

How do I write defaults at one place and reuse them elsewhere?

Answer

You first define your defaults set:

local filter_default = {
  retry: 3,
  retryperiod: '3h',
  actions: banFor('24h'),
};

The you can use it in your filters:

{
  streams: {
    ssh: {
      filters: {
        failedlogin: filter_default + {
          regex: ['...'],
        },
      },
    },
  },
}

You can override those defaults:

{
  streams: {
    ssh: {
      filters: {
        failedlogin: filter_default + {
          regex: ['...'],
          retry: 1,
        },
      },
    },
  },
}

And the + is optional.

{
  streams: {
    ssh: {
      filters: {
        failedlogin: filter_default {
          regex: ['...'],
        },
      },
    },
  },
}

How do I add multiple actions defined by JSONnet functions on the same filter?

Answer

Let's take this example: we have two functions defining actions:

The first is made to ban the IP using linux's iptables firewall:

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};

The second sends a mail:

local sendmail(text) = {
  mail: {
    cmd: ['sh', '-c', '/root/scripts/mailreaction.sh', text],
  },
};

Both create a set of actions. We want to merge the two sets.

To merge two sets with JSONnet, it's as easy as set1 + set2.

Let's see what it ressembles with a real example.

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};

local sendmail(text) = {
  mail: {
    cmd: ['sh', '-c', '/root/scripts/mailreaction.sh', text],
  },
};

{
  streams: {
    ssh: {
      filters: {
        failedlogin: {
          regex: [
            // skipping
          ],
          retry: 3,
          retryperiod: '3h',
          actions: banFor('720h') + sendmail('banned <ip> from service ssh'),
        },
      },
    },
  },
}

This will generate this configuration:

{
  "streams": {
    "ssh": {
      "filters": {
        "failedlogin": {
          "regex": [ ],
          "retry": 3,
          "retryperiod": "3h",
          "actions": {
            "ban": {
              "cmd": [ "ip46tables", "-w", "-A", "reaction", "-s", "<ip>", "-j", "DROP" ]
            },
            "unban": {
              "after": "720h",
              "cmd": [ "ip46tables", "-w", "-D", "reaction", "-s", "<ip>", "-j", "DROP" ]
            },
            "mail": {
              "cmd": [ "sh", "-c", "/root/scripts/mailreaction.sh", "banned <ip> from service ssh" ]
            }
          }
        }
      }
    }
  }
}

How do I separate my configuration in multiple files?

Answer

Firstly, you don't need to do this 😉

But if you want to, you can use the import JSONnet keyword to import files, relative to the file calling them. (Remember, the JSONnet tutorial is a good place to understand its basics).

Here's an example of how you could do this:

reaction.jsonnet

{
  streams: {
    ssh: import 'ssh.jsonnet',
  },
}

ssh.jsonnet

local lib = import 'lib.jsonnet';
{
  filters: {
    failedlogin: lib.filter_default + {
      regex: ['...'],
      retry: 3,
      retryperiod: '2h',
      actions: lib.banFor('720h'),
    },
  },
}

lib.jsonnet (definitions you can reuse in all other files)

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};

local filter_default = {
  retry: 3,
  retryperiod: '3h',
  actions: banFor('24h'),
};

{
  banFor: banFor,
  filter_default: filter_default,
}

Streams

When defining a stream, you should use a command that follows new writes on logs and print them as they arise.

The command should not print older lines. For example, tail -f /var/log/nginx/access.log will print the last 10 lines first, then follow appended lines. This a problem because restarting reaction will result in the same 10 logs potentially printed multiple times.

Examples of good commands:

Plain file

Follow logs of one file

tail -fn0 <FILE>

Follow multiple files as one stream. It will print some extra lines. Check them and see if they will match your regexes.

tail -fn0 <FILE1> <FILE2>

Follow multiple files as one stream. This alternative pattern can work for any command. sh will launch multiple commands in background, then until all of them exit.

sh -c 'tail -fn0 <FILE1> & tail -fn0 <FILE2> & wait'

⚠️ tail -f and logrotate

When files are rotated, tail -f may stay on the rotated file and miss new inputs.

Using tail -F instead permits to listen on a specific path, even if the actual file under it changes. See its manual for more details.

SystemD / JournalD

Logs of one systemd unit

journalctl -fn0 -u <UNIT>

Logs of multiple systemd units

journalctl -fn0 -u <UNIT> -u <UNIT>

Docker

Logs of one container

docker logs -fn0 <CONTAINER>

Logs of all the services of a docker compose file

docker compose --project-directory /path/to/directory logs -fn0

⚠️ docker logs print program's stderr to stderr as well, and reaction only reads stdout. So we might need to capture stdout and stderr depending on how your container does log:

cmd: ['sh', '-c', 'exec docker logs -fn0 <container> 2>&1']

There is virtually no overhead, as the sh process replaces itself with the docker logs command.

In reaction v2, stderr is read as well, so this trick is no longer needed.

Patterns

Patterns are globally defined. They're substitued in regexes.

The regex syntax of reaction is documented here.

When a filter performs an action, it replaces the found pattern by the regex.

{
  patterns: {
    // <ip> is defined here
    ip: {
      regex: '...',
    },
  },

  streams: {
    myservice: {
      cmd: [ 'echo', 'the IP 1.2.3.4 is a bot!' ],
      filters: {
        myfilter: {
          // ip regex will be substitued in this regex at <ip>
          regex: [ '^the IP <ip> is a bot!' ],
          actions: {
            myaction: {
              // when executed, <ip> will be substitued by the IP found by the filter
              cmd: [ '/path/to/ban.sh', '<ip>' ],
              // executes: /path/to/ban.sh 1.2.3.4
            },
          },
        },
      },
    },
  },
}

IP

There are both simple and full versions.

  • Simple versions should be faster, but they may also accept malformed IPs, even any hexadecimal content when using IPv6 regexes.
  • Full versions mean to be entirely correct (no false positives), adapted from IHateRegex.io (ipv4, ipv6).

IPv4 only

Simple version:

{
  patterns: {
    ipv4: {
      regex: @'(([0-9]{1,3}\.){3}[0-9]{1,3})',
      ignore: [
        '127.0.0.1' // do not ban localhost!
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ]
    },
  },
}

Full version:

{
  patterns: {
    ipv4: {
      regex: @'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}',
      ignore: [
        '127.0.0.1' // do not ban localhost!
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ]
    },
  },
}

IPv6 only

Simple version:

{
  patterns: {
    ipv6: {
      regex: @'([0-9a-fA-F:]{2,90})',
      ignore: [
        '::1' // do not ban localhost!
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ]
    },
  },
}

Full version:

{
  patterns: {
    ipv6: {
      regex: @'(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])',
      ignore: [
        '::1' // do not ban localhost!
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ]
    },
  },
}

Both IPv4 and IPv6

Simple version:

{
  patterns: {
    ip: {
      // reaction regex syntax is defined here: https://github.com/google/re2/wiki/Syntax
      // jsonnet's @'string' is for verbatim strings
      regex: @'(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})',
      ignore: [
        // do not ban localhost!
        '127.0.0.1',
        '::1',
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ],
    },
  },
}

Full version:

{
  patterns: {
    ip: {
      regex: @'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|(?:(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))',
      ignore: [
        '127.0.0.1' // do not ban localhost!
        '::1' // do not ban localhost!
        // it can be also advised to avoid banning your Internet Gateway (the router)
      ]
    },
  },
}

Filters

Here, you will find examples of filters for different programs’ logs.

Web AI crawlers

Configuration to ban GPTBot and friends. Here the idea is to look for their User-Agents in your webserver logs.

You may as well Disallow those user agents from looking at your websites in a robots.txt file. I personnally prefer banning them, to save ressources and be less cooperative to them. Note that an AI bot may give a browser-like User Agent and go unnoticed...

While the goal of this is to prevent AI bots from feeding themselves with your websites, banning search engine bots may affect how your appear in search results.

They seem to have separate user agents for AI en for search, but who knows?

A (most probably incomplete) list of user agents based on https://darkvisitors.com/agents:

  • ChatGPT-User
  • DuckAssistBot
  • Meta-ExternalFetcher
  • AI2Bot
  • Applebot-Extended
  • Bytespider
  • CCBot
  • ClaudeBot
  • Diffbot
  • FacebookBot
  • Google-Extended
  • GPTBot
  • Kangaroo Bot
  • Meta-ExternalAgent
  • omgili
  • Timpibot
  • Webzio-Extended
  • Amazonbot
  • Applebot
  • OAI-SearchBot
  • PerplexityBot
  • YouBot

(Feel free to add your own discoveries to this list!)

As a pattern, we'll use ip. See here.

JSONnet Example:

local bots = [ "ChatGPT-User", "DuckAssistBot", "Meta-ExternalFetcher", "AI2Bot", "Applebot-Extended", "Bytespider", "CCBot", "ClaudeBot", "Diffbot", "FacebookBot", "Google-Extended", "GPTBot", "Kangaroo Bot", "Meta-ExternalAgent", "omgili", "Timpibot", "Webzio-Extended", "Amazonbot", "Applebot", "OAI-SearchBot", "PerplexityBot", "YouBot" ];
{
  streams: {
    nginx: {
      cmd: ['...'], // see ./nginx.md
      filters: {
        aiBots: {
          regex: [
            // User-Agent is the last field
            // Bot's name can be anywhere in the User-Agent
            // (hence the leading and trailing [^"]*
            @'^<ip>.*"[^"]*%s[^"]*"$' % bot
            for bot in bots
          ],
          actions: banFor('720h'),
        },
      },
    },
  },
}

YAML Example:

local bots = [ "ChatGPT-User", "DuckAssistBot", "Meta-ExternalFetcher", "AI2Bot", "Applebot-Extended", "Bytespider", "CCBot", "ClaudeBot", "Diffbot", "FacebookBot", "Google-Extended", "GPTBot", "Kangaroo Bot", "Meta-ExternalAgent", "omgili", "Timpibot", "Webzio-Extended", "Amazonbot", "Applebot", "OAI-SearchBot", "PerplexityBot", "YouBot" ];

streams:
  nginx:
    cmd: ['...'] # see ./nginx.md
    filters:
      aiBots:
        regex:
            # User-Agent is the last field
            # Bot's name can be anywhere in the User-Agent
            # (hence the leading and trailing [^"]*
          - '^<ip>.*"[^"]*ChatGPT-User[^"]*"$'
          - '^<ip>.*"[^"]*DuckAssistBot[^"]*"$'
          - '...' # Repeat for each bot
      actions: '...' # your ban actions here

Dolibarr

Configuration for a Dolibarr instance. Dolibarr is one of the leading open source ERP/CRM web applications.

As an action, we'll use iptables. See here.

As a pattern, we'll use ip. See here.

Dolibarr "logs" module must be activated !

{
  streams: {
    // Ban hosts failing to connect to Dolibarr
    dolibarr: {

      cmd: ['tail', '-fn0', '/path/to/dolibarr/documents/dolibarr.log'],

      filters: {
        bad_password: {
          regex: [
            @'NOTICE  <ip> .*Bad password, connexion refused',
          ],
          retry: 3,
          retryperiod: '1h',
          actions: banFor('48h'),
                        },
                },
            },
        },
    }

Directus

Configuration for the Directus web service.

Directus doesn't log failed login attempts, so one must read the logs of the reverse proxy configured in front of Directus.

The HTTP code sent after a failed login is 401, Unauthorized.

The request to authenticate on Directus is a POST on /auth/login.

As a pattern, we'll use ip. See here.

A regex for nginx can look like this:

@'^<ip> .* domain.name "POST /auth/login HTTP/..." 401 '
  • adjust https://domain.name according to your domain.
  • if directus is served on a subpath, say /editor, then adjust to POST /editor/auth/login

Example:

{
  streams: {
    nginx: {
      cmd: ['...'], // see ./nginx.md
      filters: {
        directus: {
          regex: [
            @'^<ip> .* directus.domain "POST /auth/login HTTP/..." 401 '
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

Nextcloud

Configuration for the Nextcloud web service.

Nextcloud logs failed login attempts, so we will read Nextcloud logs.

We can't use reverse proxy's logs, because when a user logins, using a POST on /login, the HTTP status code responded by Nextcloud is always the same: 303, See Other. (That means the client has to reload the same page, but using GET).

As a pattern, we'll use ip. See here.

See Nextcloud documentation on logging to check where your application logs are.

There are multiple log configurations possible with Nextcloud. The example covers 2 cases, but there are more! Feel free to contribute your own if you think it's relevant.

Example:

{
  streams: {
    nextcloud: {

      // with a PHP-FPM worker logging to systemd
      cmd: ['journalctl', '-fn0', '-u', 'phpfpm-nextcloud.service'],

      // when logging to a file
      cmd: ['tail', '-fn0', 'NEXTCLOUD_DIR/data/nextcloud.log'],

      filters: {
        nextcloud: {
          regex: [
            @'"remoteAddr":"<ip>".*"message":"Login failed:',
            @'"remoteAddr":"<ip>".*"message":"Trusted domain error.',
          ],
          retry: 3,
          retryperiod: '1h',
          actions: banFor('3h'),
        },
      },
    },
  },
}

Nginx

Configuration for the Nginx web server.

Nginx most often logs to /var/log/nginx/access.log.

The Common Log Format, used by multiple webservers, is described in another wiki page.

Examples in this wiki use this configuration in nginx's http { } block:

log_format withhost '$remote_addr - $remote_user [$time_local] $host "$request" $status $bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log withhost;

As a pattern, we'll use ip. See here.

This permits an easy access to the IP, as it's at the very beginning. It also permits to have access to the domain name, which is useful when nginx is configured for multiple virtual hosts.

A regex for nginx can look like this:

@'^<ip> .* "POST /auth/login HTTP/..." 401 '
//          ^    ^                     ^
//     Method    Path        Status Code

Or this:

@'^<ip> .* domain.name "POST /auth/login HTTP/..." 401 '
//         ^            ^    ^                     ^
//         Domain  Method    Path        Status Code

Adjust domain.name according to your domain

Example:

{
  streams: {
    nginx: {
      cmd: ['tail', '-n0', '-f', '/var/log/nginx/access.log'],
      filters: {
        directus: {
          regex: [
            @'^<ip> .* directus.domain "POST /auth/login HTTP/..." 401 ',
          ],
          actions: banFor('1h'),
        },
      },
    },
  },
}

You can decide that all 401, Unauthorized, and 403, Forbidden, are suspicious, and have a filter for any 401 and 403:

Example:

{
  streams: {
    nginx: {
      cmd: ['tail', '-n0', '-f', '/var/log/nginx/access.log'],
      filters: {
        all403s: {
          regex: [
            @'^<ip> .* "(POST|GET) /[^ ]* HTTP/..." (401|403) ',
          ],
          retry: 15,
          retryperiod: '5m',
          actions: banFor('1h'),
        },
      },
    },
  },
}

slskd

Configuration for the slskd web service.

slskd doesn't log failed login attempts, so one must read the logs of the reverse proxy configured in front of slskd.

The HTTP code sent after a failed login is 401, Unauthorized.

The request to authenticate on slskd is a POST on /api/v0/session.

As a pattern, we'll use ip. See here.

A regex for nginx can look like this:

@'^<ip> .* slskd.domain "POST /api/v0/session HTTP/..." 401 ',
  • adjust https://slskd.domain according to your domain.
  • if slskd is served on a subpath, say /slskd, then adjust to POST /slskd/auth/login

Example:

{
  streams: {
    nginx: {
      cmd: ['...'], // see ./nginx.md
      filters: {
        slskd: {
          regex: [
            @'^<ip> .* slskd.domain "POST /api/v0/session HTTP/..." 401 ',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

SSH

Configuration for the OpenSSH service.

As an action, we'll use iptables. See here.

As a pattern, we'll use ip. See here.

{
  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      // Use systemd's `journalctl` to tail logs
      cmd: ['journalctl', '-fn0', '-u', 'ssh.service'],
                        // ⚠️ may also be ↑ sshd.service, depends on the distribution
      filters: {
        failedlogin: {
          regex: [
            // Auth fail
            @'authentication failure;.*rhost=<ip>',
            // Client disconnects during authentication
            @'Connection (reset|closed) by (authenticating|invalid) user .* <ip> port',
            @'Connection (reset|closed) by <ip> port',
            // More specific auth fail
            @'Failed password for .* from <ip>',
            //Other auth failures
            @'banner exchange: Connection from <ip> port [0-9]*: invalid format',
            @'Invalid user .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

OpenBSD

{
  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      // Use  `/var/log/authlog` to tail logs
      cmd: ['tail', '-fn0', '/var/log/authlog'],
      filters: {
        failedlogin: {
          regex: [
            // Auth fail
            @'Failed password for invalid user .* from <ip>',
            // Client disconnects during authentication
            @'Disconected from invalid user .* <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

Depending on the Linux distributions (or other UNIX systems), your OpenSSH logs may vary.

Check yourself what logs are printed by your SSH server!

Traefik

Configuration for the Traefik web server.

Traefik most often logs to stdout. If using Docker, it will be accessible using docker logs -n0 -f <traefik_container_name>. You can configure other ways to log traefik, see its documentation.

By default, Traefik logs to the Common Log Format, which is described in this section. But its log format is often configured to json, which gives much more detailed logs. That's what we'll describe here.

When logging using the json format, all is printed on one line, allowing for easy regex parsing.

Here's what it looks like pretty printed:

{
  "ClientAddr": "1.2.3.4:2048",
  "ClientHost": "1.2.3.4",
  "ClientPort": "2048",
  "DownstreamContentSize": 252,
  "DownstreamStatus": 200,
  "Duration": 1000,
  "OriginContentSize": 252,
  "OriginDuration": 900,
  "OriginStatus": 206,
  "Overhead": 10000,
  "RequestAddr": "domain.name",
  "RequestContentSize": 0,
  "RequestCount": 123,
  "RequestHost": "domain.name",
  "RequestMethod": "GET",
  "RequestPath": "/login",
  "RequestPort": "-",
  "RequestProtocol": "HTTP/2.0",
  "RequestScheme": "https",
  "RetryAttempts": 0,
  "RouterName": "my-service@docker",
  "ServiceAddr": "172.1.0.1:80",
  "ServiceName": "my-service@docker",
  "downstream_Header1": "...",
  "downstream_Header2": "...",
  "entryPointName": "websecure",
  "level": "info",
  "msg": "",
  "origin_Header1": "...",
  "origin_Header2": "...",
  "request_Header1": "...",
  "request_Header2": "...",
  "time": "YYYY-MM-DDTHH:MM:SS+UT:C0"
}

As a pattern, we'll use ip. See here.

A regex for traefik can look like this:

@'.*,"ClientHost":"<ip>",.*,"DownstreamStatus":401,.*,"RequestPath":"/login".*'

Or this:

@'.*,"ClientHost":"<ip>",.*,"DownstreamStatus":401,.*,"RequestHost":"domain.name",.*,"RequestPath":"/login".*'

Adjust domain.name according to your domain

Example:

{
  streams: {
    traefik: {
      cmd: ['tail', '-n0', '-f', '/var/lib/traefik/access.log'],
      filters: {
        website: {
          regex: [ @',"ClientHost":"<ip>",.*,"DownstreamStatus":403,.*,"RequestHost":"website.example",.*,"RequestPath":"/login",' ],
          retry: 3,
          retryperiod: '3h',
          actions: banFor('24h'),
        },
      },
    },
  },
}

You can decide that all 401, Unauthorized, and 403, Forbidden, are suspicious, and have a filter for any 401 and 403:

{
  streams: {
    traefik: {
      cmd: ['docker', 'logs', '-n0', '-f', 'traefik'],
      filters: {
        website: {
          regex: [ @',"ClientHost":"<ip>",.*,"DownstreamStatus":(401|403),' ],
          retry: 15,
          retryperiod: '5m',
          actions: banFor('1h'),
        },
      },
    },
  },
}

Web crawlers

Configuration to ban malicious Web crawlers. Here the idea is that most attackers will first try to scan what to attack on a server.

We stick to paths no unmalicious human should try by themselves.

List:

  • /.env
  • /password.txt
  • /passwords.txt
  • /config\.json
    • Rationale: .env and password(s).txt, config.json are often searched by bots, as they can contain sensitive information, such as database credentials. Do not include the third path if a client must retrieve a config.json file.
  • /info\.php
    • Rationale: info.pgp is a file often written for debugging purposes, which contains <?php phpinfo() ?>. This function exposes way too much information about the PHP environment, which is very useful when looking for security holes.
  • /wp-login\.php
  • /wp-includes
    • Rationale: Wordpress default authentication path. Do not include if you use Wordpress.
  • /owa/auth/logon.aspx
    • Rationale: Outlook authentication path. Do not include if Outlook is in use on your infrastructure.
  • /auth.html
  • /auth1.html
    • Rationale: I don't know what it is, but it has been tried by numerous bots on my webserver. Do not include if you use this path on your infrastructure.
  • /dns-query
    • Rationale: DOH (DNS Over HTTPS) standard path. Do not include if have a DOH server on your infrastructure.

(Feel free to add your own discoveries to this list!)

By adding (?:[^/" ]*/) at the beginning of each regex, we also cover all subpaths.

As a pattern, we'll use ip. See here.

Example:

{
  streams: {
    nginx: {
      cmd: ['...'], // see ./nginx.md
      filters: {
        slskd: {
          regex: [
            // (?:[^/" ]*/)* is a "non-capturing group" regex that allow for subpath(s)
            // example: /code/.env should be matched as well as /.env
            //           ^^^^^
            @'^<ip>.*"GET /(?:[^/" ]*/)*\.env ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*password.txt ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*passwords.txt ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*config\.json ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*info\.php ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*wp-login\.php',
            @'^<ip>.*"GET /(?:[^/" ]*/)*wp-includes',
            @'^<ip>.*"GET /(?:[^/" ]*/)*owa/auth/logon.aspx ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*auth.html ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*auth1.html ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*dns-query ',
          ],
          action: banFor('720h'),
        },
      },
    },
  },
}

Common Log Format

The common log format is supported by most webservers.

<remote_IP_address> - <client_user_name_if_available> [<timestamp>] "<request_method> <request_path> <request_protocol>" <HTTP_status> <content-length> "<request_referrer>" "<request_user_agent>" <number_of_requests_received_since_webserver_started> "<router_name>" "<server_URL>" <request_duration_in_ms>ms

Examples of reaction log regexes with a webserver that uses the CLF:

@'^<ip> .* "POST /auth/login HTTP/..." 401 [0-9]+ "https://domain.name/.*'
// ^        ^    ^                     ^                   ^      
// IP  Method    Path        Status Code                   Domain 
@'^<ip> .* "(GET|POST) /login HTTP/..." 401 '
// ^        ^          ^                ^
// IP       Method     Path             Status Code

Actions

Here, you will find examples of actions with different programs.

firewalld

The proposed way to ban IPs using firewalld uses one reaction zone.

The IPs are banned on all ports, meaning banned IPs won't be able to connect on any service.

Start/Stop

We first need to create this zone on startup.

{
  start: [
    // create the new zone
    ['firewall-cmd', '--permanent', '--new-zone', 'reaction'],
    // set its target to DROP
    ['firewall-cmd', '--permanent', '--set-target', 'DROP', '--zone', 'reaction'],
    // reload firewalld to be able to use the new zone
    ['firewall-cmd', '--reload'],
  ],
}

We want reaction to remove it when quitting:

{
  stop: [
    // remove the zone
    ['firewall-cmd', '--permanent', '--delete-zone', 'reaction'],
    // reload firewalld
    ['firewall-cmd', '--reload'],
  ],
}

Ban/Unban

Now we can ban an IP with this command:

{
  cmd: ['firewall-cmd', '--zone', 'reaction', '--add-source', '<ip>'],
}

And unban the IP with this command:

{
  cmd: ['firewall-cmd', '--zone', 'reaction', '--remove-source', '<ip>']
}

A good practice is to wrap the actions in a function with parameters:

local banFor(time) = {
  ban: {
    cmd: ['firewall-cmd', '--zone', 'reaction', '--add-source', '<ip>'],
  },
  unban: {
    cmd: ['firewall-cmd', '--zone', 'reaction', '--remove-source', '<ip>']
    after: time,
  },
};

See how to merge different actions in JSONnet FAQ

Real-world example

local banFor(time) = {
  ban: {
    cmd: ['firewall-cmd', '--zone', 'reaction', '--add-source', '<ip>'],
  },
  unban: {
    after: time,
    cmd: ['firewall-cmd', '--zone', 'reaction', '--remove-source', '<ip>']
  },
};

{
  patterns: {
    // IPs can be IPv4 or IPv6
    // ip46tables (C program also in this repo) handles running the good commands
    ip: {
      regex: '...', // See patterns.md
    },
  },

  start: [
    ['firewall-cmd', '--permanent', '--new-zone', 'reaction'],
    ['firewall-cmd', '--permanent', '--set-target', 'DROP', '--zone', 'reaction'],
    ['firewall-cmd', '--reload'],
  ],
  stop: [
    ['firewall-cmd', '--permanent', '--delete-zone', 'reaction'],
    ['firewall-cmd', '--reload'],
  ],

  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      cmd: ['journalctl', '-fn0', '-u', 'sshd.service'],
      filters: {
        failedlogin: {
          regex: [
            @'authentication failure;.*rhost=<ip>',
            @'Connection reset by authenticating user .* <ip>',
            @'Failed password for .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

iptables

The proposed way to ban IPs using iptables uses one reaction chain.

The IPs are banned on all ports, meaning banned IPs won't be able to connect on any service.

We use the ip46tables binary included alongside reaction, which permits to support both IPv4 and IPv6.

Start/Stop

We first need to create this chain on startup, and add it at the beginning of the INPUT iptables chain.

Docker & LXD users will need to add this rule to the FORWARD chain as well.

{
  start: [
    // create the `N`ew chain
    ['ip46tables', '-w', '-N', 'reaction'],
    // `I`nsert the chain at the beginning of INPUT & FORWARD
    ['ip46tables', '-w', '-I', 'INPUT', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-I', 'FORWARD', '-p', 'all', '-j', 'reaction'],
  ],
}

We want reaction to remove it when quitting:

{
  stop: [
    // `D`elete it from INPUT & FORWARD
    ['ip46tables', '-w', '-D', 'INPUT', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-D', 'FORWARD', '-p', 'all', '-j', 'reaction'],
    // `F`lush it (delete all the items in the chain)
    ['ip46tables', '-w', '-F', 'reaction'],
    // Remove it completely
    ['ip46tables', '-w', '-X', 'reaction'],
  ],
}

Ban/Unban

Now we can ban an IP with this command:

{
  cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP']
  // or
  cmd: [ 'sh', '-c', 'ip46tables -w -A reaction -s <ip> -j DROP']
}

And unban the IP with this command:

{
  cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP']
  // or
  cmd: [ 'sh', '-c', 'ip46tables -w -D reaction -s <ip> -j DROP']
}

A good practice is to wrap the actions in a function with parameters:

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
    after: time,
  },
};

See how to merge different actions in JSONnet FAQ

Real-world example

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};

{
  patterns: {
    // IPs can be IPv4 or IPv6
    // ip46tables (C program also in this repo) handles running the good commands
    ip: {
      regex: '...', // See patterns.md
    },
  },

  start: [
    ['ip46tables', '-w', '-N', 'reaction'],
    ['ip46tables', '-w', '-I', 'INPUT', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-I', 'FORWARD', '-p', 'all', '-j', 'reaction'],
  ],
  stop: [
    ['ip46tables', '-w', '-D', 'INPUT', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-D', 'FORWARD', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-F', 'reaction'],
    ['ip46tables', '-w', '-X', 'reaction'],
  ],

  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      cmd: ['journalctl', '-fn0', '-u', 'sshd.service'],
      filters: {
        failedlogin: {
          regex: [
            @'authentication failure;.*rhost=<ip>',
            @'Connection reset by authenticating user .* <ip>',
            @'Failed password for .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

nftables

The proposed way to ban IPs with nftables uses its own reaction table. Inside, there are two sets and two rules. One set/rule couple is for IPv4 and the other one is for IPv6.

The IPs are banned on all ports, meaning banned IPs won't be able to connect on any service of the host.

We don't make use of nftables timeouts because we need reaction to handle the lifecycle of a ban. If you choose to unban with nftables timeouts, you won't have access to all of reaction features, as it won't know what's currently banned, nor how to unban an IP: showing bans with reaction show and unbanning with reaction flush can't be supported.

Start/Stop

We create the table with relevant rules and filters

{
  start: [
    ['nft', |||
    table inet reaction {
      set ipv4bans {
        type ipv4_addr
        flags interval
        auto-merge
      }
      set ipv6bans {
        type ipv6_addr
        flags interval
        auto-merge
      }
      chain input {
        type filter hook input priority 0
        policy accept
        ip saddr @ipv4bans drop
        ip6 saddr @ipv6bans drop
      }
    }
||| ],
  ],
}

We want reaction to delete all its setup when quitting:

{
  stop: [
    ['nft', 'delete table inet reaction'],
  ],
}

🚧 auto-merge has been reported not to work well with nftables < 1.0.7

Ban/Unban

IPv4

Now we can ban an IPv4 address with this command:

{
  cmd: ['nft', 'add element inet reaction ipv4bans { <ipv4> }']
}

And unban the IP with this command:

{
  cmd: ['nft', 'delete element inet reaction ipv4bans { <ipv4> }']
}

IPv6

IPv6 works the same way:

{
  cmd: ['nft', 'add element inet reaction ipv6bans { <ipv6> }']
}
{
  cmd: ['nft', 'delete element inet reaction ipv6bans { <ipv6> }']
}

IPv4/IPv6

A very small utility, nft46, has been written to unify ipv4 and ipv6 commands:

{
  cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }']
}
{
  cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }']
}

The X in the command will be changed to 4 or 6 at runtime depending on the IP provided. There must be a X before the curly brackets, then this sequence: {, at least one space, exactly one IP (v4 or v6), at least one space, a }. You can do it!

Wrapping this in a reusable JSONnet function

local banFor(time) = {
  ban: {
    cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }'],
  },
  unban: {
    cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }'],
    after: time,
  },
};

Real-world example

local banFor(time) = {
  ban: {
    cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }'],
  },
  unban: {
    cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }'],
    after: time,
  },
};

{
  patterns: {
    ip: {
      regex: '...', // See patterns.md
    },
  },

  start: [
    ['nft', |||
    table inet reaction {
      set ipv4bans {
        type ipv4_addr
        flags interval
        auto-merge
      }
      set ipv6bans {
        type ipv6_addr
        flags interval
        auto-merge
      }
      chain input {
        type filter hook input priority 0
        policy accept
        ip saddr @ipv4bans drop
        ip6 saddr @ipv6bans drop
      }
    }
||| ],
  ],
  stop: [
    ['nft', 'delete table inet reaction'],
  ],

  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      cmd: ['journalctl', '-fn0', '-u', 'sshd.service'],
      filters: {
        failedlogin: {
          regex: [
            @'authentication failure;.*rhost=<ip>',
            @'Connection reset by authenticating user .* <ip>',
            @'Failed password for .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

OpenBSD PacketFilter

The proposed way to ban IPs using openBSD pf uses one t_reaction table. We first need to create this table on our main /etc/pf.conf file.

table <t_reaction> persist

The IPs are banned on all ports, meaning banned IPs won't be able to connect on any service.

Start/Stop

There is no specific action taken on start. On stop, all IP address contained in 't_reaction' will be flushed

  start: [
  ],
  stop: [
    ['pfctl', '-t', 't_reaction', '-T',  'flush', '<ip>'],
  ],

Ban/Unban

Then, in reaction.conf file, we need to specify pfctl behaviour and alter ban and unban command

local iptables(args) = [ 'pfctl'] + args;
local banFor(time) = {
  ban: {
    cmd: ['pfctl', '-t', 't_reaction', '-T',  'add', '<ip>'],
  },
  unban: {
    after: time,
    cmd: ['pfctl', '-t', 't_reaction', '-T',  'del', '<ip>'],
  },
};

See how to merge different actions in JSONnet FAQ

Real-world example

local banFor(time) = {
  ban: {
    cmd: ['pfctl', '-t', 't_reaction', '-T',  'add', '<ip>'],
  },
  unban: {
    after: time,
    cmd: ['pfctl', '-t', 't_reaction', '-T',  'del', '<ip>'],
  },
};
{
  patterns: {
    ip: {
      regex: @'(?:(?:[ 0-9 ]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})',
    },
  },
  start: [
  ],
  stop: [
    ['pfctl', '-t', 't_reaction', '-T',  'flush', '<ip>'],
  ],
  streams: {
    ssh: {
      cmd: [ 'tail', '-n0', '-f', '/var/log/authlog' ],
      filters: {
        failedlogin: {
          regex: [
            // Auth fail
            @'Failed password for invalid user .* from <ip>',
            // Client disconnects during authentication
            @'Disconnected from invalid user .* <ip>',
          ],  
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}

SMS alerting with Free Mobile

🇬🇧 As Free Mobile is a French Mobile/Internet Access Provider, this wiki entry is in French.

🥐 Free Mobile propose une API très simple pour s'envoyer des SMS à soi-même, quand on a un abonnement mobile.

Il faut d'abord activer les Notifications par SMS sur son espace client.

On reçoit une clé d'API, qu'on appellera PASS.

Il suffit ensuite d'envoyer une requête HTTP GET :

https://smsapi.free-mobile.fr/sendmsg?user=USER&pass=PASS&msg=MSG

Il serait donc possible de faire un simple cURL :

curl https://smsapi.free-mobile.fr/sendmsg?user=12345678&pass=abcdefghijlkmnop&msg=coucou

Cependant :

  • Comme le paramètre msg est dans l'URL, il doit être encodé avec les %20 caractères qui vont bien.
  • Si on veut garder reaction.jsonnet dans un repository Git, on préfèrera garder les secrets (user, pass) dans des fichiers externes lisibles seulement par root (ou par reaction, si vous lui avez créé un user spécfique)

cURL permet de faire tout ça en une commande, donc pas besoin de faire un script shell.

On utilise l'option --variable ajoutée dans curl 8.3.0, qui date de Septembre 2023.

Il se peut que votre distribution ne l'ait pas encore packagée.

Sans plus attendre, voilà une fonction JSONnet avec la commande cURL complète :

local sendsms(message) = {
  sendsms: {
    cmd: [
      "${pkgs.curl}/bin/curl",
      // Retourner un code d'erreur si le code de retour HTTP indique une erreur
      "--fail",
      // Ne rien afficher par défaut
      "--silent",
      // Quand même afficher les erreurs
      "--show-error",
      // Stocker dans la variable USER le contenu de /var/secrets/mobileapi-user
      "--variable", "USER@/var/secrets/mobileapi-user",
      // Stocker dans la variable PASS le contenu de /var/secrets/mobileapi-pass
      "--variable", "PASS@/var/secrets/mobileapi-pass",
      // Stocker dans la variable MSG  le contenu du message
      "--variable", "MSG=" + message,
      // Enlever les espaces et retours à la ligne des USER et PASS
      // Encoder au format URL le MSG à envoyer
      "--expand-url", "https://smsapi.free-mobile.fr/sendmsg?user={{USER:trim}}&pass={{PASS:trim}}&msg={{MSG:trim:url}}",
    ],
  },
};

Exemples de la vraie vie

Quand le service myservice affiche une erreur, me l'envoyer par SMS.

local sendsms(message) = {
  sendsms: {
    cmd: [
      "${pkgs.curl}/bin/curl",
      "--fail",
      "--silent",
      "--show-error",
      "--variable", "USER@/var/secrets/mobileapi-user",
      "--variable", "PASS@/var/secrets/mobileapi-pass",
      "--variable", "MSG=" + message,
      "--expand-url", "https://smsapi.free-mobile.fr/sendmsg?user={{USER:trim}}&pass={{PASS:trim}}&msg={{MSG:trim:url}}",
    ],
  },
};

{
  patterns: {
    untilEOL: '.*$'
  },

  streams: {
    myservice: {
      cmd: ['journalctl', '-fn0', '-u', 'myservice.service'],
      filters: {
        errors: {
          regex: [ @'ERROR <untilEOL>' ],
          actions: sendsms('<untilEOL>'),
        },
      },
    },
  },
}

Quand on bannit une IP, me l'envoyer par SMS aussi.

local sendsms(message) = {
  sendsms: {
    cmd: [
      "${pkgs.curl}/bin/curl",
      "--fail",
      "--silent",
      "--show-error",
      "--variable", "USER@/var/secrets/mobileapi-user",
      "--variable", "PASS@/var/secrets/mobileapi-pass",
      "--variable", "MSG=" + message,
      "--expand-url", "https://smsapi.free-mobile.fr/sendmsg?user={{USER:trim}}&pass={{PASS:trim}}&msg={{MSG:trim:url}}",
    ],
  },
};

local banFor(time) = {
  // la configuration de son firewall qui va bien
};

{
  patterns: {
    untilEOL: '.*$'
  },

  streams: {
    myservice: {
      cmd: ['journalctl', '-fn0', '-u', 'myservice.service'],
      filters: {
        errors: {
          regex: [ @'ERROR <untilEOL>' ],
          actions: banFor('48h') + sendsms('<ip> banned for 48h'),
        },
      },
    },
  },
}

Configurations

Here, you will find examples of full configurations.

Configs of ppom

server.jsonnet

// This is the extensive configuration used on a **real** server!

local banFor(time) = {
  ban: {
    cmd: ['ip46tables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['ip46tables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};

{
  patterns: {
    // IPs can be IPv4 or IPv6
    // ip46tables (C program also in this repo) handles running the good commands
    ip: {
      regex: '...', // See patterns.md
    },
  },

  start: [
    ['ip46tables', '-w', '-N', 'reaction'],
    ['ip46tables', '-w', '-I', 'INPUT', '-p', 'all', '-j', 'reaction'],
  ],
  stop: [
    ['ip46tables', '-w', '-D', 'INPUT', '-p', 'all', '-j', 'reaction'],
    ['ip46tables', '-w', '-F', 'reaction'],
    ['ip46tables', '-w', '-X', 'reaction'],
  ],

  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      cmd: ['journalctl', '-fn0', '-u', 'sshd.service'],
      filters: {
        failedlogin: {
          regex: [
            @'authentication failure;.*rhost=<ip>',
            @'Connection reset by authenticating user .* <ip>',
            @'Failed password for .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },

    // Ban hosts which knock on closed ports.
    // It needs this iptables chain to be used to drop packets:
    // ip46tables -N log-refuse
    // ip46tables -A log-refuse -p tcp --syn -j LOG --log-level info --log-prefix 'refused connection: '
    // ip46tables -A log-refuse -m pkttype ! --pkt-type unicast -j nixos-fw-refuse
    // ip46tables -A log-refuse -j DROP
    kernel: {
      cmd: ['journalctl', '-fn0', '-k'],
      filters: {
        portscan: {
          regex: ['refused connection: .*SRC=<ip>'],
          retry: 4,
          retryperiod: '1h',
          actions: banFor('720h'),
        },
      },
    },
    // Note: nextcloud and vaultwarden could also be filters on the nginx stream
    // I did use their own logs instead because it's less logs to parse than the front webserver

    // Ban hosts failing to connect to Nextcloud
    nextcloud: {
      cmd: ['journalctl', '-fn0', '-u', 'phpfpm-nextcloud.service'],
      filters: {
        failedLogin: {
          regex: [
            @'"remoteAddr":"<ip>".*"message":"Login failed:',
            @'"remoteAddr":"<ip>".*"message":"Trusted domain error.',
          ],
          retry: 3,
          retryperiod: '1h',
          actions: banFor('1h'),
        },
      },
    },

    // Ban hosts failing to connect to vaultwarden
    vaultwarden: {
      cmd: ['journalctl', '-fn0', '-u', 'vaultwarden.service'],
      filters: {
        failedlogin: {
          actions: banFor('2h'),
          regex: [@'Username or password is incorrect\. Try again\. IP: <ip>\. Username:'],
          retry: 3,
          retryperiod: '1h',
        },
      },
    },

    // Used with this nginx log configuration:
    // log_format withhost '$remote_addr - $remote_user [$time_local] $host "$request" $status $bytes_sent "$http_referer" "$http_user_agent"';
    // access_log /var/log/nginx/access.log withhost;
    nginx: {
      cmd: ['tail', '-n0', '-f', '/var/log/nginx/access.log'],
      filters: {
        // Ban hosts failing to connect to Directus
        directus: {
          regex: [
            @'^<ip> .* "POST /auth/login HTTP/..." 401 [0-9]+ .https://directusdomain',
          ],
          retry: 6,
          retryperiod: '4h',
          actions: banFor('4h'),
        },

        // Ban hosts presenting themselves as bots of ChatGPT
        gptbot: {
          regex: [@'^<ip>.*GPTBot/1.0'],
          action: banFor('720h'),
        },

        // Ban hosts failing to connect to slskd
        slskd: {
          regex: [
            @'^<ip> .* "POST /api/v0/session HTTP/..." 401 [0-9]+ .https://slskddomain',
          ],
          retry: 3,
          retryperiod: '1h',
          actions: banFor('6h'),
        },

        // Ban suspect HTTP requests
        // Those are frequent malicious requests I got from bots
        // Make sure you don't have honnest use cases for those requests, or your clients may be banned for 2 weeks!
        suspectRequests: {
          regex: [
            // (?:[^/" ]*/)* is a "non-capturing group" regex that allow for subpath(s)
            // example: /code/.env should be matched as well as /.env
            //           ^^^^^
            @'^<ip>.*"GET /(?:[^/" ]*/)*\.env ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*info\.php ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*owa/auth/logon.aspx ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*auth.html ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*auth1.html ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*password.txt ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*passwords.txt ',
            @'^<ip>.*"GET /(?:[^/" ]*/)*dns-query ',
            // Do not include this if you have a Wordpress website ;)
            @'^<ip>.*"GET /(?:[^/" ]*/)*wp-login\.php',
            @'^<ip>.*"GET /(?:[^/" ]*/)*wp-includes',
            // Do not include this if a client must retrieve a config.json file ;)
            @'^<ip>.*"GET /(?:[^/" ]*/)*config\.json ',
          ],
          action: banFor('720h'),
        },
      },
    },
  },
}

activitywatch.jsonnet

// Those strings will be substitued in each shell() call
local substitutions = [
  ['OUTFILE', '"$HOME/.local/share/watch/logs-$(date +%F)"'],
  ['DATE', '"$(date "+%F %T")"'],
];

// Substitue each substitutions' item in string
local sub(str) = std.foldl(
  (function(changedstr, kv) std.strReplace(changedstr, kv[0], kv[1])),
  substitutions,
  str
);
local shell(prg) = [
  'sh',
  '-c',
  sub(prg),
];

local log(line) = shell('echo DATE ' + std.strReplace(line, '\n', ' ') + '>> OUTFILE');

{
  start: [
    shell('mkdir -p "$(dirname OUTFILE)"'),
    log('start'),
  ],

  stop: [
    log('stop'),
  ],

  patterns: {
    all: { regex: '.*' },
  },

  streams: {
    // Be notified about each window focus change
    // FIXME DOESN'T WORK
    sway: {
      cmd: shell(|||
        swaymsg -rm -t subscribe "['window']" | jq -r 'select(.change == "focus") | .container | if has("app_id") and .app_id != null then .app_id else .window_properties.class end'
      |||),
      filters: {
        send: {
          regex: ['^<all>$'],
          actions: {
            send: { cmd: log('focus <all>') },
          },
        },
      },
    },

    // Be notified when user is away
    swayidle: {
      // FIXME echo stop and start instead?
      cmd: ['swayidle', 'timeout', '30', 'echo sleep', 'resume', 'echo resume'],
      filters: {
        send: {
          regex: ['^<all>$'],
          actions: {
            send: { cmd: log('<all>') },
          },
        },
      },
    },

    // Be notified about tmux activity
    // Limitation: can't handle multiple concurrently attached sessions
    // tmux: {
    //   cmd: shell(|||
    //     LAST_TIME="0"
    //     LAST_ACTIVITY=""
    //     while true;
    //     do
    //       NEW_TIME=$(tmux display -p '#{session_activity}')
    //       if [ -n "$NEW_TIME" ] && [ "$NEW_TIME" -gt "$LAST_TIME" ]
    //       then
    //         LAST_TIME="$NEW_TIME"
    //         NEW_ACTIVITY="$(tmux display -p '#{pane_current_command} #{pane_current_path}')"
    //         if [ -n "$NEW_ACTIVITY" ] && [ "$NEW_ACTIVITY" != "$LAST_ACTIVITY" ]
    //         then
    //           LAST_ACTIVITY="$NEW_ACTIVITY"
    //           echo "tmux $NEW_ACTIVITY"
    //         fi
    //       fi
    //       sleep 10
    //     done
    //   |||),
    //   filters: {
    //     send: {
    //       regex: ['^tmux <all>$'],
    //       actions: {
    //         send: { cmd: log('tmux <all>') },
    //       },
    //     },
    //   },
    // },

    // Be notified about firefox activity
    // TODO
  },
}

OpenBSD setup

Building the binary

Binary needs to be compiled for OpenBSD OS.

git clone <reaction git repo>
cd reaction
GOOS=openbsd make reaction

Service

To use reaction as service on OpenBSD, you will need to create a new file on /etc/rc.d/ which will define your service, such as :


#!/bin/ksh

daemon="/usr/local/bin/reaction"
daemon_flags="start -c /root/reaction.conf"

. /etc/rc.d/rc.subr

rc_reload=NO
rc_bg=YES

rc_cmd $1

Then you need enable it with rcctl enable reaction.

Configuration

Configuration differs from regular linux one as OpenBSD uses PacketFilter instead of iptables/nftables. Also, OpenBSD configuration doesn't need the additional ip46tables binary, and can be fully setup from configuration file.

A configuration exemple is present here.

To infinity and beyond

After configuring your instance and setting up your service, you can now enjoy it by running rcctl start reaction.

Reaction migration from v1 to v2

For now reaction v2 is released as 2.0.0-rc1, which means it's considered beta.

Feel free to try it, if you feel playful! Error reports are very welcome. A database bug is already known, see Database below.

Configuration

A working v1 configuration should work as well on reaction v2.

There are small syntax differences between the two regex engines:

However, most regexes should be unaffected.

Please report any configuration / regex problem you encounter while upgrading!

Database

A script is provided in the 2.0.0-rc1 release.

It must be run with the same user as reaction, in the same directory where the reaction database is stored.

So it must most probably be run as root in /var/lib/reaction, ie. with default setup.

🚧 Entries are discarded too soon in the database for now: it's something that needs a fix.

CLI

The CLI is the same, but more flexible. Mixing positional arguments and flags is now supported.

Packaging

Reaction now features man pages and shell completions for bash/fish/zsh! Documentation for their installation will come after.

However the Debian package has not already been rewritten. You may want to wait for the stable release.

Streams

on reaction v1, only the cmd stdout was read. reaction v2 now reads both stdout and stderr.