Une douzaine de ports qui n'auraient jamais dû sonner.
La home montre ce que le serveur attrape. Cette page montre par quel mécanisme, et pourquoi il est fait comme ça. Y compris là où il est encore faux.
Un démon écoute sur une douzaine de ports de services classiques : SMB, Telnet, RDP, VNC, MySQL, Redis, Elasticsearch, MongoDB, PostgreSQL, MSSQL, FTP, POP3, IMAP. Aucun de ces services n'existe ici. Rien, sur ce serveur, n'a de raison légitime d'être contacté sur 445 ou sur 3389.
D'où la règle, qui tient en une phrase : une connexion sur un de ces ports est une preuve suffisante. Pas un indice, pas un signal à corréler. Une preuve.
C'est ce qui rend le dispositif radicalement plus simple qu'un anti-bruteforce classique. Sur un service réel il faut un seuil : combien d'échecs, en combien de temps, avant de décider que c'est hostile ? Ce seuil est un arbitrage permanent entre laisser passer des attaquants et couper de vrais utilisateurs. Ici la question ne se pose pas. Il n'y a pas d'utilisateur légitime à protéger, donc pas de seuil à régler, donc pas de faux positif possible. Premier paquet, ban.
/wp-login.php que tout le monde remarque en premier est une toute petite partie du dispositif. Les compteurs vivants sont sur la home.L'autre entrée, plus petite mais plus bavarde. Les scanners réclament /wp-login.php, /.env, /.git/config. Ici ces URLs existent : nginx sert une copie statique de la page de login WordPress, et les chemins piégés appellent directement l'API de bannissement, sans passer par la lecture des logs.
Le formulaire ne poste nulle part. Il intercepte la soumission, transmet les identifiants tentés, puis ment :
document.getElementById('f').addEventListener('submit', async function(e) {
e.preventDefault();
await fetch('/api/trap', { method: 'POST', ... });
document.getElementById('err').style.display = 'block';
document.getElementById('user_pass').value = '';
document.getElementById('user_pass').focus();
});
Les trois dernières lignes sont les plus importantes. Le bot reçoit l'erreur exacte de WordPress, le champ mot de passe vidé, le curseur remis dedans : un échec de connexion parfaitement ordinaire. Donc il recommence, et chaque tentative supplémentaire est un couple identifiant / mot de passe de plus avant que l'IP ne tombe.
Les bans n'arrivent pas tous par le même chemin, et chacun est étiqueté par sa provenance dans la base.
sshd s'en charge.
Cette répartition dit quelque chose d'utile sur Internet : l'immense majorité du bruit hostile ne vise pas le web. Elle balaye des ports de bases de données, de partage de fichiers et d'administration à distance, en espérant tomber sur un service oublié et sans mot de passe.
L'API des statistiques fusionne les deux mondes à la volée : les bans du honeypot sortent de SQLite, ceux de fail2ban sont interrogés en direct. Le compteur de la home est la somme des deux.
Un ban vit à deux endroits, qui ne servent pas à la même chose.
Le set nftables est la blocklist active. C'est lui qui jette les paquets, et il est fait pour ça :
set banned { type ipv4_addr flags timeout # chaque element porte sa propre expiration timeout 30d } chain input { ip saddr @banned drop }
Une règle par IP ferait grossir la chaîne linéairement : à soixante mille bans, chaque paquet entrant traverserait soixante mille comparaisons. Un set se consulte à coût constant quelle que soit sa taille, et la chaîne ne garde qu'une seule règle.
La base SQLite est le registre permanent : une ligne par IP avec sa première et sa dernière apparition, son nombre de hits, son service, sa source et son pays, plus une table d'événements pour la timeline. Elle tourne en mode WAL, ce qui laisse l'API lire pendant que le honeypot écrit.
Le set nftables expire ses entrées au bout de trente jours, et il est vide après un redémarrage. La base, elle, n'oublie rien. Au démarrage, le démon repeuple le set depuis la base.
Les deux propriétés qu'on veut sont donc obtenues sans les inconvénients de l'une ou de l'autre : les bans survivent aux reboots parce que la base est durable, et le firewall ne gonfle pas indéfiniment parce que le noyau expire ses propres entrées. Aucun cron de purge à écrire, à surveiller, puis à réparer six mois plus tard.
L'ordre de démarrage compte : une unité systemd dédiée charge la table et le set avant que le honeypot ne se lance. Sans elle, le démon essaierait de remplir un set qui n'existe pas encore.
Un tableau de bord temps réel a un mode de panne bien à lui : il se vide. L'API tousse une seconde, le poll suivant renvoie une liste vide, tous les panneaux tombent à zéro. Le visiteur ne lit pas « l'API a eu un hoquet », il lit « il ne se passe rien sur ce serveur », exactement le message inverse de celui qu'on voulait.
const [dash, shame] = await Promise.allSettled([ grab('/api/dashboard'), grab('/api/shame') ]);
allSettled et non all : si le mur de la honte tombe, les compteurs continuent de s'afficher.
const seen = new Set(feedBuf.map(x => x._k)); const fresh = recent.filter(e => !seen.has(fkey(e))); if (fresh.length) feedBuf = fresh.concat(feedBuf).slice(0, 40);
Le flux live n'est pas ce que l'API vient de renvoyer, c'est ce que le navigateur a accumulé depuis l'ouverture de la page, dédoublonné et plafonné à quarante lignes. Un poll raté n'efface rien, il n'ajoute simplement rien. Un AbortController à huit secondes empêche par ailleurs deux cycles de se chevaucher.
Et quand l'API ne répond vraiment plus, la console le dit au lieu de faire semblant : la barre de statut passe de live à stale, puis à offline · demo. Les chiffres de secours sont horodatés relativement à l'instant présent, pour qu'on ne tombe jamais sur un tableau de bord fier d'afficher des données d'il y a six mois.
Le mur de la honte affiche des chaînes choisies par l'attaquant. C'est l'endroit le plus délicat du site : un honeypot qui se fait XSS par le contenu qu'il collecte est un honeypot qui a changé de camp. Et ce n'est pas théorique : les tentatives déjà capturées contiennent des injections SQL en guise de nom d'utilisateur.
La règle tient en une ligne : pas un seul innerHTML dans tout le fichier. Chaque cellule est construite en DOM et remplie par textContent.
// jamais : row.innerHTML = '<span>' + e.pass + '</span>' const s = document.createElement('span'); s.textContent = val == null ? '' : String(val); row.appendChild(s);
Un mot de passe qui vaudrait <img src=x onerror=alert(1)> s'affiche pour ce qu'il est : du texte. C'est moins agréable à écrire qu'un gabarit, et c'est le prix à payer.
Deux dépôts, deux pipelines, la même mécanique. Le site est statique et part vers la racine web ; le honeypot est du Python et part vers /opt, avec un py_compile en garde-fou avant l'envoi et un redémarrage des services derrière.
rsync ... deploy@serveur:/home/deploy/staging/...
ssh deploy@serveur sudo /usr/local/bin/deploy-app <app>
Le détail qui compte : la CI ne décide pas où vont les fichiers. Elle dépose dans un staging et appelle un script. C'est le serveur qui détient le mapping, hors des dépôts. Quelqu'un qui prendrait la main sur GitHub pourrait changer le contenu, pas la destination.
Et surtout, les données ne sont jamais sur le chemin d'un déploiement. La base vit dans un répertoire qu'aucun rsync ne touche. Un mauvais push casse le service, pas soixante mille bans accumulés.
Trois faiblesses connues, non résolues au moment où j'écris. La première se voit en affichant le source de n'importe quelle page : autant la nommer moi-même.
- La CSP autorise
'unsafe-inline'sur les scripts. Tout le JS du site est inline, donc elle ne protège pas contre l'injection de script : elle limite surtout les destinations réseau. La lever demande d'extraire le JS dans des fichiers ou de passer à des hash. Tant que ce n'est pas fait,textContentest la vraie défense, pas la CSP. - Le mur de la honte est presque vide. Les bots tapent massivement sur les ports, mais soumettent très rarement le faux formulaire web : quelques dizaines d'entrées là où les ports en comptent des dizaines de milliers. Le panneau est honnête, il est juste beaucoup moins vivant que les autres.
- Le bannissement est purement réactif. Une IP est coupée après son premier contact, jamais avant. Rien ne corrèle les plages d'adresses ni les systèmes autonomes, alors qu'une bonne partie du trafic vient manifestement des mêmes hébergeurs. Bannir par préfixe serait plus efficace, et nettement plus risqué.