
🌐 Aussi en: English · Deutsch · Español
Cet article fait suite au long article d’hier, Defense in Depth : neuf couches entre Internet et mes serveurs. L’alarme de connexion n’y avait droit qu’à une phrase. Voici comment elle fonctionne, pourquoi elle est construite ainsi – et ce qu’un attaquant apprend en lisant ceci.
L’idée est ancienne et simple : si quelqu’un se connecte à l’un de mes serveurs, je veux le savoir – en quelques minutes, pas le jour où je regarde par hasard un journal une semaine plus tard. Chaque connexion m’envoie un mail. Comme je sais quand je me suis connecté moi-même, chaque mail inattendu mérite un coup d’œil immédiat. C’est un fil d’alarme : il n’arrête personne, il garantit qu’une intrusion ne passe pas inaperçue.
Il y avait trois exigences. Elle ne doit jamais gêner une connexion – une fonction de sécurité qui m’empêche d’accéder à mon propre serveur est pire que rien. Elle doit rester silencieuse pour tout ce qui est automatisé, sinon elle m’habitue à l’ignorer. Et elle doit fonctionner de la même façon sur les trois serveurs, déployée par Ansible comme tout le reste.
🔍 D’où viennent les connexions : wtmp
Linux enregistre chaque session de connexion qui obtient un terminal dans /var/log/wtmp. La commande last le lit, et avec des horodatages ISO il est facile à analyser :
$ last -w -i --time-format iso | head -3
root pts/0 203.0.113.25 2026-10-14T05:56:45+00:00 - 2026-10-14T05:56:46+00:00 (00:00)
root pts/0 203.0.113.25 2026-10-13T14:27:37+00:00 - 2026-10-13T16:23:34+00:00 (01:55)
root pts/0 203.0.113.25 2026-10-13T07:53:55+00:00 - 2026-10-13T07:53:55+00:00 (00:00)Le détail important, c’est ce qui n’y figure pas : les sessions sans terminal. Quand ma machine de sauvegarde récupère les sauvegardes nocturnes, elle exécute une commande via SSH, n’obtient pas de terminal et ne laisse aucune entrée dans wtmp. C’est exactement le filtre que je veux – les shells interactifs sont signalés, les simples commandes automatisées non.
🧩 Le script
Toutes les deux minutes, un petit script lit last, compare le résultat avec la connexion la plus récente qu’il a déjà signalée et envoie un seul mail pour tout ce qui est nouveau dans cette fenêtre – plusieurs connexions donnent un mail, pas cinq. L’état est un simple horodatage dans /var/lib/ssh-login-alert/last-seen. Lors de sa toute première exécution sur un hôte, il ne fait qu’enregistrer la position actuelle, pour ne pas m’envoyer tout l’historique d’un coup. Et l’état n’avance qu’une fois le mail parti : si l’envoi échoue, l’exécution suivante réessaie.
#!/bin/bash
#
# Tripwire: mails every new interactive login since the previous run. Started
# every 2 minutes by ssh-login-alert.timer; all new logins of one window go
# out as ONE mail.
#
# Source is wtmp (read with "last"): only sessions that got a terminal are
# recorded there. The nightly backup pulls run plain commands without a
# terminal and never appear. Ansible requests a terminal for some steps, so a
# playbook run does show up (as short sessions) - you know when that was you.
set -u
MAILTO="you@example.com"
STATE_DIR=/var/lib/ssh-login-alert
STATE="$STATE_DIR/last-seen"
mkdir -p "$STATE_DIR"
# One line per login: start-time (ISO 8601 with the host's UTC offset) TAB
# user TAB tty TAB source IP. Sorted by time; the offset is the same for all
# entries of one host, so string comparison of the first field works.
logins=$(last -w -i --time-format iso 2>/dev/null \
| awk '$1 != "reboot" && $1 != "wtmp" && NF >= 4 { print $4 "\t" $1 "\t" $2 "\t" $3 }' \
| sort)
newest=$(printf '%s\n' "$logins" | tail -n 1 | cut -f 1)
# First run on this host: only remember the current position, so the alert
# does not mail the whole history at once.
if [ ! -s "$STATE" ]; then
printf '%s\n' "${newest:-1970-01-01T00:00:00+00:00}" > "$STATE"
exit 0
fi
seen=$(cat "$STATE")
new=$(printf '%s\n' "$logins" | awk -F '\t' -v seen="$seen" '$1 > seen')
[ -n "$new" ] || exit 0
count=$(printf '%s\n' "$new" | wc -l)
{
echo "New interactive login(s) on $(hostname -f):"
echo
printf '%s\n' "$new" | awk -F '\t' '{ printf " %s %-8s %-7s from %s\n", $1, $2, $3, $4 }'
echo
echo "Times carry the host's UTC offset. If none of these were you, check: last -i | head"
} | /usr/bin/mail -s "[$(hostname -f)] SSH login: $count new" "$MAILTO" || exit 1
# Only after the mail went out: a failed send is retried on the next run.
printf '%s\n' "$newest" > "$STATE"
Le mail part par le même petit relais SMTP (msmtp) qui envoie déjà les autres rapports des serveurs. Il ressemble à ceci :
Subject: [www.example.org] SSH login: 1 new
New interactive login(s) on www.example.org:
2026-10-14T08:01:02+00:00 root pts/1 from 203.0.113.25
Times carry the host's UTC offset. If none of these were you, check: last -i | head⏱️ Un timer, pas un hook PAM
La façon évidente de réagir à une connexion est un module PAM : pam_exec dans la pile PAM de SSH exécute un script à chaque session. J’y ai renoncé pour deux raisons.
- Risque de se retrouver bloqué. PAM, c’est la connexion elle-même. Une faute de frappe ou un script qui se bloque dans la pile PAM de SSH peut faire refuser toute connexion par le serveur – exactement la situation où j’ai alors besoin de la console de l’hébergeur.
- SELinux. Un script lancé par PAM s’exécute dans le contexte de sécurité de sshd. Envoyer un mail depuis là, ce sont des connexions réseau depuis un domaine auquel SELinux, à juste titre, ne le permet pas.
Un timer systemd n’a aucun de ces deux problèmes. Il tourne entièrement séparé de sshd, dans sa propre unité, et le pire qu’un bug puisse faire, c’est de ne pas envoyer de mail. Le prix est un délai pouvant aller jusqu’à deux minutes – acceptable pour un fil d’alarme.
# /etc/systemd/system/ssh-login-alert.service
[Unit]
Description=Mail new interactive SSH logins
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ssh-login-alert
# /etc/systemd/system/ssh-login-alert.timer
[Unit]
Description=Check for new interactive SSH logins every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
[Install]
WantedBy=timers.target🤖 Faire taire Ansible
Le premier test a réservé une surprise : chaque exécution d’Ansible apparaissait dans wtmp sous forme de quelques sessions courtes. Le plugin SSH d’Ansible demande par défaut un terminal pour certaines étapes. Aucun de mes playbooks n’en a besoin – le pipelining est activé, et rien ne demande jamais de mot de passe –, donc une ligne dans ansible.cfg le désactive :
[ssh_connection]
pipelining = True
# No terminal for Ansible's own connections - nothing here needs one, and
# without it a playbook run leaves no entry in wtmp.
usetty = FalseEnsuite, une exécution complète d’un playbook ne laissait plus aucune trace dans wtmp, et l’alarme ne se déclenche plus que pour de vraies sessions interactives. Dans Ansible même, l’ensemble tient en trois petites tâches – le script depuis un template, deux fichiers d’unité, activer le timer :
- name: SSH | Deploy the login alert script
ansible.builtin.template:
src: ssh-login-alert.sh.j2
dest: /usr/local/sbin/ssh-login-alert
owner: root
group: root
mode: "0750"
- name: SSH | Enable the login alert timer
ansible.builtin.systemd:
name: ssh-login-alert.timer
enabled: true
state: started
daemon_reload: "{{ _ssh_alert_units.changed }}"
# in --check the units were not written yet on a fresh host
when: not (ansible_check_mode and _ssh_alert_units.changed)🕵️ Est-ce que cela aide un attaquant ?
Cet article décrit exactement ce qui déclenche l’alarme et ce qui ne la déclenche pas. D’où la question honnête : donne-t-il à un attaquant une recette pour rester invisible ?
En partie, oui – et ce n’est pas un problème. Quiconque lit ceci avec une clé volée sait que ssh host 'commande' sans terminal ne déclenche pas de mail. Mais ce n’est pas une faille secrète, c’est le fonctionnement de wtmp, et quiconque connaît Linux le sait. Surtout, l’alarme est une couche parmi plusieurs. Pour entrer, il faut d’abord une clé valide – les mots de passe sont désactivés, SSH n’écoute que les plages d’adresses d’un seul pays, et les tentatives échouées sont bannies. Et ce que quelqu’un fait une fois à l’intérieur sans terminal doit encore passer les autres fils d’alarme : les modifications des comptes utilisateurs, de sudo, de la configuration SSH, des modules du noyau ou des secrets du cluster sont enregistrées et signalées par d’autres moyens, et les fichiers système modifiés sont détectés la nuit suivante.
Quelqu’un qui obtient les droits root peut aussi désactiver le timer. Mais la connexion interactive qui l’y a mené a déjà été envoyée par mail – en deux minutes, bien avant qu’on ne commence habituellement à effacer ses traces. Ce qui nous ramène au principe d’hier : la conception peut être publique ; la protection vient des clés et du fait d’avoir plus d’une couche.
✅ Résultat
- Un mail par connexion SSH interactive, en deux minutes, sur les trois serveurs.
- Silencieux pour les sauvegardes et pour Ansible.
- Aucune modification de sshd ni de PAM, donc impossible de bloquer qui que ce soit.
- Une limite assumée (les commandes sans terminal), couverte par d’autres couches.
Le premier vrai mail est arrivé pendant les tests, quelques minutes après le déploiement – une connexion de test que j’avais déclenchée exprès. C’est exactement l’effet recherché : ennuyeux quand c’était vous, impossible à manquer quand ce n’était pas le cas.




