From Fail2ban to Reaction


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(),
        },
      },
    },
  },
}

See also