Saltar al contenido principal

Postmortem #002 · Cuando el puerto 22 estaba abierto pero SSH no funcionaba

· 18 min de lectura
Nuria Liaño
Fundadora y profe de sistema, redes y programación

El 16 de octubre de 2026 intentamos conectarnos por SSH a uno de nuestros servidores de producción y recibimos un error bastante poco tranquilizador:

kex_exchange_identification: read: Connection reset
Connection reset by <servidor> port 22

La primera impresión podía ser que el servidor estaba caído, que el firewall estaba bloqueando el acceso o que el servicio SSH había dejado de escuchar.

Pero ninguna de esas explicaciones encajaba del todo.

Las webs alojadas en el servidor seguían respondiendo correctamente.

El puerto 22 aparecía abierto.

Desde otro servidor de nuestra infraestructura también era posible establecer la conexión TCP al puerto SSH.

Y, aun así, ninguna sesión SSH conseguía llegar siquiera al intercambio inicial de claves.

La causa terminó siendo una combinación bastante peculiar:

  • un proceso sshd antiguo había quedado vivo fuera del estado normal gestionado por systemd;
  • ese proceso seguía ocupando el puerto 22;
  • durante una operación automática de mantenimiento, ssh.service se detuvo;
  • ssh.socket no pudo recuperar el puerto porque seguía ocupado;
  • systemd eliminó /run/sshd, un directorio temporal ligado al ciclo de vida del servicio;
  • el listener antiguo siguió aceptando conexiones TCP, pero no podía completar nuevas sesiones SSH.

El resultado era engañoso:

servidor                  ✅
webs ✅
red ✅
puerto 22 ✅
SSH ❌

Y precisamente esa contradicción hizo interesante el incidente.

Nota de seguridad

Algunos nombres, direcciones IP, dominios y detalles concretos de la infraestructura han sido anonimizados o simplificados.

La secuencia técnica y la causa del incidente se mantienen fieles al caso real.


El primer síntoma: Connection reset​

El intento de conexión era sencillo:

ssh servidor

pero terminaba con:

kex_exchange_identification: read: Connection reset
Connection reset by <IP> port 22

Este mensaje aparece muy pronto en una conexión SSH.

No habíamos llegado todavía a:

  • validar una clave pública;
  • comprobar authorized_keys;
  • abrir una sesión;
  • ejecutar PAM;
  • ni siquiera completar el intercambio inicial de protocolo.

Eso nos permitía descartar bastantes cosas.

No parecía un problema de la clave SSH del usuario.

Tampoco parecía un problema específico de Windows, porque desde otro servidor Linux ocurría exactamente lo mismo.

La pregunta pasó a ser:

¿hasta dónde llega realmente la conexión antes de romperse?


Primera comprobación: ¿está vivo el servidor?

Antes de tocar SSH había que comprobar si estábamos ante un problema general de la máquina.

Probamos conectividad al servidor:

Test-NetConnection <IP> -Port 22
Test-NetConnection <IP> -Port 80
Test-NetConnection <IP> -Port 443

Los tres puertos respondían.

Después comprobamos las webs públicas:

curl -I https://example.com
curl -I https://cursos.example.com

Ambas devolvían:

HTTP/1.1 200 OK

Desde otro servidor de la infraestructura hicimos además:

ping -c 4 <IP>
nc -vz -w 5 <IP> 22
nc -vz -w 5 <IP> 80
nc -vz -w 5 <IP> 443

Todo respondía.

Así que ya podíamos dibujar un primer mapa:

Servidor                  ✅
ICMP ✅
HTTP ✅
HTTPS ✅
TCP/22 ✅
Aplicaciones públicas ✅
SSH ❌

Esto es importante porque evita una reacción muy tentadora:

"SSH no funciona → reiniciemos el servidor"

El servidor estaba prestando servicio correctamente.

Reiniciarlo habría introducido riesgo en aplicaciones que en ese momento estaban sanas.


Un puerto abierto no significa que el servicio funcione

Aquí apareció una de las primeras lecciones del incidente.

Una prueba TCP como:

TcpTestSucceeded : True

solo demuestra que conseguimos establecer una conexión TCP con ese puerto.

No demuestra que el protocolo que vive encima esté funcionando correctamente.

En nuestro caso:

TCP handshake
↓
correcto
↓
puerto 22 abierto
↓
SSH handshake
↓
reset

Para comprobarlo desde Linux usamos:

ssh-keyscan -T 5 <IP>

El resultado fue:

Connection closed by remote host

Y con depuración detallada:

ssh -vvv -o ConnectTimeout=5 usuario@<IP>

veíamos:

Connection established.
Local version string SSH-2.0-OpenSSH_...
kex_exchange_identification: read: Connection reset by peer

Faltaba algo que normalmente debería aparecer inmediatamente:

Remote protocol version 2.0

La conexión llegaba al servidor, pero el servidor no conseguía completar ni siquiera el inicio del protocolo SSH.


Recuperar una vía de administración

Como SSH no servía, necesitábamos una segunda vía de entrada.

Utilizamos la consola/VNC del proveedor para acceder directamente al sistema.

Este detalle fue importante.

Una consola fuera de banda permite administrar un servidor incluso cuando:

  • SSH está roto;
  • el firewall está mal configurado;
  • la red del sistema tiene problemas;
  • una clave se ha perdido;
  • o el propio servicio remoto no puede arrancar.

Una vez dentro, la investigación cambió completamente.


La pista definitiva estaba en los logs

Revisamos los eventos recientes de SSH:

sudo journalctl -u ssh --since "30 minutes ago" --no-pager

Y apareció repetidamente:

fatal: Missing privilege separation directory: /run/sshd

Ese mensaje explicaba por qué el puerto estaba abierto pero las nuevas conexiones no llegaban a funcionar.

Comprobamos el directorio:

ls -ld /run /run/sshd

Resultado simplificado:

/run       existe
/run/sshd no existe

¿Qué es /run/sshd?

/run contiene datos temporales de ejecución del sistema.

Normalmente está respaldado por memoria y su contenido no debe considerarse persistente.

En nuestra unidad de systemd para OpenSSH existían estas directivas:

RuntimeDirectory=sshd
RuntimeDirectoryMode=0755

Esto significa que systemd es responsable de gestionar:

/run/sshd

mientras el servicio está activo.

Conceptualmente:

systemd inicia ssh.service
↓
crea /run/sshd
↓
arranca sshd

Y, cuando el servicio deja de estar activo, ese directorio puede desaparecer como parte de su ciclo de vida.

Así que el problema no era simplemente:

alguien borró /run/sshd

Había que entender por qué el servicio y el proceso que escuchaba en el puerto 22 habían dejado de estar coordinados.


Un estado imposible: servicio muerto, proceso vivo

Comprobamos systemd:

systemctl status ssh.service ssh.socket

El resultado era extraño.

Simplificando:

ssh.service → inactive (dead)
ssh.socket → failed

Pero al comprobar quién estaba escuchando en el puerto 22:

sudo ss -lntp '( sport = :22 )'

aparecía un proceso real:

sshd ... [listener]

Y ps confirmaba que llevaba vivo desde semanas antes.

Teníamos algo conceptualmente parecido a esto:

systemd
│
├─ ssh.service → dead
│
└─ ssh.socket → failed

pero...

sshd antiguo
└─ sigue vivo
└─ sigue escuchando :22

Ese era el corazón del incidente.


ssh.service y ssh.socket

En Ubuntu moderno OpenSSH puede utilizar activación mediante sockets de systemd.

En lugar de depender exclusivamente de que sshd arranque durante el boot, podemos tener:

ssh.socket
↓
escucha el puerto 22
↓
activa ssh.service

Esto permite que systemd gestione el socket y lance el servicio cuando corresponde.

En un estado sano observamos algo similar a:

ssh.socket
→ enabled
→ active

ssh.service
→ puede estar disabled para boot directo
→ active cuando ha sido activado por ssh.socket

Otro servidor equivalente de nuestra infraestructura estaba precisamente en ese estado y funcionaba correctamente.

El servidor afectado, sin embargo, tenía habilitados simultáneamente:

ssh.service → enabled
ssh.socket → enabled

Esto no explica por sí solo todo el incidente, pero introducía un estado distinto al servidor de referencia y aumentaba la complejidad del ciclo de vida de SSH.


Reconstruyendo la secuencia con journalctl

Buscamos los minutos exactos en los que SSH había dejado de comportarse normalmente:

sudo journalctl \
-u ssh.service \
-u ssh.socket \
--since "06:00" \
--until "06:20"

La secuencia relevante era muy clara.

Simplificada:

06:13:45
│
├─ systemd detiene ssh.service
│
├─ el MainPID recibe SIGTERM
│
├─ ssh.service se desactiva correctamente
│
└─ systemd avisa:
"un proceso sshd sigue vivo después de parar la unidad"

│
▼

ssh.socket se detiene

│
▼

intenta volver a escuchar :22

│
▼

no puede recuperar el socket

│
▼

ssh.socket → failed

│
▼

ssh.service no puede arrancar por dependencia

│
▼

/run/sshd ya no está disponible

│
▼

el sshd antiguo continúa aceptando TCP

│
▼

cada nueva conexión termina con:

Missing privilege separation directory: /run/sshd

Systemd lo había dejado escrito explícitamente:

Unit process (...) remains running after unit stopped.

Y pocos segundos después:

Failed to listen on ssh.socket

seguido por:

fatal: Missing privilege separation directory: /run/sshd

La cadena estaba completa.


¿Qué provocó que SSH se reiniciara?

El siguiente paso era entender por qué ssh.service se había detenido a esa hora.

Revisamos los logs de mantenimiento automático:

sudo grep -iE 'openssh|ssh' \
/var/log/unattended-upgrades/unattended-upgrades.log

En ese mismo minuto unattended-upgrades estaba procesando una actualización que incluía numerosos paquetes de systemd y había considerado también paquetes de OpenSSH.

Pero hay una precisión importante.

Los propios logs indicaban posteriormente que los paquetes de OpenSSH habían quedado retenidos.

Por tanto, la conclusión correcta no es:

"una actualización de OpenSSH rompió SSH"

Lo que sí sabemos es:

mantenimiento automático del sistema
+
actualización de componentes de systemd
+
reinicio/parada de ssh.service
+
estado previo con un listener residual
=
incidente

Las actualizaciones automáticas fueron el desencadenante que hizo visible el problema, no necesariamente su causa raíz.


El problema venía de antes

La investigación histórica añadió otra pieza importante.

El proceso sshd que sobrevivió al incidente había nacido semanas antes.

Al revisar journalctl alrededor de aquel momento encontramos que systemd ya había detectado procesos residuales durante un reinicio anterior de SSH:

Unit process (...) remains running after unit stopped.
Found left-over process (...) in control group while starting unit.
This usually indicates unclean termination of a previous run...

Después arrancó un nuevo listener SSH.

Eso nos enseñó algo importante:

el evento de octubre no creó necesariamente la anomalía; hizo que una anomalía previa terminara convirtiéndose en una pérdida de acceso.

No conseguimos demostrar con los logs disponibles el instante exacto en el que el listener antiguo dejó de ser el MainPID y pasó a convertirse en un proceso residual.

Por tanto, preferimos no inventar una explicación que las evidencias no soportan.

Sí podemos afirmar que, cuando ocurrió el incidente, systemd gestionaba un MainPID diferente mientras otro listener sshd más antiguo seguía vivo y ocupaba el puerto 22.


El ruido que casi nos despista: bots contra SSH

Los logs estaban llenos de intentos como:

Invalid user admin
Invalid user test
Failed password for root
Connection closed [preauth]

Esto es habitual cuando un puerto SSH está expuesto a Internet.

También teníamos Fail2ban funcionando y muchas direcciones habían sido bloqueadas históricamente.

Era fácil pensar:

"nos están atacando y han saturado SSH"

Pero las evidencias no apuntaban ahí.

El fallo ocurría también desde otro servidor de confianza.

El puerto aceptaba TCP.

Y el journal mostraba una causa local muchísimo más directa:

Missing privilege separation directory

Los intentos automatizados eran ruido operacional, no la causa del incidente.

Esta distinción es importante en diagnóstico:

Que algo llamativo aparezca en los logs no significa que sea causal.


Recuperación temporal: volver a crear /run/sshd

Antes de cambiar servicios necesitábamos recuperar acceso administrativo normal.

Desde la consola del proveedor recreamos temporalmente el directorio esperado:

sudo install -d -o root -g root -m 0755 /run/sshd

Validamos la configuración:

sudo sshd -t

No produjo errores.

En ese momento las nuevas conexiones SSH volvieron a funcionar, porque el listener antiguo podía volver a crear procesos hijos correctamente.

Pero esto solo era recovery.

No habíamos corregido el estado inconsistente de systemd.


Recovery no es remediation

La diferencia era clara.

Recovery​

crear /run/sshd
→ SSH vuelve a aceptar sesiones

Remediation​

eliminar listener huérfano
→ recuperar control de systemd sobre :22
→ normalizar ssh.socket / ssh.service
→ declarar el estado en infraestructura

Si nos hubiéramos quedado simplemente con:

mkdir /run/sshd

el problema podría haber vuelto a aparecer en otro ciclo de mantenimiento.

Además, /run es temporal.

Crear ese directorio manualmente no sustituye a restaurar el ciclo de vida correcto del servicio.


Normalizando SSH sin reiniciar el servidor

Con VNC disponible como red de seguridad y una sesión SSH abierta, validamos primero:

sudo sshd -t

Después identificamos exactamente el listener residual y terminamos solo ese proceso, no todos los procesos SSH:

sudo kill -TERM <pid-del-listener>

Comprobamos que el puerto quedaba libre:

sudo ss -lntp '( sport = :22 )'

A continuación limpiamos el estado fallido y arrancamos de nuevo el socket:

sudo systemctl reset-failed ssh.socket
sudo systemctl start ssh.socket

El resultado:

ssh.socket → active (listening)

Abrimos una nueva terminal y probamos:

ssh servidor

La conexión funcionó.

Systemd volvió a activar correctamente ssh.service:

ssh.service → active (running)
MainPID → válido
ssh.socket → active

Y el puerto 22 aparecía gestionado coherentemente por systemd y el nuevo sshd.

Todo ello sin reiniciar la máquina.

Las webs y los contenedores siguieron funcionando durante toda la intervención.


Una diferencia entre servidores que resultó útil

Comparamos el servidor afectado con otro servidor equivalente.

En el servidor sano:

ssh.service → disabled
ssh.socket → enabled

Esto no significa que SSH esté apagado.

ssh.service puede estar perfectamente:

active (running)

porque ha sido activado por ssh.socket.

En el servidor afectado encontrábamos inicialmente:

ssh.service → enabled
ssh.socket → enabled

Normalizamos el servidor afectado al mismo modelo que el servidor sano:

sudo systemctl disable ssh.service

sin utilizar --now, para no detener el servicio durante la intervención.

Después verificamos:

ssh.service → disabled + active
ssh.socket → enabled + active

Y una nueva conexión desde otro equipo volvió a funcionar correctamente.


No desactivamos las actualizaciones automáticas

Igual que en otros incidentes provocados durante tareas de mantenimiento, una conclusión fácil sería:

"desactivemos unattended-upgrades"

No sería la lección correcta.

Las actualizaciones de seguridad siguen siendo necesarias.

El problema real era que una operación perfectamente normal encontró un estado anómalo preexistente en SSH y lo transformó en una pérdida de acceso administrativo.

Queremos que el servidor pueda soportar:

actualizaciones
reinicios de servicios
mantenimiento

sin depender de que nunca ocurra nada inesperado.


Causa inmediata, causa raíz y factores contribuyentes

Separar estos conceptos ayuda a no quedarnos con una explicación superficial.

Síntoma​

No era posible establecer sesiones SSH.

Fallo inmediato​

Los nuevos procesos sshd fallaban porque /run/sshd no existía.

Motivo técnico​

ssh.service estaba detenido,
ssh.socket había fallado,
y un listener sshd residual seguía ocupando el puerto 22.

Desencadenante​

Una operación automática de mantenimiento detuvo/reinició
componentes del sistema y SSH.

Estado previo que permitió el incidente​

Existía un listener sshd residual que no coincidía
con el MainPID gestionado por systemd.

Factor de complejidad​

El servidor tenía habilitados simultáneamente
ssh.service y ssh.socket,
a diferencia del servidor de referencia.

Factor de detección​

El puerto 22 seguía aceptando TCP,
por lo que una comprobación simple de puerto abierto
no detectaba que SSH estaba roto.

Impacto

El impacto fue limitado, pero importante.

Afectado​

  • acceso administrativo SSH al servidor;
  • automatizaciones o tareas que dependieran de SSH hacia ese host;
  • capacidad de administración remota normal.

No afectado​

  • las webs públicas;
  • Nginx;
  • los contenedores de aplicaciones;
  • HTTPS;
  • el otro servidor de infraestructura;
  • los datos persistentes.

La máquina nunca dejó de servir las aplicaciones públicas.

Esto redujo mucho el impacto para usuarios finales, pero creó un riesgo operacional evidente:

Si durante ese periodo hubiese aparecido otro problema en producción, nuestra principal vía de administración estaba indisponible.


¿Cuánto duró?

Los logs sitúan el fallo persistente de SSH alrededor de las 06:14 del 15 de octubre.

El acceso quedó completamente normalizado aproximadamente a las 10:57 del 16 de octubre.

Por tanto, el estado incorrecto persistió durante algo más de 28 horas.

No detectamos el incidente inmediatamente porque las aplicaciones públicas continuaban funcionando.

Este punto refuerza otra necesidad:

monitorizar la capacidad real de administración, no solo la disponibilidad de las webs.


¿Cómo evitar que vuelva a pasar?

El incidente deja varias acciones concretas.

1. Declarar explícitamente el modelo SSH en Ansible​

Los servidores equivalentes no deberían depender de estados históricos diferentes.

El objetivo será declarar algo conceptualmente equivalente a:

ssh.socket
→ enabled

ssh.service
→ disabled como unidad de arranque directo
→ gestionado mediante ssh.socket

Así un servidor nuevo y uno existente convergerán al mismo modelo.


2. Detectar listeners SSH huérfanos​

Una comprobación como:

ss -lntp '( sport = :22 )'

no debería limitarse a comprobar que alguien escucha.

Debemos verificar también que:

listener real
==
proceso esperado por systemd

Un estado como:

MainPID = 0
pero
sshd -D sigue escuchando :22

debe considerarse drift crítico.


3. Auditar conjuntamente ssh.service y ssh.socket​

La auditoría debería comprobar al menos:

sshd -t                       OK
ssh.socket estado esperado
ssh.service estado coherente
/run/sshd coherente con el runtime
puerto 22 LISTEN
listener sshd proceso esperado
MainPID consistente

Una única señal no es suficiente.


4. Monitorizar SSH a nivel de protocolo​

Comprobar únicamente:

TCP/22 abierto

no habría detectado este incidente.

Una comprobación externa puede intentar obtener el banner SSH o completar una negociación controlada.

Conceptualmente:

monitor
↓
conecta :22
↓
¿responde protocolo SSH?
│
├─ sí → OK
└─ no → alerta

No es necesario autenticarse para saber que el daemon responde correctamente al protocolo.


5. Mantener una vía de acceso fuera de banda​

La consola del proveedor fue esencial.

Hay que documentar:

  • cómo acceder;
  • dónde está la consola/VNC;
  • qué hacer si SSH no funciona;
  • qué comprobar antes de reiniciar una máquina.

Disponer de VNC o consola de emergencia puede evitar convertir un fallo de un único servicio en un reboot innecesario de producción.


6. Añadir este caso al Disaster Recovery​

El procedimiento debería incluir un runbook específico para:

kex_exchange_identification: Connection reset

con comprobaciones como:

systemctl status ssh.service ssh.socket
ss -lntp '( sport = :22 )'
sshd -t
journalctl -u ssh.service -u ssh.socket
ls -ld /run/sshd

Y una recuperación controlada, evitando comandos globales como:

pkill sshd

que podrían terminar también sesiones administrativas válidas.


7. Probar el escenario después de implementar la remediación​

No consideraremos cerrado el problema únicamente porque hoy SSH vuelva a funcionar.

Cuando la política quede declarada en infraestructura habrá que probar de forma controlada:

reinicio de ssh.service
reinicio de ssh.socket
actualización de paquetes relacionados
reboot del host

Y validar:

SSH sigue disponible
no aparecen listeners residuales
/run/sshd se gestiona correctamente
systemd conserva un estado coherente

Qué aprendimos

Un puerto abierto no es un health check​

Esta probablemente sea la lección más clara del incidente.

Teníamos:

22/tcp → OPEN

pero SSH estaba inutilizable.

Un health check debe comprobar la función que realmente necesitamos, no únicamente una capa inferior de la pila.


Los procesos y el gestor de servicios pueden divergir​

Estamos acostumbrados a pensar:

systemctl status servicio

como representación completa de lo que está ocurriendo.

Pero pueden existir procesos residuales.

Por eso, cuando un servicio presenta comportamientos extraños, es útil combinar:

systemctl
ps
ss
journalctl

Cada herramienta observa una parte diferente del estado.


inactive no siempre significa que no haya ningún proceso​

En nuestro caso:

ssh.service → inactive

convivía con:

sshd → escuchando en :22

Era precisamente esa contradicción la que revelaba el problema.


Los estados heredados importan​

Dos servidores con la misma versión de Ubuntu pueden comportarse de manera diferente si acumulan años o meses de configuración histórica.

Comparar el servidor afectado con otro equivalente nos permitió detectar rápidamente una diferencia:

servidor A:
ssh.service enabled
ssh.socket enabled

servidor B:
ssh.service disabled
ssh.socket enabled

La infraestructura declarativa ayuda a evitar precisamente ese tipo de drift.


No todo lo que ocurre durante una actualización es culpa de la actualización​

El incidente coincidió con unattended-upgrades.

Pero los logs mostraban que el estado problemático venía de antes.

La actualización actuó como desencadenante.

Eso es diferente de afirmar que el paquete actualizado tenía un bug o que las actualizaciones automáticas son inseguras.

En un postmortem debemos intentar mantener esa diferencia.


Los logs históricos permiten descubrir problemas latentes​

El proceso que terminó siendo importante llevaba vivo desde semanas antes.

Sin revisar el journal histórico probablemente habríamos concluido simplemente:

systemd se volvió loco durante una actualización

Los logs mostraron que ya existían advertencias de procesos residuales en reinicios anteriores de SSH.

El incidente actual era la consecuencia visible de una incoherencia previa.


Recovery y remediation son cosas distintas​

Crear /run/sshd recuperó el acceso.

Eliminar el listener residual y devolver el control del puerto a systemd normalizó el runtime.

Declarar el estado en Ansible y añadir auditoría será la remediación permanente.

Cada paso resuelve un nivel distinto del problema.


Cronología final

La secuencia simplificada quedó así:

Semanas antes
│
├─ reinicios de ssh.service dejan evidencias de procesos residuales
│
└─ un listener sshd antiguo permanece vivo

│
▼

15 de octubre · 06:13
│
├─ comienza mantenimiento automático del sistema
│
├─ systemd detiene ssh.service
│
├─ termina el MainPID gestionado
│
├─ otro sshd permanece vivo
│
├─ ssh.socket se detiene
│
├─ /run/sshd deja de estar disponible
│
├─ ssh.socket intenta volver a escuchar :22
│
├─ el puerto sigue ocupado
│
└─ ssh.socket queda failed

│
▼

El listener residual continúa aceptando TCP
│
└─ nuevas conexiones SSH fallan antes del handshake

│
▼

16 de octubre
│
├─ detectamos que SSH devuelve Connection reset
├─ confirmamos que las webs siguen respondiendo
├─ confirmamos TCP/22 abierto
├─ reproducimos el fallo desde otro servidor
├─ accedemos por consola/VNC
├─ encontramos Missing privilege separation directory: /run/sshd
├─ recreamos temporalmente /run/sshd
└─ recuperamos acceso SSH

│
▼

Investigación
│
├─ ssh.service aparece inactive
├─ ssh.socket aparece failed
├─ un sshd residual sigue escuchando :22
├─ journalctl reconstruye la parada del servicio
└─ los logs históricos muestran procesos residuales previos

│
▼

Remediación operativa
│
├─ validamos sshd -t
├─ terminamos únicamente el listener residual
├─ liberamos :22
├─ reset-failed ssh.socket
├─ arrancamos ssh.socket
├─ nueva sesión SSH funciona
├─ systemd recupera un MainPID coherente
└─ normalizamos el modelo de activación con el servidor de referencia

│
▼

Servicio recuperado y estado normalizado

Conclusión

Este incidente no provocó una caída de las webs.

Eso fue precisamente lo que lo hizo fácil de pasar por alto.

El servidor seguía funcionando, Nginx seguía respondiendo y las aplicaciones continuaban saludables.

Solo habíamos perdido la principal vía de administración.

El comportamiento resultaba además engañoso:

puerto 22 abierto
+
TCP conecta
+
servidor responde

parecía indicar que SSH estaba disponible.

No lo estaba.

La causa real requería mirar varias capas a la vez:

systemd
+
ssh.socket
+
ssh.service
+
procesos sshd
+
RuntimeDirectory
+
actualizaciones automáticas

Cada pieza aislada parecía razonable.

La combinación de un proceso residual, una parada del servicio y el ciclo de vida de /run/sshd creó un estado en el que el puerto seguía abierto pero el protocolo había dejado de funcionar.

La recuperación terminó siendo relativamente pequeña.

Entender por qué había ocurrido llevó bastante más investigación.

Y, de nuevo, esa es la parte útil de un postmortem:

No documentamos un incidente solo para recordar qué comando lo arregló. Lo documentamos para transformar una anomalía concreta en una mejora reproducible de la infraestructura.