reaction, remplaçant de fail2ban

This article is also available in 🇬🇧 English.

Enjeux🔗

Cet article est organisé de manière à montrer qu'un programme que j'ai conçu est utile à la société aux personnes qui administrent des serveurs.

Problème🔗

De nombreuses personnes écrivent des programmes (appelés robots ou bots) qui scannent Internet et cherchent à s'introduire sur les serveurs (les ordinateurs qui font fonctionner Internet) qu'ils trouvent. Le plus souvent, une fois que ces robots ont pris le contrôle d'un serveur, ils vont :

Tous ces exemples sont problématiques. Le dernier l'est particulièrement, car un serveur contient souvent des données sensibles des personnes qui l'utilisent.

Par exemple, à l'association Picasoft, on héberge une application de messagerie alternative, face aux géants du web. Si un robot accède au serveur, il peut potentiellement récupérer toutes leurs conversations, ce qui est vrai pour n'importe quel service en ligne.

C'est donc important de bien protéger ses serveurs.

Solution🔗

L'honorable fail2ban est un logiciel qui permet de bannir les robots qui tentent bêtement de briser les barrières des serveurs.

Le principe est simple :

Problème de la solution🔗

Fail2ban fait correctement son travail. Cependant, j'ai deux grands reproches à lui faire :

Lenteur / consommation🔗

Pour traiter les logs de plusieurs services de mon serveur, fail2ban prenait à peu près 1h/semaine de temps de travail (CPU). Ça peut paraître peu, mais c'est beaucoup, vu le peu de trafic qu'il y a sur mon serveur. fail2ban ne fait que lire du texte et envoyer des actions au pare-feu !

Pour comparaison, c'est autant que le service Funkwhale sur mon serveur, une alternative à Spotify, qui fait bien plus : Funkwhale me permet d'écouter ma musique, depuis mes différents appareils, et je l'utilise ~ 24h par semaine. C'est une tâche nettement plus lourde.

Fail2ban consomme également beaucoup de mémoire vive, facilement 300 Mo.

Sur un serveur avec plus de trafic, ça peut rapidement devenir un vrai problème. Je voulais donc une solution qui consomme moins.

Complexité d'une configuration très abstraite...🔗

fail2ban dispose d'une configuration par défaut très étoffée.

Il y a une grosse ingénierie préalable avec pour objectif de rendre la configuration le plus simple possible pour l'admin.

Elle vient avec des règles par défaut pour plein de pare-feux et services différents, réparties en 160 fichiers, et comprenant 2600 lignes de configuration (8900 avec les commentaires).

Finalement, elle rend plus difficile de s'approprier le sujet : le fichier pour l'action iptables fait 45 lignes de code (170 avec les commentaires). C'est un mélange de TOML et d'une 2e couche mal documentée de substitutions, faites par fail2ban, qui permet de définir des variables, des options etc.

Tant qu'on peut se contenter de la configuration par défaut, ce n'est pas un gros problème. Mais dès qu'on veut faire sa propre configuration pour un service qui n'est pas supporté, ou parce que notre pare-feu est un petit peu différent, ça devient beaucoup trop complexe.

...mais pas très flexible🔗

Puisqu'on a un programme qui lit quelque chose en entrée, et qui exécute des commandes en sortie, pourquoi ne pas présenter les choses aussi simplement ?

Je voulais une solution sans configuration par défaut, qui permette aux admins de construire simplement la leur, à partir d'exemples bien documentés, et de n'avoir que les couches d'abstraction que les admins estiment nécessaire.

Disons que je veux exécuter une action quand on active une certaine URL (cachée ou pas) sur mon serveur. Plutôt que de créer une application web, avec des "webhooks" etc, quelques lignes devraient suffire pour exécuter une action arbitraire.

Je voulais un outil aussi adapté pour faire autre chose que des bans de robots.

Solution au problème de la solution🔗

Après cette très longue introduction, permettez-moi de vous présenter reaction !

Vitesse🔗

Je n'ai pas d'expertise dans le langage Go. Il est sûrement possible d'améliorer ses performances, mais sa consommation actuelle me satisfait déjà pleinement.

Sur mon serveur, sur lequel sont analysés bien plus de logs que seulement ceux du service SSH, reaction (+ toutes les commandes lancées) consomme 5 min de temps de travail par semaine et 25 Mo de mémoire vive.

À charge de travail égale, fail2ban consommait 1 heure et 300 Mo, soit 30 fois et 10 fois plus de ressources.

Configuration🔗

À partir de là, ça devient technique. À vos risques et périls.

Trois langages de configuration sont disponibles : JSON, YAML, JSONnet (❤️). Je ne vous présente pas les deux premiers, mais je parle du dernier à la fin.

On commence par préciser comment reaction va reconnaitre une IP.

patterns:
  ip:
    regex: '(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})'

En remplacement des jails de fail2ban, on a des streams, qui définissent une source de données (par exemple tail -f /var/log/nginx/access.log pour nginx).

streams:
  ssh:
    cmd: ['journalctl', '-fu', 'sshd.service']

À ces streams, on attache un ou plusieurs filters. Ce sont des groupes d'expressions régulières. C'est aussi sur un filter qu'on décide du nombre de mauvais essais (retry) qu'on accorde à une IP avant de réagir.

streams:
  ssh:
    cmd: ['journalctl', '-fu', 'sshd.service']
    filters:
      fail:
        regex:
          - 'authentication failure;.*rhost=<ip>'
        retry: 3
        retryperiod: '3h'

À un filter, on rajoute une ou plusieurs actions, qui seront exécutées quand il se déclenche.

streams:
  ssh:
    cmd: ['journalctl', '-fu', 'sshd.service']
    filters:
      fail:
        regex:
          - 'authentication failure;.*rhost=<ip>'
        retry: 3
        retryperiod: '3h'
        actions:
          ban:
            cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP']

Elles peuvent être effectuées tout de suite, ou être délayées avec after, ce qui permet de bannir une IP maintenant, et de la débannir quelque temps plus tard.

streams:
  ssh:
    cmd: ['journalctl', '-fu', 'sshd.service']
    filters:
      fail:
        regex:
          - 'authentication failure;.*rhost=<ip>'
        retry: 3
        retryperiod: '3h'
        actions:
          ban:
            cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP']
          unban:
            cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP']
            after: '24h'

Ces commandes iptables ont besoin de l'existence de la chain reaction dans le pare-feu. Au démarrage, on demande à reaction de la créer, et l'ajouter à la chaine INPUT, qui contrôle les connexions entrantes.

start:
  - [ 'iptables', '-w', '-N', 'reaction' ]
  - [ 'iptables', '-w', '-I', 'INPUT', '-p', 'all', '-j', 'reaction' ]

On lui demande aussi de la vider et la supprimer en quittant :

stop:
  - [ 'iptables', '-w', '-D', 'INPUT', '-p', 'all', '-j', 'reaction' ]
  - [ 'iptables', '-w', '-F', 'reaction' ]
  - [ 'iptables', '-w', '-X', 'reaction' ]

Et voilà. En 26 lignes de configuration, sans aucune configuration par défaut, reaction va surveiller les connexions SSH et bannir les connexions malveillantes pendant 24h au bout de 3 essais infructueux.

JSONnet🔗

C'est un langage simple et flexible, avec une syntaxe proche de Javascript et JSON. Par défaut, c'est juste un langage qui ressemble à JSON, mais en plus flexible :

// On peut mettre des commentaires
{
  // Pas besoin de mettre des "quotes" partout
  streams: {
    ssh: {
      // On peut avoir des virgules après le dernier élément ↓
      cmd: [' journalctl', '-fu', 'sshd.service'],
    }
  }
}

Pour éviter les répétitions, on écrit des variables et des fonctions.

local hour2second(i) = i * 60 * 60;
{
  seconds: [
    hour2second(1),
    hour2second(3),
    hour2second(5),
  ],
}

JSONnet fonctionne comme un préprocesseur qui va générer une structure de données compatible JSON. C'est ce résultat qui est donné à reaction.

{
  "seconds": [ 3600, 10800, 18000 ]
}

C'est un peu comme le langage Nix, mais avec une syntaxe plus agréable.

Maintenant que JSONnet est présenté, on reprend l'exemple précédemment écrit en YAML. On peut le réécrire en JSONnet en imaginant qu'on rajoute un deuxième stream pour protéger un autre service.

On veut éviter d'écrire plusieurs fois les commandes iptables (une fois suffit amplement 😆).

On va donc écrire une fonction banFor(), qui prend en argument la durée selon laquelle on va bannir les IPs, et retourne une liste d'actions. On peut la réutiliser sur chaque stream.

local banFor(time) = {
  ban: {
    cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
  unban: {
    after: time,
    cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
  },
};
{
  patterns: {
    ip: {
      regex: @'(([0-9]{1,3}\.){3}[0-9]{1,3})|([0-9a-fA-F:]{2,90})',
    },
  },
  start: [
    [ 'iptables', '-w', '-N', 'reaction' ],
    [ 'iptables', '-w', '-I', 'INPUT', '-p', 'all', '-j', 'reaction' ],
  ],
  stop: [
    [ 'iptables', '-w,', '-D', 'INPUT', '-p', 'all', '-j', 'reaction' ],
    [ 'iptables', '-w', '-F', 'reaction' ],
    [ 'iptables', '-w', '-X', 'reaction' ],
  ],
  streams: {
    ssh: {
      cmd: ['journalctl', '-f', '-u', 'sshd.service'],
      filters: {
        login: {
          regex: [ @'authentication failure;.*rhost=<ip>' ],
          retry: 3,
          retryperiod: '3h',
          actions: banFor('24h'),
        },
      },
    },
    nginx: {
      cmd: ['tail', '-f', '/var/log/nginx/access.log'],
      filters: {
        directus: {
          regex: [ @'^<ip> .* "POST /auth/login HTTP/..." 401', ],
          retry: 6,
          retryperiod: '4h',
          actions: banFor('4h'),
        },
      },
    },
  },
}

Et voilà ! On a écrit le soupçon de généricité dont on avait besoin. On peut maintenant définir en 8 lignes comment protéger un nouveau service.

En l'occurence, c'est Directus, que je conseille vivement pour construire des interfaces et des bases de données sur-mesure très simplement. Directus a été une source d'inspiration pour construire un logiciel expressif et flexible.

Ce fichier de configuration est fonctionnel, mais un petit peu simplifié. Un exemple plus complet pour un ban SSH est disponible ici.

Installation🔗

Il est possible de le compiler soi-même depuis la source, ou de le télécharger depuis les releases :

# curl -o /usr/local/bin/reaction https://static.ppom.me/reaction/releases/$VERSION/reaction
# chmod 755 /usr/local/bin/reaction

Pour le lancer via une unit systemd:

/etc/systemd/system/reaction.service

[Install]
WantedBy=multi-user.target
[Service]
ExecStart=/usr/local/bin/reaction start -c /etc/reaction.yml
StateDirectory=reaction
RuntimeDirectory=reaction
WorkingDirectory=/var/lib/reaction

Écrire son fichier de configuration /etc/reaction.yml ou /etc/reaction.jsonnet (adapter le fichier systemd en fonction du format retenu).

Puis recharger systemd pour qu'il découvre ce nouveau fichier, l'activer au démarrage et le lancer maintenant :

# systemctl daemon-reload
# systemctl enable reaction.service
# systemctl start reaction.service

Utilisation🔗

Conclusion🔗

Après 6 mois de travail (à temps très partiel), reaction est assez mature pour être en v1. Il remplace fail2ban sur mon infrastructure depuis 4 mois.

La v2 est déjà prévue, et elle permettra à des reaction sur des serveurs différents de fonctionner en grappe, en s'échangeant les IPs à bannir en pair-à-pair !

Si

n'hésitez pas