
🌐 Aussi en: English · Deutsch · Español
Après avoir migré cette semaine deux instances Nextcloud vers de nouveaux serveurs, je me suis posé une question simple : mes trois serveurs – ce blog et les deux Nextcloud – attirent-ils des attaquants différents ? La réponse a été, pour l’essentiel, « non, c’est partout le même bruit de fond ». Sauf une ligne qui me chiffonnait : SSH. Il tourne sur un port non standard, n’accepte que des clés, et fail2ban le surveille. Et pourtant, le blog à lui seul enregistrait environ 16 tentatives de connexion par heure sur le démon SSH, venues du monde entier.
Le fait est que je ne me connecte jamais que depuis l’Allemagne. Alors pourquoi un serveur dans un datacenter allemand devrait-il seulement parler SSH avec le reste de la planète ? Méthode habituelle : je pose les questions et je prends les décisions, mon co-admin IA (Claude) fouille les logs, écrit le code et vérifie chaque chiffre deux fois avant que nous lui fassions confiance.
📊 Avant : qui frappe au port 10022 ?
Au cours des huit derniers jours, 3 089 connexions provenant de 510 adresses IP différentes sont arrivées jusqu’au démon SSH du blog (mes propres connexions exclues). Aucune n’est entrée – il n’y a pas de mot de passe à deviner –, mais chacune coûte une poignée de main, une ligne de log et une évaluation par fail2ban. Leur provenance :
| Pays | Part des tentatives |
|---|---|
| Chine | 30 % |
| États-Unis | 16 % |
| Corée du Sud | 4 % |
| Allemagne | 4 % |
| Inde, Royaume-Uni, Hong Kong, Vietnam, Afrique du Sud | 3 % chacun |
| Tout le reste | ~31 % |
Les noms d’utilisateur essayés sont le dictionnaire habituel : admin, ubuntu, user, test, ftpuser, deploy, git, steam. Le tableau était le même sur les deux serveurs Nextcloud : 98 % (blog) et 99 % (Nextcloud) de toutes les tentatives SSH échouées venaient de l’extérieur de l’Allemagne. Et les quelques allemandes ? Presque exclusivement des serveurs loués chez les grands hébergeurs allemands – les scanners louent aussi des machines ici.
🤔 « Mais les attaquants n’ont qu’à prendre un VPN allemand ! »
Exact, et je tiens à être honnête là-dessus : un filtre par pays n’est pas une frontière de sécurité. Quiconque me vise personnellement loue un VPS allemand pour quelques euros et passe tranquillement à côté. La vraie protection reste exactement là où elle était : authentification par clé uniquement, fail2ban et correctifs. Alors pourquoi se donner cette peine ?
- Le bruit. 98 % de déchets en moins dans les logs, c’est la garantie que l’entrée qui compte vraiment saute enfin aux yeux.
- La prochaine faille zero-day. Quand une vraie faille apparaît dans OpenSSH (souvenez-vous de regreSSHion, CVE-2024-6387, en 2024), la première vague consiste à scanner massivement tout Internet. Un serveur qui ne répond pas à 98 % de cet Internet échappe tout simplement à l’essentiel de cette vague.
- Coût : quasiment nul – voir plus bas.
🌍 D’où vient « l’Allemagne » ?
Pas besoin de base GeoIP commerciale. Les cinq registres Internet régionaux (RIR) publient chaque jour quels blocs IP ils ont attribués à quel pays. Pour l’Allemagne, c’est le fichier delegated-ripencc-extended-latest du RIPE NCC : environ 8 700 blocs IPv4 et 3 100 blocs IPv6, soit quelque 126 millions d’adresses IPv4. Fusionnés dans des sets d’intervalles nftables, cela donne 6 639 + 3 013 entrées.
Est-ce que ça coûte de la RAM ou du CPU ? Nous avons mesuré au lieu de deviner :
| Quoi | Mesuré |
|---|---|
| Mémoire noyau pour les deux sets | ~1,5 MB |
| Chargement de la liste complète | 0,06 s |
| Recherche par nouvelle connexion SSH | ~14 comparaisons (arbre d’intervalles trié), quelques nanosecondes |
| Effet sur le trafic web | aucun – seules les nouvelles connexions vers le port 10022 sont vérifiées |
À garder en tête : les données des RIR indiquent à qui un bloc d’adresses a été attribué, pas où l’appareil se trouve physiquement. Pour la question « est-ce un FAI ou un hébergeur allemand ? », c’est exactement la bonne information.
🧱 La nouvelle fonctionnalité Ansible : une variable
Les trois serveurs sont construits à partir du même dépôt Ansible, et le pare-feu est le rôle partagé common_firewall. Le filtre par pays en fait désormais partie – désactivé par défaut, activable hôte par hôte :
# inventory/host_vars/<host>/vars.yml
fw_ssh_geo_countries: [DE]
fw_ssh_geo_extra: [] # optional: CIDRs that are always allowedDans le jeu de règles nftables généré, la règle SSH gagne simplement une recherche dans un set :
ip daddr 203.0.113.10 tcp dport 10022 ct state new ip saddr @ssh_geo4 accept
ip6 daddr 2001:db8::1 tcp dport 10022 ip6 saddr @ssh_geo6 acceptTout ce qui ne correspond pas tombe dans la détection de scan de ports existante puis dans le drop par défaut – exactement comme si le port était fermé. Un petit script, ssh-geo-update, télécharge le fichier du RIPE, garde les blocs des pays configurés, les fusionne et écrit /etc/nftables/ssh-geo.nft. Un timer systemd le rafraîchit chaque dimanche dans la nuit.
🪤 La partie intéressante : comment ne pas s’enfermer dehors
Une liste d’adresses autorisées a un mode de défaillance bien vicieux : si elle est un jour vide, personne n’entre plus – moi compris. L’essentiel du travail a consisté à rendre cela impossible :
- Les rechargements du pare-feu. Mon jeu de règles recrée toute sa table à chaque rechargement. Nous l’avons appris à nos dépens en début de semaine, quand un rechargement a vidé en silence les sets de bannissement de fail2ban. Ici, ce serait pire : des sets vides signifient SSH fermé pour tout le monde. La liste est donc incluse dans le fichier du jeu de règles et remplie dans la même transaction atomique. Il n’existe jamais un instant sans elle.
- La première exécution. Ansible construit la liste avant d’écrire le nouveau jeu de règles. Si le téléchargement échoue, le playbook s’arrête net, avant qu’une seule règle puisse enfermer qui que ce soit dehors.
- Les données corrompues. La mise à jour conserve l’ancienne liste si le téléchargement échoue, si les données du RIPE ont plus de 14 jours ou si la nouvelle liste rétrécit soudain de plus de 20 %. Les mises à jour à chaud ne touchent que les deux sets ; les sets de fail2ban et toutes les autres règles restent intacts.
- SELinux. Le premier jet plaçait la liste dans
/var/lib. Mais sous AlmaLinux, l’unité nftables charge le jeu de règles dans un domaine SELinux confiné qui lit/etc, et un include illisible ferait échouer le jeu de règles entier au démarrage. La liste habite donc dans/etc/nftables. - La surveillance d’intégrité. AIDE surveille
/etcet envoie un mail à chaque modification. Le timer tourne donc le dimanche entre 02:30 et 03:00, après la vérification AIDE nocturne et avant le rafraîchissement de la baseline à 04:00, afin que la mise à jour hebdomadaire ne déclenche jamais de fausse alerte. - La sortie de secours. À l’étranger, ou derrière un VPN étranger ? La console web de l’hébergeur fonctionne toujours avec le mot de passe root, et
fw_ssh_geo_extraaccepte toutes les adresses dont vous avez besoin.
Avant l’activation, Claude a généré le template pour les trois hôtes et l’a comparé aux jeux de règles en production : sans la variable, la sortie est identique à l’octet près, les deux serveurs Nextcloud n’ont donc même pas remarqué le nouveau code. Le nouveau jeu de règles du blog est passé par nft -c (mode vérification) sur l’hôte réel avant même que le playbook ne tourne. Comme le blog est le moins critique des trois, c’est lui qui est passé en premier.
📉 Après : le silence
Pour compter ce que le filtre rejette, j’ai ajouté temporairement une règle de log dédiée aux tentatives SSH refusées (le log de drop normal est plafonné à deux lignes par minute pour tout). La première fenêtre de mesure :
| Avant (8 jours) | Après (environ 2 premières heures) | |
|---|---|---|
| Connexions atteignant le démon SSH | 16,3 par heure | 0 |
| Rejetées par le filtre par pays | – | 41 tentatives depuis 9 IP (Chine, États-Unis, Hong Kong, Macao, Mexique, Canada, Royaume-Uni) |
| Mes propres connexions depuis l’Allemagne | fonctionnent | fonctionnent toujours |
Deux heures, c’est une fenêtre courte, donc prenez les chiffres exacts avec des pincettes – mais zéro contre seize par heure, ce n’est pas une erreur d’arrondi. La règle de log temporaire a de nouveau disparu ; le filtre, lui, reste.
👀 Bonus : qui lit vraiment ce blog ?
Puisque nous étions déjà dans les logs, je voulais savoir autre chose : je publie beaucoup de nouveaux articles ces derniers temps, alors davantage de gens trouvent-ils le blog via les moteurs de recherche ? Pour cela, le dépôt contient un petit script, scripts/analytics/visitor-stats.py. Il tourne sur le serveur lui-même, car les IP des visiteurs sont des données personnelles au sens du RGPD et ne quittent jamais l’hôte ; il n’affiche que des chiffres agrégés et masqués. Cette semaine, il a appris à compter aussi les arrivées depuis les moteurs de recherche.
D’abord la partie qui refroidit : sur 18 084 requêtes dans la fenêtre mesurée, l’écrasante majorité venait de machines. 81 % de toutes les pages vues provenaient de bots qui s’annoncent comme tels (Amazonbot, PetalBot, SemrushBot, PerplexityBot, AhrefsBot, Googlebot, ClaudeBot, …), et une bonne partie du reste étaient des navigateurs headless dans des datacenters cloud, qui chargent une page avec toutes ses ressources en une seconde puis disparaissent. Ce qui restait après filtrage :
| Indicateur | Valeur |
|---|---|
| Visiteurs réels | ~17 par jour, ~120 par semaine |
| Principaux pays | États-Unis, Chine (sans doute encore quelques scrapers déguisés), Italie, Royaume-Uni, Pays-Bas, Suisse, Allemagne, … |
| Arrivés via un moteur de recherche | 13 visiteurs sur 33 (Google 7, DuckDuckGo 4, Bing 2) |
| Les plus lus | la page d’accueil, le guide Samba sous Debian 13, l’article sur l’historique de températures de l’iLO |
Une réserve honnête : K3s ne garde que quelques jours de logs de conteneurs, il s’agit donc d’un instantané sur deux jours, pas d’une tendance. Pour savoir si le trafic issu des moteurs de recherche augmente avec les nouveaux articles, il faudra attendre que le serveur tienne un décompte quotidien et anonyme – c’est le prochain point sur la liste. Et oui : une IP n’est pas une personne. Ce sont des ordres de grandeur, pas un recensement.
📦 À récupérer
Tout se trouve dans le dépôt public github.com/aptupgrademe/www_k3s (les secrets restent en local, évidemment) : le rôle pare-feu avec la nouvelle fonctionnalité est dans roles/common_firewall, les statistiques de visites dans scripts/analytics. Quelques commandes utiles une fois que tout tourne :
# is this address allowed to reach SSH?
nft get element inet filter ssh_geo4 { 203.0.113.7 }
# when does the next refresh run, and what did the last one do?
systemctl list-timers ssh-geo-update.timer
journalctl -u ssh-geo-update.service🎯 À retenir
Un port non standard ne cache SSH à presque personne, et l’authentification par clé seule rend les coups à la porte inoffensifs, mais pas silencieux. Limiter SSH au seul pays depuis lequel je travaille réellement a supprimé pratiquement tout le bruit pour 1,5 Mo de RAM. Le plus difficile n’était pas le filtre lui-même, mais de garantir qu’une liste vide, un rechargement du pare-feu, SELinux ou un téléchargement raté ne puissent jamais m’enfermer dehors. Le blog ouvre la marche ; s’il se tient tranquille quelque temps, les deux serveurs Nextcloud suivront avec une seule ligne de YAML.





