Fail2ban has been an important security tool I’ve been using in my servers for many years. However sometimes is difficult to configure, lacks certain features and can consume too many resources.
Recently I’ve switched to Reaction, a new tool with the same philosophy: monitor logs and execute some actions based on log matches. Reaction is written in Rust, so it’s fast and resource efficient.
My servers use Fedora CoreOS, so I try to use containers for most of my workloads. Reaction doesn’t provide an official container image, so I just build mine:
ARG FEDORA_VERSION="43"
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION} AS builder
# https://framagit.org/ppom/reaction/-/releases
ARG REACTION_VERSION="2.3.0"
RUN dnf update -y --setopt=install_weak_deps=False --nodocs && \
dnf install -y --setopt=install_weak_deps=False --nodocs rust cargo @c-development git && \
dnf clean all && \
git clone --depth 1 --branch v${REACTION_VERSION} https://framagit.org/ppom/reaction.git /src/reaction && \
cd /src/reaction && \
make && \
make install
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION}
COPY --from=builder /usr/local/bin /usr/local/bin
RUN dnf update -y --setopt=install_weak_deps=False --nodocs && \
dnf install -y --setopt=install_weak_deps=False --nodocs tini nftables conntrack-tools iproute systemd && \
dnf clean all && \
mkdir -p /var/lib/reaction /run/reaction
VOLUME /var/lib/reaction
WORKDIR /var/lib/reaction
ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
CMD ["/usr/local/bin/reaction", "start", "-c", "/etc/reaction/reaction.jsonnet"]
I create these quadlet files to create the volume and the container:
/etc/containers/systemd/reaction.volume:
[Volume]
VolumeName=reaction
/etc/containers/systemd/reaction.container:
[Container]
AddCapability=NET_ADMIN
ContainerName=reaction
DropCapability=ALL
GroupAdd=190
Image=quay.io/jorti/reaction:v2.3.0
Network=host
NoNewPrivileges=true
ReadOnly=true
Volume=reaction.volume:/var/lib/reaction
Volume=/etc/reaction:/etc/reaction:ro
Volume=/var/log:/var/log:ro
Volume=/etc/machine-id:/etc/machine-id:ro
PodmanArgs=--memory 256m
PodmanArgs=--security-opt label=disable
AutoUpdate=registry
[Install]
WantedBy=multi-user.target default.target
[Service]
Restart=always
RestartSec=5
[Unit]
Description=Reaction log monitoring and banning
Finally, this is the reaction configuration I’m using to ban IPs probing the services of my home lab:
/etc/reaction/reaction.jsonnet:
local banFor(time) = {
ban4: {
cmd: ['nft', 'add element inet reaction ipv4bans { <ip> }'],
ipv4only: true,
},
ban6: {
cmd: ['nft', 'add element inet reaction ipv6bans { <ip> }'],
ipv6only: true,
},
unban4: {
cmd: ['nft', 'delete element inet reaction ipv4bans { <ip> }'],
after: time,
ipv4only: true,
},
unban6: {
cmd: ['nft', 'delete element inet reaction ipv6bans { <ip> }'],
after: time,
ipv6only: true,
},
};
local killConnection() = {
kill: {
cmd: ['conntrack', '-D -s <ip>'],
},
};
{
patterns: {
ip: {
// patterns can have a special 'ip' type that matches both ipv4 and ipv6
// or 'ipv4' or 'ipv6' to match only that ip version
type: 'ip',
ignore: ['::1'],
// they can also ignore whole CIDR ranges of ip
ignorecidr: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7', 'fe80::/10'],
// last but not least, patterns of type ip, ipv4, ipv6 can also group their matched ips by mask
// ipv4mask: 30
// this means that ipv6 matches will be converted to their network part.
ipv6mask: 64,
// for example,"2001:db8:85a3:9de5::8a2e:370:7334" will be converted to "2001:db8:85a3:9de5::/64".
},
},
start: [
['nft', |||
table inet reaction {
set ipv4bans {
type ipv4_addr
flags interval
auto-merge
}
set ipv6bans {
type ipv6_addr
flags interval
auto-merge
}
chain prerouting {
type filter hook prerouting priority filter
policy accept
ip saddr @ipv4bans log prefix "reaction/drop: " limit rate 10/second burst 5 packets
ip saddr @ipv4bans counter drop
ip6 saddr @ipv6bans log prefix "reaction/drop: " limit rate 10/second burst 5 packets
ip6 saddr @ipv6bans counter drop
}
}
|||],
],
stop: [
['nft', 'delete table inet reaction'],
],
streams: {
// Ban hosts failing to connect via ssh in Forgejo
forgejo_ssh: {
cmd: ['journalctl', '-fn0', '-u', 'forgejo.service'],
filters: {
failedlogin: {
regex: [
@'.*sshConnectionFailed\(\) .* Failed connection from \[?<ip>\]?:\d+',
],
retry: 3,
retryperiod: '2h',
actions: banFor('90d') + killConnection(),
},
},
},
// Ban hosts failing to connect to Forgejo
forgejo: {
cmd: ['journalctl', '-fn0', '-u', 'forgejo.service'],
filters: {
failedlogin: {
regex: [
@'.*(Failed authentication attempt|invalid credentials|Attempted access of unknown user).* from \[?<ip>\]?',
],
retry: 3,
retryperiod: '2h',
actions: banFor('90d') + killConnection(),
},
},
},
// Ban hosts failing to connect to Nextcloud
nextcloud: {
cmd: ['tail', '-n0', '-F', '/var/log/nextcloud/nextcloud.log'],
filters: {
failedlogin: {
regex: [
@'^\{(?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*),?\s*"remoteAddr":"<ip>"(?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*),?\s*"message":"Login failed:',
@'^\{(?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*),?\s*"remoteAddr":"<ip>"(?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*),?\s*"message":"Trusted domain error.',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Jellyfin
jellyfin: {
cmd: ['journalctl', '-fn0', '-u', 'jellyfin.service'],
filters: {
failedlogin: {
regex: [
@'^.*Authentication request for .* has been denied \(IP: <ip>\)\.',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Seerr
seerr: {
cmd: ['journalctl', '-fn0', '-u', 'seerr.service'],
filters: {
failedlogin: {
regex: [
@'^.*\[Auth\]: Failed login attempt from user with incorrect .* credentials \{"account":\{"ip":"<ip>".*$',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Authelia
authelia: {
cmd: ['journalctl', '-fn0', '-u', 'authelia.service'],
filters: {
failedlogin: {
regex: [
@'^.*Unsuccessful (1FA|TOTP|Duo|U2F) authentication attempt by user .*remote_ip"?(:|=)"?<ip>"?.*$',
@'^.*user not found.*path=/api/reset-password/identity/start remote_ip"?(:|=)"?<ip>"?.*$',
@'^.*Sending an email to user.*path=/api/.*/start remote_ip"?(:|=)"?<ip>"?.*$',
@'^.*Error occurred getting details for user with username input .* which usually indicates they do not exist.*remote_ip"?(:|=)"?<ip>"?.*$',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Vaultwarden
vaultwarden: {
cmd: ['journalctl', '-fn0', '-u', 'vaultwarden.service'],
filters: {
failedlogin: {
regex: [
@'^.*?Username or password is incorrect\. Try again\. IP: <ip>\. Username:.*$',
@'^.*Invalid admin token\. IP: <ip>.*$',
@'^.*\[ERROR\] Invalid TOTP code! Server time: (.*) UTC IP: <ip>$',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Immich
immich: {
cmd: ['journalctl', '-fn0', '-u', 'immich-server.service'],
filters: {
failedlogin: {
regex: [
@'^.*Failed login attempt for user .* from ip address\s?<ip>',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to Audiobookshelf
audiobookshelf: {
cmd: ['journalctl', '-fn0', '-u', 'audiobookshelf.service'],
filters: {
failedlogin: {
regex: [
@'^.*\[Auth\] Failed login attempt for username .* from ip <ip>.*',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts failing to connect to FMD
fmd: {
cmd: ['journalctl', '-fn0', '-u', 'fmd.service'],
filters: {
failedlogin: {
regex: [
@'^.*"remoteIp":"<ip>:?\d*".*"message":"(?:failed|blocked) login attempt".*$',
],
retry: 3,
retryperiod: '2h',
actions: banFor('24h') + killConnection(),
},
},
},
// Ban hosts attacking or crawling Traefik
traefik: {
cmd: ['tail', '-n0', '-F', '/var/log/traefik/access.log'],
filters: {
// Ban suspect HTTP requests
// Those are frequent malicious requests I got from bots
// Make sure you don't have honest use cases for those requests, or your clients may be banned for 3 months!
suspectRequests: {
regex: [
// https://reaction.ppom.me/filters/web-crawlers.html
// @'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*\.env".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*info\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*phpinfo".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*Dockerrun\.aws\.json".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*sftp\.json".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*sftp-config\.json".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*security\.txt".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*wp-login\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*wp-includes\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*wp-admin\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*%%2e%%2e\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*etc/(?:hosts|passwd|shadow)".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*wlwmanifest\.xml".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*\.DS_Store\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*login\.aspx".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*filemanager\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*blog\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*temp\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*old\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*backup\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*wordpress\S*".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*\.git/config".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*\.svn/entries".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*debug/pprof".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*jsquery\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*updates\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*plugins\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*post\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*manager\.php".*',
@'.*,"ClientHost":"<ip>",.*,"RequestPath":"/(?:[^/" ]*/)*xmlrpc\.php".*',
],
actions: banFor('90d') + killConnection(),
},
},
},
},
}