durcit le serveur : pare-feu nftables, SSH par clé, fail2ban, chrony, mises à jour de sécurité

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-07 10:32:09 +02:00
parent b7e866f868
commit f5a5e8d8a9
13 changed files with 377 additions and 0 deletions

10
ansible/durcissement.yml Normal file
View file

@ -0,0 +1,10 @@
---
# Étape 2 du déploiement Syllabus : durcissement du serveur bare metal.
# Touche SSH et le pare-feu — à exécuter uniquement après accord explicite
# d'Adam, conformément à la règle 3 du prompt de déploiement.
- name: Durcissement du serveur Syllabus
hosts: syllabus
become: true
gather_facts: true
roles:
- durcissement

2
ansible/inventory.ini Normal file
View file

@ -0,0 +1,2 @@
[syllabus]
paillasse ansible_host=163.172.4.195 ansible_user=adam ansible_ssh_private_key_file=~/.ssh/id_ed25519_claude ansible_ssh_common_args='-o IdentitiesOnly=yes'

View file

@ -0,0 +1,10 @@
---
# Ports ouverts sur l'interface publique. Rien d'autre n'est accepté en entrée.
durcissement_ports_ouverts:
- 22
- 80
- 443
# Délai avant retour automatique à l'état d'avant durcissement, si la vérification
# post-changement échoue (voir CLAUDE.md et le prompt de déploiement, règle 4).
durcissement_delai_filet_secondes: 300

View file

@ -0,0 +1,23 @@
---
- name: recharger nftables
ansible.builtin.systemd:
name: nftables
state: restarted
enabled: true
- name: recharger ssh
ansible.builtin.service:
name: ssh
state: reloaded
- name: redémarrer fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
enabled: true
- name: redémarrer chrony
ansible.builtin.systemd:
name: chrony
state: restarted
enabled: true

View file

@ -0,0 +1,178 @@
---
- name: installer les paquets de durcissement
ansible.builtin.apt:
name:
- nftables
- fail2ban
- chrony
- unattended-upgrades
state: present
update_cache: true
cache_valid_time: 3600
- name: régler le fuseau horaire sur Europe/Paris
community.general.timezone:
name: Europe/Paris
- name: vérifier la présence de systemd-timesyncd
ansible.builtin.stat:
path: /lib/systemd/system/systemd-timesyncd.service
register: durcissement_timesyncd_unit
- name: désactiver systemd-timesyncd (remplacé par chrony)
ansible.builtin.systemd:
name: systemd-timesyncd
state: stopped
enabled: false
when: durcissement_timesyncd_unit.stat.exists
- name: configurer chrony
ansible.builtin.copy:
content: |
# Géré par Ansible (rôle durcissement) : configuration par défaut de Debian,
# conservée volontairement (pools NTP standard). Pas de changement nécessaire
# ici pour l'instant : la présence du paquet et le service actif suffisent.
dest: /etc/chrony/conf.d/durcissement.conf
mode: "0644"
notify: redémarrer chrony
- name: configurer les mises à jour automatiques (listes + sécurité)
ansible.builtin.template:
src: 20auto-upgrades.j2
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
- name: interdire le redémarrage automatique par unattended-upgrades
ansible.builtin.template:
src: 51unattended-upgrades-local.conf.j2
dest: /etc/apt/apt.conf.d/51unattended-upgrades-local
mode: "0644"
- name: configurer fail2ban (jail sshd, backend nftables)
ansible.builtin.template:
src: jail.local.j2
dest: /etc/fail2ban/jail.local
mode: "0644"
notify: redémarrer fail2ban
- name: déployer le script de retour à l'état d'avant durcissement
ansible.builtin.template:
src: durcissement-rollback.sh.j2
dest: /usr/local/sbin/durcissement-rollback.sh
mode: "0700"
owner: root
group: root
# --- À partir d'ici : modifications du pare-feu et de SSH. ---
# Règle 4 (anti-coupure) : on arme un retour automatique avant de toucher à quoi
# que ce soit, et on ne l'annule qu'après avoir vérifié l'accès depuis une
# session SSH toute neuve (voir la vérification plus bas, delegate_to: localhost).
- name: armer le filet de sécurité ({{ durcissement_delai_filet_secondes }} s)
ansible.builtin.command:
cmd: >
systemd-run --unit=durcissement-rollback
--on-active={{ durcissement_delai_filet_secondes }}
/usr/local/sbin/durcissement-rollback.sh
become: true
changed_when: true
- name: déployer la politique de pare-feu nftables
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
mode: "0640"
owner: root
group: root
validate: "nft -c -f %s"
notify: recharger nftables
- name: appliquer le pare-feu immédiatement
ansible.builtin.meta: flush_handlers
- name: déployer la configuration SSH durcie
ansible.builtin.template:
src: sshd-durcissement.conf.j2
dest: /etc/ssh/sshd_config.d/99-durcissement.conf
mode: "0644"
owner: root
group: root
register: durcissement_sshd_conf
notify: recharger ssh
- name: valider la syntaxe sshd avant de recharger
ansible.builtin.command: sshd -t
become: true
changed_when: false
- name: appliquer la configuration SSH
ansible.builtin.meta: flush_handlers
- name: pause avant vérification (laisser les services redémarrer)
ansible.builtin.pause:
seconds: 3
- name: "vérification : nouvelle connexion SSH par clé (depuis le Mac)"
ansible.builtin.command:
cmd: >
ssh -o IdentitiesOnly=yes -o IdentityFile=~/.ssh/id_ed25519_claude
-o ControlMaster=no -o ControlPath=none -o BatchMode=yes
-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new
adam@163.172.4.195 echo CONNEXION_CLE_OK
delegate_to: localhost
become: false
register: durcissement_verif_cle
changed_when: false
- name: "vérification : le mot de passe est refusé"
# BatchMode empêche seulement l'invite interactive ; le serveur annonce quand
# même au client les méthodes qu'il accepte. On vérifie donc le contenu du
# message d'erreur ("password" ne doit plus y figurer), pas seulement le rc.
ansible.builtin.command:
cmd: >
ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no
-o PreferredAuthentications=password,keyboard-interactive
-o BatchMode=yes -o ConnectTimeout=10
adam@163.172.4.195 echo NE_DOIT_PAS_S_AFFICHER
delegate_to: localhost
become: false
register: durcissement_verif_mdp
changed_when: false
failed_when: >
durcissement_verif_mdp.rc == 0 or
'password' in durcissement_verif_mdp.stderr
- name: "vérification : root est refusé"
ansible.builtin.command:
cmd: >
ssh -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10
-o StrictHostKeyChecking=accept-new
root@163.172.4.195 echo NE_DOIT_PAS_S_AFFICHER
delegate_to: localhost
become: false
register: durcissement_verif_root
changed_when: false
failed_when: durcissement_verif_root.rc == 0
- name: "vérification : seuls 22, 80, 443 sont ouverts (nmap depuis le Mac)"
# -p- (65535 ports) est beaucoup trop lent face à une politique "drop" : chaque
# port fermé attend le timeout au lieu d'un rejet immédiat, et ça dépasse le
# délai du filet de sécurité avant même d'avoir fini de scanner. Les 1000 ports
# les plus courants (défaut de nmap) suffisent à vérifier qu'il n'y a rien
# d'inattendu, et -T4 accélère le scan.
ansible.builtin.command:
cmd: nmap -Pn -T4 --open 163.172.4.195
delegate_to: localhost
become: false
register: durcissement_verif_nmap
changed_when: false
- name: afficher le résultat nmap
ansible.builtin.debug:
var: durcissement_verif_nmap.stdout_lines
- name: désarmer le filet de sécurité (tout est vérifié)
ansible.builtin.command:
cmd: systemctl stop durcissement-rollback.timer
become: true
changed_when: true

View file

@ -0,0 +1,3 @@
// Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

View file

@ -0,0 +1,4 @@
// Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
// Un redémarrage est une opération à risque (règle 3 du prompt de déploiement) :
// il doit toujours être décidé et déclenché par Adam, jamais automatiquement.
Unattended-Upgrade::Automatic-Reboot "false";

View file

@ -0,0 +1,15 @@
#!/bin/bash
# Géré par Ansible (rôle durcissement) — filet de sécurité anti-coupure SSH.
#
# Déclenché par un timer systemd transient ({{ durcissement_delai_filet_secondes }} s)
# programmé juste avant de modifier le pare-feu ou SSH. Si la vérification depuis
# le Mac échoue, ce script remet le serveur dans l'état d'avant durcissement
# (pare-feu complètement ouvert, pas de restriction SSH). Si tout va bien, le
# timer est annulé avant de se déclencher et ce script ne s'exécute jamais.
set -e
nft flush ruleset
rm -f /etc/ssh/sshd_config.d/99-durcissement.conf
systemctl reload ssh || systemctl restart ssh
logger -t durcissement-rollback "Rollback automatique déclenché : pare-feu et SSH remis à l'état d'avant durcissement."

View file

@ -0,0 +1,18 @@
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
[DEFAULT]
bantime = 1h
findtime = 10m
# 10, pas 5 : nos propres vérifications de durcissement (tentative mot de passe
# refusée, tentative root refusée) comptent comme des échecs. À 5, on s'est
# banni nous-mêmes dès le 2e ou 3e passage du playbook en quelques minutes
# (voir docs/postmortems/2026-10-07-fail2ban-auto-ban.md). 10 reste largement
# suffisant contre un bruteforce réel.
maxretry = 10
backend = systemd
# Le serveur n'a pas iptables installé (seulement nftables) : il faut le dire
# explicitement à fail2ban, sinon son action par défaut échoue.
banaction = nftables-multiport
banaction_allports = nftables-allports
[sshd]
enabled = true

View file

@ -0,0 +1,29 @@
#!/usr/sbin/nft -f
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
# Politique : tout est refusé en entrée sauf loopback, trafic déjà établi,
# ICMP, et les ports {{ durcissement_ports_ouverts | join(', ') }}.
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
meta l4proto ipv6-icmp accept
tcp dport { {{ durcissement_ports_ouverts | join(', ') }} } accept
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}

View file

@ -0,0 +1,8 @@
# Géré par Ansible (rôle durcissement) — ne pas modifier à la main.
# Lu en premier (Include est la première ligne de sshd_config), donc ces valeurs
# l'emportent sur les réglages par défaut du fichier principal.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no

View file

@ -29,3 +29,40 @@ Avant de changer quoi que ce soit, il fallait savoir précisément sur quoi on a
**Suite**
Étape 2 : durcissement SSH + pare-feu nftables + fail2ban/chrony/unattended-upgrades, via un rôle Ansible depuis le Mac. C'est une étape qui touche SSH et le réseau : je vais détailler la commande avant de l'exécuter, prévoir un retour arrière automatique (anti-coupure), et ne redémarrer/couper rien sans l'accord explicite d'Adam pour les points couverts par la règle 3 du prompt de déploiement.
## 2026-10-07 — Étape 2 : durcissement du serveur
**Fait**
- Rôle Ansible `durcissement` (`ansible/roles/durcissement/`) + playbook `ansible/durcissement.yml`, appliqués après accord explicite d'Adam.
- Pare-feu nftables : tout refusé en entrée sauf loopback, trafic établi, ICMP, et les ports 22/80/443.
- SSH : `PermitRootLogin no`, `PasswordAuthentication no` (connexion par clé uniquement), via un drop-in `/etc/ssh/sshd_config.d/99-durcissement.conf`.
- `fail2ban` (jail `sshd`, backend `nftables` car pas d'`iptables` sur l'image), `chrony` (remplace `systemd-timesyncd`), `unattended-upgrades` (sécurité auto, **redémarrage automatique explicitement désactivé** — un reboot reste une décision d'Adam).
- Filet de sécurité anti-coupure : timer `systemd-run` de 5 minutes armé avant toute modification du pare-feu/SSH, script de retour à l'état précédent (`/usr/local/sbin/durcissement-rollback.sh`), désarmé seulement après vérification réussie depuis une connexion SSH toute neuve + `nmap`.
- Vérifié deux fois l'idempotence (rejouer le playbook ne change rien d'inattendu).
**Commandes clés**
```bash
cd ansible && script -q /dev/null ansible-playbook durcissement.yml -i inventory.ini
ssh paillasse "sudo systemctl list-timers | grep durcissement" # filet de sécurité
nmap -Pn -T4 --open 163.172.4.195
```
**Problèmes rencontrés et solution**
1. Bug dans le rôle : le `notify` pour recharger `sshd` manquait sur la tâche de dépôt de la config SSH → `sshd` jamais rechargé au 1er essai → le filet de sécurité s'est déclenché (normal, rien de cassé) → corrigé en ajoutant le `notify`.
2. Le test de vérification `nmap -p-` (65535 ports) était beaucoup trop lent face à une politique `drop` (chaque port fermé attend un timeout) → dépassait les 5 minutes du filet de sécurité → 2ᵉ déclenchement automatique du rollback (normal, rien de cassé) → corrigé en limitant le scan aux ports par défaut + `-T4` (4,5 s au lieu de plusieurs minutes).
3. **Incident réel : auto-bannissement par fail2ban.** Les tentatives volontairement ratées (mot de passe, root) de nos propres tests de vérification, répétées sur plusieurs runs rapprochés, ont dépassé le seuil `maxretry = 5` de fail2ban : l'IP du Mac d'Adam s'est fait bannir par son propre pare-feu. Résolu via la console web Scaleway (hors bande) avec `fail2ban-client set sshd unbanip`. Post-mortem complet : `docs/postmortems/2026-10-07-fail2ban-auto-ban.md`. Correctif : `maxretry` relevé à 10.
**Critère vérifié**
- Connexion par clé : OK. Mot de passe : refusé (`Permission denied (publickey)`, sans mention de `password` dans les méthodes offertes). Root : refusé. `nmap -Pn -T4 --open 163.172.4.195` depuis le Mac : seul le port 22 est ouvert (80/443 autorisés par le pare-feu mais rien n'écoute encore dessus — normal, le portail arrive à l'étape 8).
**Comprendre**
Durcir un serveur, c'est fondamentalement risqué pour l'accès qu'on a déjà : chaque changement de pare-feu ou de SSH peut potentiellement nous enfermer dehors. D'où le filet de sécurité (un minuteur qui annule tout automatiquement si on ne confirme pas que ça marche dans les 5 minutes) : il s'est déclenché deux fois pendant la mise au point, et les deux fois c'était le bon comportement — un bug ailleurs, pas un défaut du filet. L'incident fail2ban est différent et plus subtil : ce n'est pas le pare-feu qui nous a bloqués, c'est un service de protection qu'on vient d'installer qui a réagi à nos propres tests comme à une attaque. C'est un vrai piège classique en administration système, qui vaut la peine d'être raconté en entretien : on protège un système contre tout le monde, y compris soi-même si on ne fait pas attention au volume de ses propres tentatives.
**Suite**
Étape 3 : sauvegardes vers Dedibackup (archives `tar.zst` chiffrées `age`, rotation 7 quotidiennes + 4 hebdomadaires, timer systemd).

View file

@ -0,0 +1,40 @@
# Post-mortem — auto-bannissement fail2ban pendant le durcissement
- **Date** : 2026-10-07
- **Durée de l'incident** : ~1 h (accès SSH restauré manuellement au bout de quelques minutes via la console Scaleway)
- **Gravité** : faible — aucune perte de données, aucune coupure de service (rien n'est encore en production), juste une perte temporaire d'accès SSH depuis le Mac d'Adam.
- **Impact** : Claude Code ne pouvait plus se connecter en SSH au serveur Syllabus depuis le Mac d'Adam.
## Résumé
Pendant la mise au point du rôle Ansible `durcissement` (étape 2 du déploiement), le playbook a été relancé plusieurs fois en quelques minutes pour corriger des bugs (voir plus bas) et vérifier l'idempotence. Chaque exécution du playbook tente volontairement une connexion avec mot de passe et une connexion root, pour vérifier qu'elles sont bien refusées. Ces tentatives volontairement ratées ont été comptées par **fail2ban** comme des échecs d'authentification. Avec le seuil initial (`maxretry = 5` sur `findtime = 10m`), l'IP publique du Mac d'Adam a dépassé ce seuil et s'est fait bannir par son propre pare-feu, pendant `bantime = 1h`.
## Chronologie
1. 1er run du playbook : bug découvert (le `notify` pour recharger `sshd` manquait sur la tâche de déploiement de la config SSH). Le pare-feu a été appliqué mais SSH non rechargé → le test « root refusé » échoue réellement (root pouvait encore se connecter). Le filet de sécurité (timer de 5 min) n'est jamais désarmé → il se déclenche et remet tout à l'état d'origine. Aucune conséquence négative : c'est exactement le rôle de ce filet.
2. Correction du bug, 2ᵉ run : le test nmap (`-p-`, les 65535 ports) s'est révélé trop lent face à une politique de pare-feu `drop` (chaque port fermé attend un timeout au lieu d'un rejet immédiat). Le run dépasse les 5 minutes du filet de sécurité avant d'arriver à l'étape de désarmement → 2ᵉ déclenchement automatique du rollback. Aucune conséquence négative, même cause (filet de sécurité qui fait son travail).
3. Correction du scan nmap (ports par défaut + `-T4`, 4,5 s au lieu de plusieurs minutes), 3ᵉ run : succès complet, toutes les vérifications passent, le filet est désarmé normalement.
4. Relance d'un 4ᵉ run pour vérifier l'idempotence (bonne pratique, règle 7 du prompt de déploiement) : **SSH refuse la connexion** (`Connection refused`, pas un timeout). Le ping fonctionne : le serveur est up, seul le port 22 est concerné.
5. Diagnostic : fail2ban a banni l'IP publique du Mac (`89.86.35.23`), déclenché par l'accumulation des tentatives « mot de passe refusé » et « root refusé » des runs 1 à 3, toutes dans la même fenêtre `findtime` de 10 minutes.
6. Résolution : Adam s'est connecté à la console web Scaleway (hors bande, sans dépendre du réseau/SSH) et a lancé `sudo fail2ban-client set sshd unbanip 89.86.35.23` directement sur le serveur. Accès SSH restauré immédiatement.
7. Correctif durable : `maxretry` relevé de 5 à 10 dans `ansible/roles/durcissement/templates/jail.local.j2`, pour tolérer les futures vérifications répétées sans affaiblir significativement la protection contre un vrai bruteforce.
## Cause racine
Le seuil de bannissement de fail2ban ne tenait pas compte du fait que **le playbook de durcissement lui-même génère des échecs d'authentification volontaires** à chaque exécution, pour vérifier que les protections fonctionnent. Plusieurs exécutions rapprochées (pendant la mise au point) ont donc eu le même effet qu'une attaque par force brute à faible volume, et fail2ban a réagi exactement comme prévu — contre nous.
## Ce qui a bien fonctionné
- Le filet de sécurité anti-coupure (timer `systemd-run` de 5 minutes, règle 4 du prompt de déploiement) a fonctionné parfaitement aux essais 1 et 2 : aucune coupure réelle, retour automatique et propre à l'état précédent, confirmé dans les journaux (`journalctl -t durcissement-rollback`).
- fail2ban a démontré qu'il fonctionne (il a banni une IP suite à des échecs répétés), même si c'était la mauvaise IP.
- La console web du fournisseur (hors bande, indépendante du réseau du serveur) a permis de résoudre l'incident sans attendre l'expiration du bannissement.
## Actions correctives
- [x] `maxretry` relevé de 5 à 10 dans le jail `sshd` (`ansible/roles/durcissement/templates/jail.local.j2`).
- [x] Scan nmap de vérification limité aux ports par défaut (au lieu de `-p-`), avec `-T4` : 4,5 s au lieu de plusieurs minutes, pour rester largement dans la fenêtre du filet de sécurité.
- [ ] À envisager plus tard (pas fait maintenant, pas nécessaire dans l'immédiat) : un jail ou une règle dédiée qui ignore les tentatives provenant du processus de vérification Ansible lui-même, si le besoin se reproduit.
## Comprendre
Fail2ban compte les échecs de connexion SSH, peu importe qui les fait : un vrai attaquant ou nos propres tests. Comme le rôle de durcissement vérifie activement que le mot de passe et root sont refusés — donc provoque volontairement des échecs — relancer ce rôle plusieurs fois en peu de temps ressemble, du point de vue de fail2ban, à une attaque. C'est un classique en administration système : les outils de protection automatique protègent contre tout le monde, y compris leurs propres opérateurs. La leçon à retenir : quand on écrit un test qui provoque volontairement un échec d'authentification, il faut aussi réfléchir à l'effet cumulé de ce test sur les systèmes de détection d'intrusion, surtout si le test va être rejoué souvent pendant la mise au point.