
🌐 También en: English · Deutsch · Français
Esta es la continuación del largo artículo de ayer, Defense in Depth: nueve capas entre internet y mis servidores. Allí, la alarma de inicio de sesión recibió una sola frase. Aquí explico cómo funciona, por qué está construida así – y qué aprende un atacante al leer esto.
La idea es antigua y sencilla: si alguien inicia sesión en uno de mis servidores, quiero saberlo – en minutos, no cuando por casualidad mire un registro una semana después. Cada inicio de sesión me envía un correo. Como sé cuándo he entrado yo mismo, cada correo que no esperaba merece una mirada inmediata. Es un cable trampa: no detiene a nadie, se asegura de que una intrusión no pase desapercibida.
Había tres requisitos. Nunca debe estorbar un inicio de sesión – una función de seguridad que me deja fuera de mi propio servidor es peor que ninguna. Debe permanecer en silencio ante todo lo automatizado; si no, me acostumbra a ignorarla. Y debe funcionar igual en los tres servidores, desplegada con Ansible como todo lo demás.
🔍 De dónde vienen los inicios de sesión: wtmp
Linux registra en /var/log/wtmp cada sesión que obtiene un terminal. El comando last lo lee, y con marcas de tiempo ISO es fácil de analizar:
$ 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)El detalle importante es lo que no aparece ahí: las sesiones sin terminal. Cuando mi equipo de copias de seguridad recoge las copias nocturnas, ejecuta un comando por SSH, no obtiene terminal y no deja ninguna entrada en wtmp. Es exactamente el filtro que quiero – las shells interactivas se notifican, los simples comandos automatizados no.
🧩 El script
Cada dos minutos, un pequeño script lee last, lo compara con el inicio de sesión más reciente que ya ha notificado y envía un solo correo con todo lo nuevo en esa ventana – varios inicios de sesión dan un correo, no cinco. El estado es una única marca de tiempo en /var/lib/ssh-login-alert/last-seen. En la primerísima ejecución en un host solo guarda la posición actual, para no enviarme todo el historial de golpe. Y el estado solo avanza después de que el correo haya salido: si el envío falla, la siguiente ejecución lo vuelve a intentar.
#!/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"
El correo sale por el mismo pequeño relé SMTP (msmtp) que ya envía los demás informes de los servidores. Tiene este aspecto:
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 temporizador, no un hook de PAM
La forma obvia de reaccionar a un inicio de sesión es un módulo PAM: pam_exec en la pila PAM de SSH ejecuta un script en cada sesión. Lo descarté por dos razones.
- Riesgo de quedarse fuera. PAM es el propio inicio de sesión. Una errata o un script que se cuelga en la pila PAM de SSH puede hacer que el servidor rechace todos los inicios de sesión – justo la situación en la que necesito la consola del proveedor de hosting.
- SELinux. Un script lanzado por PAM se ejecuta en el contexto de seguridad de sshd. Enviar correo desde ahí significa conexiones de red desde un dominio al que SELinux, con razón, no se lo permite.
Un temporizador de systemd no tiene ninguno de los dos problemas. Se ejecuta completamente separado de sshd, en su propia unidad, y lo peor que puede hacer un fallo es no enviar un correo. El precio es un retraso de hasta dos minutos – aceptable para un cable trampa.
# /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🤖 Silenciar a Ansible
La primera prueba trajo una sorpresa: cada ejecución de Ansible aparecía en wtmp como unas cuantas sesiones cortas. El plugin SSH de Ansible pide por defecto un terminal para algunos pasos. Ninguno de mis playbooks lo necesita – el pipelining está activado y nada pide nunca una contraseña –, así que una línea en ansible.cfg lo desactiva:
[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 = FalseDespués, una ejecución completa de un playbook ya no dejaba rastro en wtmp, y la alarma solo salta con sesiones interactivas reales. En el propio Ansible, todo son tres pequeñas tareas – el script desde una plantilla, dos archivos de unidad, activar el temporizador:
- 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)🕵️ ¿Le sirve esto a un atacante?
Este artículo describe exactamente qué dispara la alarma y qué no. Así que la pregunta honesta: ¿le da a un atacante una receta para pasar desapercibido?
En parte, sí – y no pasa nada. Quien lea esto con una clave robada sabe que ssh host 'comando' sin terminal no dispara ningún correo. Pero eso no es un agujero secreto, es como funciona wtmp, y quien conoce Linux lo sabe. Y lo más importante: la alarma es una capa entre varias. Para entrar hace falta primero una clave válida – las contraseñas están desactivadas, SSH solo escucha los rangos de direcciones de un país y los intentos fallidos se bloquean. Y lo que alguien haga dentro sin terminal tiene que superar aún los otros cables trampa: los cambios en cuentas de usuario, sudo, la configuración de SSH, los módulos del kernel o los secretos del clúster se registran y notifican por otras vías, y los archivos del sistema modificados se detectan la noche siguiente.
Quien consiga root también puede desactivar el temporizador. Pero el inicio de sesión interactivo que le llevó hasta ahí ya se ha enviado por correo – en dos minutos, mucho antes de que normalmente alguien empiece a borrar huellas. Lo que nos lleva de vuelta al principio de ayer: el diseño puede ser público; la protección viene de las claves y de tener más de una capa.
✅ Resultado
- Un correo por cada inicio de sesión SSH interactivo, en dos minutos, en los tres servidores.
- Silencioso con las copias de seguridad y con Ansible.
- Ningún cambio en sshd ni en PAM, así que no puede dejar fuera a nadie.
- Un límite deliberado (comandos sin terminal), cubierto por otras capas.
El primer correo real llegó durante las pruebas, unos minutos después del despliegue – un inicio de sesión de prueba que había provocado a propósito. Así es exactamente como debe sentirse: aburrido cuando fuiste tú, imposible de pasar por alto cuando no.




