Tutorial in French
Ce tutoriel concerne la configuration de reaction. Pour l'installation, voir la page des releases.
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.
Qu'est-ce qu'on veut protéger ?
On va commencer par identifier un service qu'on veut protéger. Tout service exposé sur le réseau peut potentiellement être protégé par reaction d'une façon ou d'une autre.
Dans ce tutoriel, on va se pencher sur SSH, la porte d'entrée principale de la plupart des serveurs Linux et *BSD.
Récupérer les logs
reaction protège un service en suivant ses logs.
Sur les distributions basées sur Debian, le daemon SSH log dans journald.
La commande qui permet de suivre ses logs, en tant que root, est : journalctl -f ssh
(ou journalctl -f sshd, ou tail -F /var/log/auth.log, etc. Ça dépend de la distribution).
Essayons quelques connexions infructueuses sur le serveur SSH depuis un autre serveur :
ssh invalid@myip # en utilisant un mauvais username
ssh ppom@myip # avec un username existant, mais avec un mauvais mot de passe
Ces deux commandes échouent au login. On peut maintenant regarder les logs correspondants générés par le daemon SSH :
[...]
Invalid user invalid from 198.51.100.23 port 60316
[...]
Failed password for ppom from 198.51.100.23 port 56952 ssh2
[...]Comment protéger le service
La manière la plus simple et efficace de protéger le service est de bannir l'IP au niveau du firewall du serveur.
Il y a de la documentation pour différentes actions, qui couvrent notamment les firewalls les plus communs.
Mais dans ce tutoriel, on va dire que le serveur utilise iptables, et utiliser les ensembles ipset pour stocker les IPs.
Configuration
Maintenant qu'on a réuni toutes ces informations, voyons comment inscrire ça dans la configuration de reaction.
Les Patterns
reaction va devoir extraire l'IP des logs pertinents. C'est fait avec des patterns, qui peuvent ensuite être utilisés au sein d'une expression régulière de logs, puis être réutilisés pour passer à l'action.
On commence donc par préciser comment reaction va reconnaitre une IP.
On définit donc un pattern, de type ipv4, qu'on appelle myip.
patterns:
myip:
type: 'ipv4'
Les patterns ont des types spéciaux :
ipv4pour des IPv4,ipv6pour des IPv6, etippour reconnaitre à la fois des IPv4 et des IPv6.Si vous voulez créer des patterns qui permettent de capturer autre chose que des IPs, vous pouvez renseigner vous-même vos propres regex :
patterns: # Un pattern appelé name qui reconnait un mot ASCII # commençant par une majuscule name: regex: '[A-Z][a-z]*'
ℹ️ Référence de configuration des Patterns
Les Streams
En remplacement des jails de fail2ban, on a des streams, qui définissent une source de données.
C'est une simple commande UNIX. reaction va la lancer, et lire sa sortie standard.
Exemples :
- nginx:
tail -F -n0 /var/log/nginx/access.log - ssh via journald:
journalctl -f -n0 ssh.service
Pour en apprendre plus sur les bonnes pratiques quand on écrit des streams, et sur pourquoi on précise
-n0: ℹ️ Bonnes pratiques des Streams
On ajoute donc à notre configuration un stream nommé ssh, avec la bonne commande de log :
patterns:
myip:
type: 'ipv4'
# Nouvelles lignes ↓
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
ℹ️ Pourquoi les commandes sont des arrays ?
ℹ️ Référence de configuration des Streams
Les Filters
À ces streams, on attache un ou plusieurs filters. Ce sont des groupes d'expressions régulières.
Regardons à nouveau nos lignes de logs :
Invalid user invalid from 198.51.100.23 port 60316 Failed password for ppom from 198.51.100.23 port 56952 ssh2Le username et le numéro de port sont inutiles, donc on les abstrait :
Invalid user .* from 198.51.100.23 port Failed password for .* from 198.51.100.23 port(L'expression régulière doit matcher n'importe où sur la ligne, donc on ignore simplement la fin quand elle ne sert à rien.)
On va maintenant remplacer les IPs concrètes par le pattern. On utilise son nom, qu'on entoure de ces symboles :
<>.Invalid user .* from <myip> port Failed password for .* from <myip> portOn s'est débarassé des parties non variables des lignes de logs, en capturant la seule partie variable qu'on veut garder : l'IP.
Ici, on définit un filter appelé authfail qui sera déclenché ("trigger") dès qu'une ligne matche une des expression régulières :
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
# Nouvelles lignes ↓
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
Mais souvent, on ne veut pas que reaction bannisse l'IP dès la première erreur.
On veut éviter de blocker des utilisateurices réel'les, donc on va bannir seulement après quelques tentatives dans une période donnée.
Pour cela, reaction propose les options retry et retryperiod, qui délaient le déclenchement d'un filter.
Précisons que le fitler authfail doit être déclenché ("trigger") si le pattern myip est retrouvé 3 fois en 3h.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
# Nouvelles lignes ↓
retry: 3
retryperiod: '3h'
ℹ️ Référence de configuration des Filters
Les Actions
Okay, on a appris à reaction comment repérer une attaque, et quand réagir à ces attaques.
On va maintenant lui apprendre quoi faire ! Le concept pour ça c'est les actions.
À un filter, on rajoute une ou plusieurs actions, qui seront exécutées quand le filter se déclenche.
Ici, on crée une action sobrement appelée ban qui insère l'IP dans un set IPset nommé reactionv4.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
# Nouvelles lignes ↓
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
Une action peut être effectuée tout de suite, ou être délayée avec after, ce qui permet de bannir une IP maintenant, et de la débannir quelque temps plus tard.
On ajoute ici une action pour débannir l'IP 24h plus tard, qu'on choisit d'appeler unban.
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
# Nouvelles lignes ↓
unban:
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
after: '24h'
ℹ️ Référence de configuration des Actions
Start / Stop
Ces commandes ipset ont besoin de l'existence du set reactionv4 dans le pare-feu.
Au démarrage, on demande à reaction de le créer. Puis on lui demande de l'ajouter à la chaine INPUT qui contrôle les connexions entrantes.
On l'ajoute aussi à la chaine FORWARD, qui contrôle les connexions transférées (FORWARD est utile si vous êtes sur un routeur, ou si le serveur a des conteneurs ou des VMs).
start:
- ['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet']
- ['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
On lui demande aussi de supprimer le set et retirer ses règles en quittant :
stop:
- ['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['ipset', '-X', 'reactionv4' ]Récap
patterns:
myip:
type: 'ipv4'
streams:
ssh:
cmd: ['journalctl', '-n0', '-fu', 'ssh.service']
filters:
authfail:
regex:
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
retry: 3
retryperiod: '3h'
actions:
ban:
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
unban:
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
after: '24h'
start:
- ['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet']
- ['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
stop:
- ['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP']
- ['ipset', '-X', 'reactionv4' ]
Et voilà. En 27 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.
Remarques :
-
Pour faire simple ici, on ne gère que les IPv4. Mais c'est possible de définir une certaine action pour les IPv4 et une autre pour les IPv6.
-
Toujours pour faire simple, on n'a défini qu'une regex pour les logs SSH. Mais OpenSSH peut logger d'autres lignes qui indiquent un échec de connexion. Voir la doc du Filter SSH.
-
Il est possible de faire n'importe quelle action : lancer les commandes pour n'importe quel firewall, envoyer un mail, etc. Voir les exemples d'actions pour des inspirations.
-
Il est conseillé de jeter un coup d'oeil sur le chapitre des Streams pour comprendre pourquoi il vaut mieux utiliser
tail -Fquetail -f, pourquoi on utilise-n0, etc. -
Lire aussi le chapitre Sécurité pour ne pas faire de bêtises. On est là pour améliorer la sécurité de ses serveurs, pas l'empirer !
-
Les Plugins permettent de nouveaux types de Streams et d'Actions, qu'on n'a pas vus ici.
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', 'ssh.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 ipset (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: ['ipset', '-exist', '-A', 'reactionv4', '<myip>']
},
unban: {
after: time,
cmd: ['ipset', '-D', 'reactionv4', '<myip>']
},
};
{
patterns: {
myip: {
type: 'ipv4',
},
},
streams: {
ssh: {
cmd: ['journalctl', '-n0', '-fu', 'ssh.service'],
filters: {
authfail: {
regex: [
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
],
retry: 3,
retryperiod: '3h',
actions: banFor('24h'),
},
},
},
nginx: {
cmd: ['tail', '-f', '/var/log/nginx/access.log'],
filters: {
directus: {
regex: [ @'^<myip> .* "POST /auth/login HTTP/..." 401', ],
retry: 6,
retryperiod: '4h',
actions: banFor('4h'),
},
},
},
},
start: [
['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet'],
['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
],
stop: [
['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['ipset', '-X', 'reactionv4' ],
],
}
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 pour construire des interfaces et des bases de données sur-mesure simplement.
Séparer la configuration en plusieurs fichiers
On peut avoir plusieurs bonnes raisons de vouloir séparer sa configuration en plusieurs fichiers.
Ainsi chaque pattern, stream, filter, start/stop, peut être défini dans un fichier séparé.
Voilà un exemple de découpage de la configuration précédente en plusieurs fichiers :
/etc/reaction/startstop.jsonnet
{
start: [
['ipset', '-exist', '-N', 'reactionv4', 'hash:net', 'family', 'inet'],
['iptables', '-w', '-I', 'INPUT', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-I', 'FORWARD', '1', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
],
stop: [
['iptables', '-w', '-D', 'INPUT', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['iptables', '-w', '-D', 'FORWARD', '-m', 'set', '--match-set', 'reactionv4', 'src', '-j', 'DROP'],
['ipset', '-X', 'reactionv4' ],
],
}
On a renommé le pattern myip en ip ici.
/etc/reaction/ip.yaml
patterns:
ip:
type: 'ipv4'
On peut écrire dans un fichier commençant par _ des définitions JSONnet qui ne seront pas chargées directement par reaction. Elles ne seront chargées que si explicitement importées dans un autre fichier :
/etc/reaction/_ban.jsonnet
local banFor(time) = {
ban: {
cmd: ['ipset', '-exist', '-A', 'reactionv4', '<ip>']
},
unban: {
after: time,
cmd: ['ipset', '-D', 'reactionv4', '<ip>']
},
};
banFor
/etc/reaction/ssh.jsonnet
local banFor = import '_ban.jsonnet';
{
streams: {
ssh: {
cmd: ['journalctl', '-n0', '-fu', 'ssh.service'],
filters: {
authfail: {
regex: [
- 'Invalid user .* from <myip> port'
- 'Failed password for .* from <myip> port'
],
retry: 3,
retryperiod: '3h',
actions: banFor('24h'),
},
},
},
},
}
/etc/reaction/nginx.jsonnet
local banFor = import '_ban.jsonnet';
{
streams: {
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'),
},
},
},
},
}Utilisation
- Étudier les logs avec
journalctl -f -u reaction.service. - Connaitre l'état actuel des bans avec
reaction show$ reaction show ssh: login: 4.3.2.1: actions: unban: - "2025-11-01 22:00:00" 112.113.114.115: actions: unban: - "2025-10-29 00:16:16" - Enlever un ban avec
reaction flush ip=4.3.2.1 - Tester ses regex avec
reaction test-regex - Consulter l'aide pour toutes ces commandes avec
reaction help
Conclusion
C'est bon ! Tu peux maintenant explorer le reste de la doc, et chercher ce qui te manque.
Si tu écris de nouveaux filtres et actions pour reaction, pense à les ajouter ici ! Merci beaucoup !