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:
- Its README
- The tutorial, in French and in English.
- The example, fully commented configuration file, in YAML or in JSONnet.
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
❤️ 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 tail
ed 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
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.
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 toPOST /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 toPOST /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.
- Rationale: info.pgp is a file often written for debugging purposes, which contains
/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 withnftables
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 withreaction show
and unbanning withreaction 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 parroot
(ou parreaction
, 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
: more or less the same conf which is on my principal homeserver.activitywatch.jsonnet
: a WIP attempt to do a small activity-watch cloneexample.jsonnet
, which is the upstream example.
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
.