Asegurar SSH en tu servidor: llaves, sshd_config, fail2ban, bastión y túneles, probado en un laboratorio

Si tienes un servidor con SSH abierto a internet, alguien está intentando entrar en este momento. No es nada personal: hay robots que recorren todas las direcciones IP probando usuarios como admin, root u oracle con contraseñas comunes. Esta guía deja tu servidor en un estado en el que esos intentos no tienen nada que hacer: solo se entra con llave, solo los usuarios que tú digas, y quien insiste queda bloqueado. Además verás cómo entrar a máquinas internas por un bastión, cómo funcionan los túneles y qué cambió con el cifrado poscuántico.

Todo lo que ves aquí se ejecutó de verdad. Montamos un laboratorio con un servidor Debian 13 (OpenSSH 10.0p2, fail2ban, nftables y ufw), una laptop, un atacante, un bastión, una máquina interna sin salida a internet y un cliente con OpenSSH 10.6p2. Las salidas son las que dieron esas máquinas. Si nunca has usado SSH, empieza por Qué es SSH y sus comandos principales: esta guía da por sabido lo básico (conectarte, scp, ~/.ssh/config y el túnel -L).

Antes de empezar: qué vas a cambiar

Endurecer SSH es tocar la puerta por la que tú mismo entras. Por eso el orden importa: primero te aseguras de entrar con llave, luego quitas las contraseñas, y cada cambio se prueba en una terminal nueva mientras otra sesión sigue abierta. Lo que vas a dejar listo:

  • Llaves en lugar de contraseñas, y la cuenta root sin entrada directa.
  • Una lista de usuarios permitidos (AllowUsers o AllowGroups).
  • Bloqueo automático de quien falla varias veces, con fail2ban, y un límite de conexiones en el firewall.
  • Saber leer el registro: quién intentó, con qué usuario y quién sí entró.
  • Para quien lo necesite: llaves físicas, bastión, túneles limitados y certificados.

Capas, de afuera hacia adentro Un intento de entrada tiene que pasar las cuatro 1 Firewall limita conexiones nuevas por IP nftables o ufw limit 2 fail2ban lee el registro y bloquea la IP tras 3 intentos fallidos 3 sshd_config sin contraseñas, sin root, solo los usuarios de AllowUsers 4 Tu llave ed25519 con frase de paso, o una llave física FIDO2 Si una capa falla o la configuras mal, las otras siguen en pie. Ninguna sola basta, y cambiar el puerto no es una capa.

Cada capa cubre lo que la anterior deja pasar. Esquema: decodigo.com.

El laboratorio de esta guía

Las máquinas del laboratorio están en una red de Docker propia. El servidor arranca con systemd, como uno real, para que systemctl, journalctl y fail2ban funcionen igual que en tu VPS. La versión de partida:

ssh -V; sshd -V 2>&1 | head -1
salida · el servidorOpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026
OpenSSH_10.0p2 Debian-7+deb13u4, OpenSSL 3.5.7 9 Jun 2026

Y así viene configurado de fábrica en Debian 13 (las líneas que nos importan; sshd -T imprime la configuración efectiva, ya con todos los archivos y valores por omisión):

sudo sshd -T | grep -E "^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries|logingracetime|x11forwarding|allowtcpforwarding|usepam|kexalgorithms) "
salida · el servidorport 22
usepam yes
logingracetime 120
maxauthtries 6
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication yes
kbdinteractiveauthentication no
x11forwarding yes
allowtcpforwarding yes
kexalgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521

Fíjate en dos cosas: passwordauthentication yes, es decir, cualquiera puede probar contraseñas, y maxauthtries 6. root ya no puede entrar con contraseña (without-password significa «solo con llave»), pero cualquier otro usuario sí. Desde el atacante, con la contraseña de ana:

sshpass -p "la-contraseña-de-ana" ssh -o PubkeyAuthentication=no ana@servidor "echo entré como $(whoami)"
salida · el atacanteentré como ana

Eso es lo que un robot logra si adivina la contraseña. Vamos a cerrarlo.

Llaves ed25519 con frase de paso

En tu laptop, crea una llave ed25519 (es el tipo recomendado: corta, rápida y segura) con una frase de paso. La frase protege la llave si alguien copia tu carpeta ~/.ssh; con ssh-agent la escribes una vez por sesión, como se explica en la guía básica.

ssh-keygen -t ed25519 -C "ana@laptop"
salida · tu laptopCreated directory '/home/ana/.ssh'.
Generating public/private ed25519 key pair.
Your identification has been saved in /home/ana/.ssh/id_ed25519
Your public key has been saved in /home/ana/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s ana@laptop
The key's randomart image is:
+--[ED25519 256]--+
|o .. .           |
|.o.   o          |
|oo . . .         |
|.o. = ..         |
|o oo.*.oS        |
|o..*O++ +        |
|oo.****o         |
|o. +*B..         |
|  .E*+o.         |
+----[SHA256]-----+

Copia la parte pública al servidor. ssh-copy-id te pide la contraseña una última vez y añade la llave a ~/.ssh/authorized_keys con los permisos correctos:

ssh-copy-id ana@servidor
salida · tu laptop/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/ana/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys

Number of key(s) added: 1

Now try logging into the machine, with: "ssh -o 'StrictHostKeyChecking=accept-new' 'ana@servidor'"
and check to make sure that only the key(s) you wanted were added.

Comprueba que entras con la llave antes de tocar nada más. Con -v se ve el método:

ssh -v ana@servidor true 2>&1 | grep -E "kex: algorithm|Server host key|Authenticated to"
salida · tu laptopdebug1: kex: algorithm: mlkem768x25519-sha256
debug1: Server host key: ssh-ed25519 SHA256:gdHgtvWqP56n1S9yxr0w4gYvFO256Be+XWe7uXgihe4
Authenticated to servidor ([172.31.7.20]:22) using "publickey".

La última línea es la que buscas: using "publickey". La de kex: algorithm la explicamos más abajo, en el intercambio poscuántico.

Dónde va la configuración: gana el primero

No edites /etc/ssh/sshd_config directamente: las actualizaciones del paquete pueden querer reemplazarlo. Debian, Ubuntu y Fedora incluyen al principio de ese archivo todo lo que haya en /etc/ssh/sshd_config.d/*.conf, así que lo limpio es poner tus cambios en un archivo propio ahí.

grep -n "^Include" /etc/ssh/sshd_config; ls /etc/ssh/sshd_config.d/
salida · el servidor12:Include /etc/ssh/sshd_config.d/*.conf
64:KbdInteractiveAuthentication no
87:UsePAM yes
92:X11Forwarding yes
96:PrintMotd no
114:AcceptEnv LANG LC_* COLORTERM NO_COLOR
117:Subsystem	sftp	/usr/lib/openssh/sftp-server

Y aquí está la trampa que más gente pisa: en sshd, el primer valor que aparece es el que gana, no el último. Los archivos de sshd_config.d se leen en orden alfabético. Muchas imágenes de nube traen un 50-cloud-init.conf con PasswordAuthentication yes. Lo reprodujimos y pusimos nuestra configuración en 99-endurecer.conf:

$ ls /etc/ssh/sshd_config.d/
50-cloud-init.conf
99-endurecer.conf
$ sudo sshd -T | grep -E "^(passwordauthentication|permitrootlogin|allowusers|maxauthtries) "
salida · el servidor50-cloud-init.conf
99-endurecer.conf
maxauthtries 3
permitrootlogin no
passwordauthentication yes
allowusers ana

Todo se aplicó… menos lo más importante: passwordauthentication yes. El 50 se leyó antes que el 99 y ganó. Con el mismo contenido renombrado a 10-endurecer.conf:

sudo mv /etc/ssh/sshd_config.d/99-endurecer.conf /etc/ssh/sshd_config.d/10-endurecer.conf
sudo sshd -T | grep -E "^(passwordauthentication|permitrootlogin|allowusers|maxauthtries|logingracetime|x11forwarding) "
salida · el servidor10-endurecer.conf
50-cloud-init.conf
logingracetime 30
maxauthtries 3
permitrootlogin no
passwordauthentication no
x11forwarding no
allowusers ana

Ahora sí. Por eso conviene un número bajo, y por eso siempre hay que confirmar con sshd -T en lugar de confiar en lo que crees haber escrito.

La configuración, línea por línea

Este es el archivo /etc/ssh/sshd_config.d/10-endurecer.conf con el que empezamos (al final de la guía está la versión completa):

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers ana
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
  • PasswordAuthentication no: se acabaron las contraseñas. Es el cambio que más ataques corta.
  • KbdInteractiveAuthentication no: el otro camino por el que se pueden pedir contraseñas (a través de PAM). Si lo dejas en yes, quitar PasswordAuthentication no basta. Debian ya lo trae en no; ponerlo explícito no cuesta nada.
  • PermitRootLogin no: nadie entra directo como root. Entras con tu usuario y usas sudo, y así el registro dice quién hizo qué.
  • AllowUsers ana: solo esos usuarios pueden entrar, aunque otra cuenta tenga una llave válida. Si son muchos, usa AllowGroups sshusers y mete a cada quien en ese grupo con sudo usermod -aG sshusers nombre.
  • MaxAuthTries 3: intentos de autenticación por conexión (cada llave que ofrece tu cliente cuenta como uno; más abajo verás el error que provoca).
  • LoginGraceTime 30: segundos para autenticarse antes de que sshd corte. El valor de fábrica, 120, deja conexiones a medias ocupando lugar.
  • X11Forwarding no: nadie necesita ventanas gráficas por SSH en un servidor.

Después de recargar (lo vemos en el siguiente apartado), el atacante prueba otra vez con la contraseña correcta de ana, y luego como root:

sshpass -p "la-contraseña-de-ana" ssh -o PubkeyAuthentication=no ana@servidor true; echo "código: $?"
salida · el atacanteana@servidor: Permission denied (publickey).
código: 255
sshpass -p "la-contraseña-de-root" ssh -o PubkeyAuthentication=no root@servidor true; echo "código: $?"
salida · el atacanteroot@servidor: Permission denied (publickey).
código: 255

Permission denied (publickey): el servidor ya ni siquiera ofrece la contraseña como opción, solo publickey. Y luis, que sí tiene una llave válida en su authorized_keys pero no está en AllowUsers, tampoco pasa:

ssh -i ~/.ssh/luis luis@servidor true; echo "código: $?"
salida · tu laptopluis@servidor: Permission denied (publickey).
código: 255

Del lado del cliente, los tres rechazos se ven iguales. La diferencia está en el registro del servidor, que verás en Leer los intentos de entrada.

Comprobar y recargar sin cortar

Antes de recargar, sshd -t revisa la sintaxis. Le metimos dos errores al archivo:

sudo sshd -t; echo "código de salida: $?"
salida · el servidor/etc/ssh/sshd_config.d/10-endurecer.conf line 3: unsupported option "nop".
código de salida: 255
sudo sshd -t; echo "código de salida: $?"
salida · el servidor/etc/ssh/sshd_config.d/10-endurecer.conf: line 5: Bad configuration option: MaxAuthTrys
/etc/ssh/sshd_config.d/10-endurecer.conf: terminating, 1 bad configuration options
código de salida: 255

Un valor que no existe (nop) y una opción mal escrita (MaxAuthTrys), cada uno con su archivo y su línea. Corregido, sin errores, recargamos:

sudo sshd -t && echo "sin errores" && sudo systemctl reload ssh && systemctl is-active ssh
salida · el servidorsin errores
active

Recargar no corta las sesiones abiertas. Teníamos una sesión de respaldo abierta desde antes de tocar nada, y siguió viva:

ssh ana@servidor "echo la sesión de respaldo sigue viva"   # en la terminal que dejaste abierta
salida · tu laptopla sesión de respaldo sigue viva

La unidad de systemd de Debian, además, vuelve a ejecutar sshd -t antes de recargar o arrancar: si la configuración está rota, se niega en lugar de dejarte sin servicio.

systemctl cat ssh.service | grep -E "^Exec"
salida · el servidorExecStartPre=/usr/sbin/sshd -t
ExecStart=/usr/sbin/sshd -D $SSHD_OPTS
ExecReload=/usr/sbin/sshd -t
ExecReload=/bin/kill -HUP $MAINPID

En Ubuntu el servicio se llama igual, ssh; en Fedora, Rocky y Alma es sshd: sudo systemctl reload sshd. Si quieres saber más de las unidades de systemd, está la guía de systemd y cron.

Cambiar sin quedarte fuera 1. Abre una sesión de respaldo y no la cierres hasta el final 2. Edita sshd_config.d/10-…conf un archivo propio, con número bajo 3. sudo sshd -t revisa la sintaxis: si falla, no sigas 4. sudo sshd -T -C user=tú… muestra lo que de verdad aplicará 5. sudo systemctl reload ssh las sesiones abiertas no se cortan 6. Prueba en una terminal nueva ¿entras? Ya puedes cerrar el respaldo

El orden que evita el susto. Esquema: decodigo.com.

Si te quedas fuera

sshd -t revisa la sintaxis, no tus intenciones. Escribimos AllowUsers anna en lugar de ana, un error de dedo que cualquiera comete:

sudo sed -i "s/^AllowUsers ana/AllowUsers anna/" /etc/ssh/sshd_config.d/10-endurecer.conf
sudo sshd -t && echo "sshd -t: sin errores" && sudo systemctl reload ssh && echo recargado
salida · el servidorsshd -t: sin errores
recargado

Sin errores, recargado… y una conexión nueva:

ssh ana@servidor true; echo "código: $?"
salida · tu laptopana@servidor: Permission denied (publickey).
código: 255

Fuera. Si no hubiera una sesión abierta, tocaría entrar por la consola web del proveedor (casi todos los VPS la tienen) o restaurar una instantánea. Con la sesión de respaldo, el arreglo es inmediato:

sudo sed -i "s/^AllowUsers anna/AllowUsers ana/" /etc/ssh/sshd_config.d/10-endurecer.conf
sudo sshd -t && sudo systemctl reload ssh && echo "arreglado desde la sesión de respaldo"   # en la sesión de respaldo
salida · tu laptoparreglado desde la sesión de respaldo
ssh ana@servidor "echo de vuelta: $(whoami)"
salida · tu laptopde vuelta: ana
Antes de recargar, pregúntale a sshd qué aplicará a tu usuario. sudo sshd -T -C user=ana,host=laptop,addr=203.0.113.5 | grep allowusers habría mostrado allowusers anna, y con los bloques Match (más abajo) es la única forma de saber qué ve cada usuario. Y la sesión de respaldo no se cierra hasta que una terminal nueva entre bien.

Leer los intentos de entrada

En Debian 13 ya no existe /var/log/auth.log: todo va al journal de systemd.

ls /var/log/auth.log; journalctl --version | head -1
salida · el servidorls: cannot access '/var/log/auth.log': No such file or directory
systemd 257 (257.13-1~deb13u1)

En Ubuntu y en Debian con rsyslog instalado sigue existiendo /var/log/auth.log; en Fedora, Rocky y Alma es /var/log/secure; en Alpine, /var/log/messages. journalctl funciona en cualquier sistema con systemd. Los rechazos de antes, en el servidor:

sudo journalctl -u ssh --since today --no-pager | grep -E "not allowed|Failed|Accepted|Connection closed|Invalid"
salida · el servidorNov 11 01:07:19 servidor sshd-session[151]: Connection closed by authenticating user ana 172.31.7.10 port 34622 [preauth]
Nov 11 01:07:20 servidor sshd-session[153]: Accepted password for ana from 172.31.7.10 port 34624 ssh2
Nov 11 01:07:28 servidor sshd-session[167]: Accepted publickey for ana from 172.31.7.10 port 41556 ssh2: ED25519 SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s
Nov 11 01:07:28 servidor sshd-session[177]: Accepted publickey for ana from 172.31.7.10 port 41568 ssh2: ED25519 SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s
Nov 11 01:07:34 servidor sshd-session[186]: Accepted password for ana from 172.31.7.66 port 39782 ssh2
Nov 11 01:07:43 servidor sshd-session[216]: Accepted publickey for ana from 172.31.7.10 port 33190 ssh2: ED25519 SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s
Nov 11 01:08:12 servidor sshd-session[578]: Accepted publickey for ana from 172.31.7.10 port 34446 ssh2: ED25519 SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s
Nov 11 01:08:13 servidor sshd-session[588]: Connection closed by authenticating user ana 172.31.7.66 port 41576 [preauth]
Nov 11 01:08:13 servidor sshd-session[590]: User root from 172.31.7.66 not allowed because not listed in AllowUsers
Nov 11 01:08:13 servidor sshd-session[590]: Connection closed by invalid user root 172.31.7.66 port 41584 [preauth]
Nov 11 01:08:20 servidor sshd-session[601]: User luis from 172.31.7.10 not allowed because not listed in AllowUsers
Nov 11 01:08:20 servidor sshd-session[601]: Connection closed by invalid user luis 172.31.7.10 port 34448 [preauth]

Cómo leerlo, de arriba abajo:

  • Accepted publickey for ana: una entrada buena, con la huella de la llave. Puedes compararla con ssh-keygen -lf ~/.ssh/id_ed25519.pub en tu laptop para saber qué llave se usó.
  • Accepted password for ana from 172.31.7.66: el atacante entrando con contraseña antes de endurecer. Esta es la línea que hay que buscar si sospechas algo: una contraseña aceptada desde una IP que no conoces. Fue justo lo que recomendaron revisar tras el fallo *sshole.
  • User root from … not allowed because not listed in AllowUsers: con AllowUsers, root se rechaza ahí, antes de llegar a PermitRootLogin.
  • Connection closed by authenticating user ana … [preauth]: alguien se conectó como ana y se fue sin autenticarse: un intento fallido con un usuario que sí existe.

Ahora el atacante prueba la lista típica de usuarios:

for u in admin test oracle ubuntu ana root; do sshpass -p 123456 ssh -o PubkeyAuthentication=no $u@servidor true 2>&1 | head -1; done
salida · el atacanteadmin@servidor: Permission denied (publickey).
test@servidor: Permission denied (publickey).
oracle@servidor: Permission denied (publickey).
ubuntu@servidor: Permission denied (publickey).
ana@servidor: Permission denied (publickey).
root@servidor: Permission denied (publickey).
sudo journalctl -u ssh --since "-1min" --no-pager -o short | grep -v Accepted
salida · el servidorNov 11 01:08:13 servidor sshd-session[590]: Connection closed by invalid user root 172.31.7.66 port 41584 [preauth]
Nov 11 01:08:20 servidor sshd-session[601]: User luis from 172.31.7.10 not allowed because not listed in AllowUsers
Nov 11 01:08:20 servidor sshd-session[601]: Connection closed by invalid user luis 172.31.7.10 port 34448 [preauth]
Nov 11 01:08:27 servidor sshd-session[620]: Invalid user admin from 172.31.7.66 port 48452
Nov 11 01:08:27 servidor sshd-session[620]: Connection closed by invalid user admin 172.31.7.66 port 48452 [preauth]
Nov 11 01:08:27 servidor sshd-session[622]: Invalid user test from 172.31.7.66 port 48462
Nov 11 01:08:27 servidor sshd-session[622]: Connection closed by invalid user test 172.31.7.66 port 48462 [preauth]
Nov 11 01:08:27 servidor sshd-session[624]: Invalid user oracle from 172.31.7.66 port 48472
Nov 11 01:08:27 servidor sshd-session[624]: Connection closed by invalid user oracle 172.31.7.66 port 48472 [preauth]
Nov 11 01:08:28 servidor sshd-session[626]: Invalid user ubuntu from 172.31.7.66 port 48488
Nov 11 01:08:28 servidor sshd-session[626]: Connection closed by invalid user ubuntu 172.31.7.66 port 48488 [preauth]
Nov 11 01:08:28 servidor sshd-session[628]: Connection closed by authenticating user ana 172.31.7.66 port 48500 [preauth]
Nov 11 01:08:28 servidor sshd-session[630]: User root from 172.31.7.66 not allowed because not listed in AllowUsers
Nov 11 01:08:28 servidor sshd-session[630]: Connection closed by invalid user root 172.31.7.66 port 48506 [preauth]

Invalid user significa que la cuenta no existe en el servidor. Para un resumen, cuenta por IP y por usuario:

sudo journalctl -u ssh --since today --no-pager | grep -E "Invalid user|not allowed" | grep -oE "from [0-9.]+" | sort | uniq -c | sort -rn
salida · el servidor      6 from 172.31.7.66
      1 from 172.31.7.10
sudo journalctl -u ssh --since today --no-pager | grep -oE "(Invalid user|User) [^ ]+ from" | awk "{print \$(NF-1)}" | sort | uniq -c | sort -rn
salida · el servidor      2 root
      1 ubuntu
      1 test
      1 oracle
      1 luis
      1 admin
sudo journalctl -u ssh --since today --no-pager | grep Accepted | awk "{print \$7, \$9, \$11}" | sort | uniq -c
salida · el servidor      1 password ana 172.31.7.10
      1 password ana 172.31.7.66
      4 publickey ana 172.31.7.10

La última es la que conviene revisar cada tanto: quién entró, con qué método y desde dónde. Si aparece password después de haberlas quitado, o una IP que no reconoces, investiga. Para una revisión más completa del servidor, la guía Cómo saber si atacaron tu WordPress recorre los demás registros.

fail2ban: bloquear a quien insiste

Sin contraseñas, los robots ya no pueden entrar, pero siguen llenando el registro. fail2ban lee ese registro y, cuando una IP falla varias veces, la bloquea en el firewall por un tiempo. En Debian se instala con sudo apt install fail2ban y ya viene con la cárcel (jail) de SSH encendida y leyendo el journal. Nuestros ajustes van en /etc/fail2ban/jail.local, que no se toca al actualizar:

[DEFAULT]
# tus propias direcciones: nunca se bloquean
ignoreip = 127.0.0.1/8 ::1 172.31.7.10

[sshd]
enabled  = true
mode     = aggressive
maxretry = 3
findtime = 10m
bantime  = 1h
bantime.increment = true
  • ignoreip: tus propias IP fijas. Si no las pones, un día de dedos torpes te bloqueas tú.
  • mode = aggressive: además de las contraseñas fallidas, cuenta los rechazos por llave y los usuarios no permitidos, que es lo que verás en un servidor sin contraseñas.
  • maxretry, findtime, bantime: 3 fallos en 10 minutos, una hora de bloqueo; con bantime.increment, cada reincidencia dura más.
sudo fail2ban-client reload && sudo fail2ban-client get sshd maxretry && sudo fail2ban-client get sshd bantime && sudo fail2ban-client get sshd ignoreip
salida · el servidorOK
3
3600
These IP addresses/networks are ignored:
|- 127.0.0.0/8
|- ::1
`- 172.31.7.10

El atacante insiste, con dos segundos entre intento e intento:

for i in 1 2 3 4 5; do printf "intento $i: "; sshpass -p 123456 ssh -o PubkeyAuthentication=no admin@servidor true 2>&1 | head -1; sleep 2; done
salida · el atacanteintento 1: admin@servidor: Permission denied (publickey).
intento 2: ssh: connect to host servidor port 22: Connection refused
intento 3: ssh: connect to host servidor port 22: Connection refused
intento 4: ssh: connect to host servidor port 22: Connection refused
intento 5: ssh: connect to host servidor port 22: Connection refused

Desde el segundo intento ya no hay SSH: Connection refused. ¿Por qué tan pronto, si pusimos 3? El registro de fail2ban lo explica:

sudo grep -E "Found|Ban" /var/log/fail2ban.log | tail -4
salida · el servidor2026-11-11 01:09:09,610 fail2ban.filter         [921]: INFO    [sshd] Found 172.31.7.66 - 2026-11-11 01:08:54
2026-11-11 01:09:15,017 fail2ban.filter         [921]: INFO    [sshd] Found 172.31.7.66 - 2026-11-11 01:09:14
2026-11-11 01:09:15,018 fail2ban.filter         [921]: INFO    [sshd] Found 172.31.7.66 - 2026-11-11 01:09:14
2026-11-11 01:09:15,608 fail2ban.actions        [921]: NOTICE  [sshd] Ban 172.31.7.66

Ya tenía anotado un fallo de 20 segundos antes, y en modo aggressive una sola conexión rechazada contó dos veces (el «usuario no válido» y el cierre de la conexión, a la misma hora exacta). En la práctica, maxretry = 3 se alcanza con dos conexiones. Tenlo en cuenta al elegir el número, sobre todo si tú mismo sueles equivocarte de usuario. El estado y la regla que creó en nftables:

sudo fail2ban-client status sshd
salida · el servidorStatus for the jail: sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	3
|  `- Journal matches:	_SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	172.31.7.66
sudo nft list table inet f2b-table
salida · el servidortable inet f2b-table {
	set addr-set-sshd {
		type ipv4_addr
		elements = { 172.31.7.66 }
	}

	chain f2b-chain {
		type filter hook input priority filter - 1; policy accept;
		tcp dport 22 ip saddr @addr-set-sshd reject with icmp port-unreachable
	}
}

Mientras tanto, la laptop de ana, que está en ignoreip, sigue entrando:

ssh ana@servidor "echo sigo entrando: $(hostname)"
salida · tu laptopsigo entrando: servidor

Para quitar un bloqueo a mano (por ejemplo, el de un compañero que se equivocó):

sudo fail2ban-client set sshd unbanip 172.31.7.66
salida · el servidor1

Límites en el firewall: nftables y ufw

fail2ban reacciona a lo que ya pasó. Un límite en el firewall frena antes: no deja abrir más de cierto número de conexiones nuevas por minuto desde una misma IP, sin importar qué haga cada una. Con nftables, el firewall de los Linux actuales, en un archivo propio (/etc/ssh-limite.nft en el laboratorio):

table inet ssh_limite {
    set exceso {
        type ipv4_addr
        flags dynamic, timeout
        timeout 2m
    }
    chain entrada {
        type filter hook input priority filter; policy accept;
        ip saddr 172.31.7.10 tcp dport 22 accept comment "mi IP fija: nunca se limita"
        tcp dport 22 ct state new add @exceso { ip saddr limit rate over 3/minute burst 3 packets } counter drop
    }
}

Cada IP que pasa de 3 conexiones nuevas por minuto entra al conjunto exceso y sus paquetes se descartan; la IP de tu laptop va primero y nunca se limita. Se revisa con -c y se carga:

sudo nft -c -f /etc/ssh-limite.nft && echo "sintaxis correcta" && sudo nft -f /etc/ssh-limite.nft && echo cargada
salida · el servidorsintaxis correcta
cargada
for i in $(seq 1 6); do r=$(timeout 3 nc -w2 servidor 22 </dev/null | head -c 20); echo "conexión $i: ${{r:-sin respuesta (descartada)}}"; done
salida · el atacanteconexión 1: SSH-2.0-OpenSSH_10.0
conexión 2: SSH-2.0-OpenSSH_10.0
conexión 3: SSH-2.0-OpenSSH_10.0
conexión 4: sin respuesta (descartada)
conexión 5: sin respuesta (descartada)
conexión 6: sin respuesta (descartada)
for i in $(seq 1 6); do r=$(timeout 3 nc -w2 servidor 22 </dev/null | head -c 20); echo "conexión $i: ${{r:-sin respuesta}}"; done
salida · tu laptopconexión 1: SSH-2.0-OpenSSH_10.0
conexión 2: SSH-2.0-OpenSSH_10.0
conexión 3: SSH-2.0-OpenSSH_10.0
conexión 4: SSH-2.0-OpenSSH_10.0
conexión 5: SSH-2.0-OpenSSH_10.0
conexión 6: SSH-2.0-OpenSSH_10.0

Para que sobreviva a un reinicio, añade su contenido a /etc/nftables.conf (o un include a tu archivo) y activa sudo systemctl enable nftables. Si tu servidor usa ufw, como muchos Ubuntu, hay un atajo hecho para esto: limit bloquea a una IP que abre 6 o más conexiones en 30 segundos.

sudo ufw allow from 172.31.7.10 to any port 22 proto tcp
sudo ufw limit 22/tcp
sudo ufw --force enable
sudo ufw status numbered
salida · el servidorRules updated
Rules updated
Rules updated (v6)
Firewall is active and enabled on system startup
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp                     ALLOW IN    172.31.7.10               
[ 2] 22/tcp                     LIMIT IN    Anywhere                  
[ 3] 22/tcp (v6)                LIMIT IN    Anywhere (v6)             
for i in $(seq 1 8); do r=$(timeout 3 nc -w2 servidor 22 </dev/null | head -c 20); echo "conexión $i: ${{r:-sin respuesta}}"; done
salida · el atacanteconexión 1: SSH-2.0-OpenSSH_10.0
conexión 2: SSH-2.0-OpenSSH_10.0
conexión 3: SSH-2.0-OpenSSH_10.0
conexión 4: SSH-2.0-OpenSSH_10.0
conexión 5: SSH-2.0-OpenSSH_10.0
conexión 6: sin respuesta
conexión 7: sin respuesta
conexión 8: sin respuesta
Antes de encender ufw en un servidor remoto, permite SSH. ufw enable bloquea todo lo entrante que no esté permitido; si lo enciendes sin una regla para el 22, la conexión con la que lo encendiste es la última. En la prueba, la regla de nuestra IP va primero, así que a nosotros no nos aplica el límite. Si tu proveedor tiene un firewall en su panel (los grupos de seguridad de AWS, por ejemplo), lo mejor es abrir el 22 solo a tus IP desde ahí.

Cambiar el puerto: lo que sí y lo que no

Mover SSH al puerto 2222 (o cualquier otro) reduce mucho el ruido en el registro, porque la mayoría de los robots solo prueban el 22. Pero no es seguridad: quien te busca a ti lo encuentra en segundos. Lo movimos y escaneamos desde el atacante los primeros 5,000 puertos:

echo "Port 2222" | sudo tee /etc/ssh/sshd_config.d/20-puerto.conf
sudo sshd -t && sudo systemctl reload ssh && sudo ss -ltnp | grep sshd
salida · el servidorLISTEN 0      128          0.0.0.0:2222       0.0.0.0:*    users:(("sshd",pid=83,fd=6))
LISTEN 0      128             [::]:2222          [::]:*    users:(("sshd",pid=83,fd=7))
time (nc -zv -w1 servidor 1-5000 2>&1 | grep -i succeeded)
salida · el atacanteConnection to servidor (172.31.7.20) 2222 port [tcp/*] succeeded!

real	0m0.362s
user	0m0.072s
sys	0m0.317s
nc -w2 servidor 2222 </dev/null | head -1
salida · el atacanteSSH-2.0-OpenSSH_10.0p2 Debian-7+deb13u4

Menos de medio segundo para encontrarlo, y el saludo del servidor dice qué programa y qué versión es. En Debian y Ubuntu, DebianBanner no quita al menos la parte de la distribución y el número de parche:

nc -w2 servidor 22 </dev/null | head -1   # con DebianBanner no
salida · el atacanteSSH-2.0-OpenSSH_10.0p2

Si de todos modos cambias el puerto, tres cosas que se olvidan:

  • fail2ban protege el puerto que le digas. La regla que vimos decía tcp dport 22; con otro puerto, añade port = 2222 en la sección [sshd] de jail.local. Lo mismo para la regla de nftables o ufw.
  • En Ubuntu 22.10 y posteriores, SSH arranca por ssh.socket. Tras cambiar el puerto hay que ejecutar sudo systemctl daemon-reload y sudo systemctl restart ssh.socket.
  • En Fedora, Rocky y Alma, SELinux solo deja a sshd escuchar en puertos marcados: sudo semanage port -a -t ssh_port_t -p tcp 2222.

En el laboratorio volvimos al 22.

Llaves físicas FIDO2

Una llave física (YubiKey, Nitrokey, SoloKey, la de Google) guarda la parte secreta dentro del dispositivo: no se puede copiar, y cada entrada exige tocarla. OpenSSH las maneja desde la versión 8.2 con los tipos -sk (security key):

ssh -Q key | grep sk-
salida · tu laptopsk-ssh-ed25519@openssh.com
sk-ssh-ed25519-cert-v01@openssh.com
sk-ecdsa-sha2-nistp256@openssh.com
sk-ecdsa-sha2-nistp256-cert-v01@openssh.com
ssh-keygen -t ed25519-sk -O resident -O verify-required -O application=ssh:servidor -C "ana yubikey"
  • -O resident: la llave se guarda dentro del dispositivo. En otra computadora la recuperas con ssh-keygen -K (conectando la llave física), sin copiar archivos.
  • -O verify-required: además de tocarla, pide el PIN del dispositivo. Si alguien te la roba, no le sirve sola.
  • -O application=ssh:servidor: un nombre para distinguirla si guardas varias en el mismo dispositivo.

En el servidor no hay nada especial que hacer: la parte pública (sk-ssh-ed25519@openssh.com …) va en authorized_keys como cualquier otra. Si además quieres obligar a que cada entrada lleve PIN, en el servidor existe PubkeyAuthOptions verify-required. Ten siempre dos llaves físicas registradas, o una llave normal guardada fuera de línea, por si pierdes la primera.

Lo que no pudimos probar. El laboratorio no tiene una llave física conectada, así que la creación falla en el último paso. Es útil ver el error, porque es el mismo que obtendrás si el dispositivo no está conectado o tu sistema no lo ve (en Linux suele faltar el paquete libfido2 o una regla de udev):
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "ana yubikey"
salida · tu laptopGenerating public/private ed25519-sk key pair.
You may need to touch your authenticator to authorize key generation.
Key enrollment failed: device not found
código de salida: 255

Entrar por un bastión con ProxyJump

Un bastión es el único servidor con SSH abierto a internet; las demás máquinas (la base de datos, los servidores de la app) solo aceptan conexiones desde la red privada. En el laboratorio, la máquina «interno» no se ve desde la laptop:

ssh ana@172.31.8.40 true; echo "código: $?"
salida · tu laptopssh: connect to host 172.31.8.40 port 22: Connection timed out
código: 255

Con -J (ProxyJump) se entra al interno pasando por el bastión, en una sola orden:

ssh -J ana@bastion ana@interno "hostname; ip -4 -o addr show eth0 | awk '{{print $4}}'"
salida · tu laptopinterno
172.31.8.40/24

Para no escribirlo cada vez, en ~/.ssh/config de tu laptop:

cat ~/.ssh/config; echo ---; ssh interno hostname
salida · tu laptopHost bastion
    HostName bastion
    User ana

Host interno
    HostName interno
    User ana
    ProxyJump bastion
---
interno
ssh -v interno true 2>&1 | grep -E "Setting implicit ProxyCommand|Authenticated to"
salida · tu laptopdebug1: Setting implicit ProxyCommand from ProxyJump: ssh -v -W '[%h]:%p' bastion
Authenticated to bastion ([172.31.7.30]:22) using "publickey".
Authenticated to interno (via proxy) using "publickey".

La conexión al interno va cifrada de punta a punta dentro del túnel por el bastión, y tu llave nunca sale de tu laptop: el bastión solo pasa bytes. Es mucho mejor que la costumbre vieja de entrar al bastión con ssh -A (reenviar el agente) y de ahí saltar. Mira lo que ve el bastión en cada caso:

echo "sin -A:"; ssh bastion "ssh-add -l"; echo "con -A:"; ssh -A bastion "echo $SSH_AUTH_SOCK; ssh-add -l"
salida · tu laptopsin -A:
Could not open a connection to your authentication agent.
con -A:
/tmp/ssh-OtHgmLjzTY/agent.72
256 SHA256:6MYj5FuIz5sWRjvmgc8/DqwgbHADwaW4dBfvm6HEw6s ana@laptop (ED25519)

Con -A, mientras estés conectado, cualquiera con permisos de root en el bastión puede usar ese agent.72 para entrar a donde entre tu llave. No sacan la llave, pero la usan. Por eso en el servidor pondremos AllowAgentForwarding no y en tu laptop no actives ForwardAgent salvo que de verdad lo necesites.

Bastión y túneles Tu laptop internet Bastión el borde Interno red privada solo el bastión cruza ProxyJump: entrar al interno ssh -J bastion interno Una sola orden, cifrado de punta a punta. Tu llave nunca sale de tu laptop. Túnel dinámico: un proxy SOCKS ssh -D 1080 bastion curl o el navegador llegan a los servicios de la red privada a través del bastión. Túnel remoto: al revés ssh -R 8080:127.0.0.1:3000 servidor El servidor abre su puerto 8080 y lo que llega ahí sale por el 3000 de tu laptop.

ProxyJump y los dos túneles que no trae la guía básica. Esquema: decodigo.com.

Túneles -D y -R, y cómo limitarlos

La guía básica explica el túnel local, -L. Hay otros dos.

-D: un proxy SOCKS para llegar a toda una red

En el interno hay un panel web en el puerto 8080. Desde la laptop no se alcanza:

curl -s -m 4 http://172.31.8.40:8080/ ; echo "código de curl: $?"
salida · tu laptopcódigo de curl: 28

ssh -D 1080 abre en tu laptop un proxy SOCKS: lo que mandes por él sale desde el bastión. -f manda SSH al fondo y -N le dice que no ejecute nada, solo el túnel:

ssh -D 1080 -fN bastion && curl -s --socks5-hostname 127.0.0.1:1080 http://interno:8080/
salida · tu laptoppanel interno: solo en la red privada
ss -ltn | grep 1080
salida · tu laptopLISTEN 0      128        127.0.0.1:1080       0.0.0.0:*   
LISTEN 0      128            [::1]:1080          [::]:*   

Sirve para revisar paneles internos desde el navegador (configurando el proxy SOCKS en 127.0.0.1:1080) sin abrir nada a internet. Con --socks5-hostname, hasta el nombre interno se resuelve del otro lado.

-R: un túnel al revés

-R abre un puerto en el servidor que lleva a algo de tu laptop. Sirve para enseñar algo que corre en tu máquina, o para que una máquina detrás de un NAT se deje alcanzar. En la laptop corre un servidor web en el 3000:

ssh -R 8080:127.0.0.1:3000 -fN ana@servidor
ssh ana@servidor "ss -ltn | grep 8080; curl -s http://127.0.0.1:8080/"
salida · tu laptopLISTEN 0      128        127.0.0.1:8080       0.0.0.0:*   
LISTEN 0      128            [::1]:8080          [::]:*   
hola desde la laptop de ana
curl -s -m 3 http://servidor:8080/; echo "código de curl: $?"   # desde otra máquina
salida · el atacantecódigo de curl: 7

El puerto quedó en 127.0.0.1 del servidor: solo se alcanza desde el propio servidor. Para abrirlo a todos haría falta GatewayPorts yes, y casi nunca es buena idea.

Limitar los túneles en el servidor

Los túneles son útiles, pero un usuario con SSH puede usarlos para saltar a otras máquinas de tu red. Lo prudente es apagarlos para todos y encender solo lo necesario, por usuario, con un bloque Match al final del archivo (todo lo que va debajo de un Match solo aplica a quien coincide):

AllowTcpForwarding no
AllowAgentForwarding no

Match User ana
    AllowTcpForwarding remote

remote permite -R pero no -L ni -D. Probado contra el servidor:

ssh -D 1081 -fN ana@servidor; curl -s -m 4 --socks5-hostname 127.0.0.1:1081 http://servidor:22/ ; echo "con -D, código de curl: $?"
salida · tu laptopcon -D, código de curl: 97
ssh -R 8081:127.0.0.1:3000 -fN ana@servidor && ssh ana@servidor "curl -s http://127.0.0.1:8081/"
salida · tu laptophola desde la laptop de ana
sudo journalctl -u ssh --since "-2min" --no-pager -o cat | grep refused
salida · el servidorrefused local port forward: originator 127.0.0.1 port 44396, target servidor port 22
refused local port forward: originator 127.0.0.1 port 44408, target servidor port 22

El código 97 de curl es «el proxy SOCKS no respondió bien»: sshd rechazó el túnel y lo dejó anotado. Para revisar qué aplica a cada usuario, sshd -T -C:

for u in ana luis; do echo "$u: $(sudo sshd -T -C user=$u,host=laptop,addr=172.31.7.10 | grep -E "^(printmotd|allowtcpforwarding|x11forwarding) " | tr "\n" " ")"; done
salida · el servidorana: printmotd no x11forwarding no allowtcpforwarding remote 
luis: printmotd no x11forwarding no allowtcpforwarding no 

Intercambio de llaves poscuántico

Al conectar, cliente y servidor acuerdan una llave de sesión con un algoritmo de intercambio de llaves (key exchange, «kex»). El que se usó por años, curve25519, se podría romper con una computadora cuántica lo bastante grande. Nadie tiene una hoy, pero alguien puede guardar tu tráfico ahora y descifrarlo después («store now, decrypt later»). Por eso OpenSSH 10.0 (abril de 2025) pasó a usar por omisión mlkem768x25519-sha256, que combina ML-KEM, el estándar poscuántico del NIST, con curve25519: si una de las dos partes cae, la otra sigue protegiendo.

Ya lo viste en el ssh -v del principio: kex: algorithm: mlkem768x25519-sha256. La lista de lo que soporta tu cliente:

ssh -Q kex
salida · tu laptopdiffie-hellman-group1-sha1
diffie-hellman-group14-sha1
diffie-hellman-group14-sha256
diffie-hellman-group16-sha512
diffie-hellman-group18-sha512
diffie-hellman-group-exchange-sha1
diffie-hellman-group-exchange-sha256
ecdh-sha2-nistp256
ecdh-sha2-nistp384
ecdh-sha2-nistp521
curve25519-sha256
curve25519-sha256@libssh.org
sntrup761x25519-sha512
sntrup761x25519-sha512@openssh.com
mlkem768x25519-sha256

Lo que añaden las versiones siguientes:

  • OpenSSH 10.1 (octubre de 2025): el cliente avisa cuando la conexión no usa un intercambio poscuántico. Se controla con WarnWeakCrypto, encendido por omisión.
  • OpenSSH 10.6 (octubre de 2026): WarnWeakCrypto llega también a sshd_config, encendido por omisión, y el servidor anota en su registro a los clientes que no usan intercambio poscuántico.

Lo probamos con un cliente 10.6p2 contra un servidor que solo ofrece curve25519-sha256:

ssh -p 2200 ana@127.0.0.1 "echo conectado"   # un sshd con KexAlgorithms curve25519-sha256
salida · OpenSSH 10.6** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
conectado

Y al revés: un cliente 10.0 forzado a curve25519 contra un servidor 10.6p2 (en el laboratorio escucha en el puerto 2201 y escribe su registro en un archivo). El cliente no dice nada, pero el servidor lo anota:

ssh -o KexAlgorithms=curve25519-sha256 -p 2201 ana@nuevo "echo conectado al servidor 10.6"
salida · tu laptopconectado al servidor 10.6
grep -i post-quantum /var/log/sshd-nuevo.log   # en el servidor 10.6p2
salida · OpenSSH 10.6WARNING: connection from 172.31.7.10 port 42564 is not using a post-quantum key exchange algorithm: "curve25519-sha256" [preauth]

Un detalle que encontramos: el aviso del cliente sale cuando es el servidor el que no ofrece un intercambio poscuántico. Si eres tú quien fuerza KexAlgorithms=curve25519-sha256 contra un servidor moderno, el cliente 10.6p2 no avisó:

ssh -o KexAlgorithms=curve25519-sha256 ana@servidor "echo conectado"
salida · OpenSSH 10.6conectado

Qué hacer: no fijes KexAlgorithms en tus archivos salvo que sepas por qué (las listas copiadas de guías viejas suelen dejar fuera los poscuánticos), mantén OpenSSH al día de los dos lados y, si ves ese aviso al conectarte a un servidor tuyo, actualízalo.

Certificados SSH, en breve

Con muchos servidores y muchas personas, repartir llaves en cada authorized_keys se vuelve un desorden: nadie sabe qué llave es de quién ni cuándo quitarla. Los certificados SSH lo resuelven: una autoridad (CA, una llave más) firma las llaves de cada persona con un nombre y una fecha de vencimiento, y los servidores solo confían en la CA. No tienen nada que ver con los certificados de HTTPS (esos se revisan con OpenSSL, como explicamos en OpenSSL en la práctica): son de OpenSSH y no hay que pagar ni pedir nada.

Creamos la CA y firmamos una llave nueva de ana, válida 8 horas (en tu caso, la CA debe vivir en una máquina aparte o fuera de línea, nunca en los servidores):

ssh-keygen -t ed25519 -C "CA de usuarios" -f ~/ca/ca_usuarios
ssh-keygen -t ed25519 -C "ana@laptop-nueva" -f ~/.ssh/id_nueva
ssh-keygen -s ~/ca/ca_usuarios -I "ana-laptop-nueva" -n ana -V +8h ~/.ssh/id_nueva.pub
salida · tu laptopSigned user key /home/ana/.ssh/id_nueva-cert.pub: id "ana-laptop-nueva" serial 0 for ana valid from 2026-11-11T01:11:00 to 2026-11-11T09:12:43
ssh-keygen -L -f ~/.ssh/id_nueva-cert.pub
salida · tu laptop/home/ana/.ssh/id_nueva-cert.pub:
        Type: ssh-ed25519-cert-v01@openssh.com user certificate
        Public key: ED25519-CERT SHA256:Pqnc4RD75pGaNq8gP1bc4lFmuHfljfZ0BZ/8mAFWYZM
        Signing CA: ED25519 SHA256:XWpCEU4Rxsj+yVn2ctASAm8a1DqyGsAgZLm1UZFUxoY (using ssh-ed25519)
        Key ID: "ana-laptop-nueva"
        Serial: 0
        Valid: from 2026-11-11T01:11:00 to 2026-11-11T09:12:43
        Principals: 
                ana
        Critical Options: (none)
        Extensions: 
                permit-X11-forwarding
                permit-agent-forwarding
                permit-port-forwarding
                permit-pty
                permit-user-rc

-I es el nombre que saldrá en el registro, -n los usuarios con los que vale (principals) y -V la vigencia. Esa llave nueva no está en ningún authorized_keys, así que el servidor la rechaza:

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_nueva ana@servidor true; echo "código: $?"
salida · tu laptopana@servidor: Permission denied (publickey).
código: 255

En el servidor se copia la parte pública de la CA y se le dice a sshd que confíe en ella:

echo "TrustedUserCAKeys /etc/ssh/ca_usuarios.pub" | sudo tee /etc/ssh/sshd_config.d/30-ca.conf
sudo sshd -t && sudo systemctl reload ssh && echo recargado
salida · el servidorTrustedUserCAKeys /etc/ssh/ca_usuarios.pub
recargado
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_nueva ana@servidor "echo entré con certificado; grep -c . ~/.ssh/authorized_keys"
salida · tu laptopentré con certificado
2
sudo journalctl -u ssh --since "-1min" --no-pager -o cat | grep "Accepted publickey" | tail -1
salida · el servidorAccepted publickey for ana from 172.31.7.10 port 55932 ssh2: ED25519-CERT SHA256:Pqnc4RD75pGaNq8gP1bc4lFmuHfljfZ0BZ/8mAFWYZM ID ana-laptop-nueva (serial 0) CA ED25519 SHA256:XWpCEU4Rxsj+yVn2ctASAm8a1DqyGsAgZLm1UZFUxoY

El authorized_keys sigue con sus 2 llaves de antes; la nueva entró solo por el certificado, y el registro dice qué certificado fue. A las 8 horas deja de servir sin que nadie haga nada. Para quitar uno antes de tiempo existe RevokedKeys. Herramientas como step-ca, Teleport o Vault automatizan la firma; para un equipo chico, ssh-keygen -s alcanza.

Mantener OpenSSH al día

Nada de lo anterior sirve si el propio OpenSSH tiene un fallo. En noviembre de 2026, *sshole permitió entrar a cuentas que no tienen contraseña en Alpine y otros sistemas con musl, con solo mandar una contraseña de más de 256 bytes; OpenSSH 10.6p2 lo corrigió. Dos detalles de esa historia vienen a cuento aquí: los servidores con PasswordAuthentication no no estaban expuestos, y la forma de saber si alguien entró era buscar Accepted password en el registro, justo lo que vimos arriba.

Revisa qué versión tienes y si hay una más nueva en tu distribución:

apt-cache policy openssh-server | head -4
salida · el servidoropenssh-server:
  Installed: 1:10.0p1-7+deb13u4
  Candidate: 1:10.0p1-7+deb13u4
  Version table:
sudo apt-get -s install --only-upgrade openssh-server | tail -3
salida · el servidorReading state information...
openssh-server is already the newest version (1:10.0p1-7+deb13u4).
0 upgraded, 0 newly installed, 0 to remove and 1 not upgraded.
apk list -I | grep "^openssh-server-[0-9]"; sshd -V   # en Alpine
salida · OpenSSH 10.6openssh-server-10.6_p2-r0 x86_64 {openssh} (SSH-OpenSSH) [installed]
OpenSSH_10.6p2, OpenSSL 3.5.7 9 Jun 2026

Ojo: el número de la distribución no siempre coincide con el de OpenSSH. Debian 13 trae la rama 10.0 y le aplica los parches de seguridad sin cambiarle la versión; lo que cuenta es el sufijo del paquete (+deb13u4) y los avisos de seguridad de tu distribución. Para no depender de acordarte, en Debian y Ubuntu instala unattended-upgrades, que aplica solo las actualizaciones de seguridad cada día; en el laboratorio no venía instalado:

apt-cache policy unattended-upgrades | head -3
salida · el servidorunattended-upgrades:
  Installed: (none)
  Candidate: 2.12

Después de actualizar OpenSSH, reinicia el servicio (sudo systemctl restart ssh): las sesiones abiertas siguen con el programa viejo hasta que se cierran, pero las nuevas ya usan el corregido.

Errores típicos

Too many authentication failures

Con MaxAuthTries 3, si tu agente tiene varias llaves (la de GitHub, la de GitLab, la del trabajo), el cliente las ofrece una por una y el servidor corta antes de llegar a la buena:

ssh-add -l | awk '{{print $3}}'; ssh ana@servidor true; echo "código: $?"
salida · tu laptopgithub
gitlab
trabajo
ana@laptop-nueva
ana@laptop-nueva
Received disconnect from 172.31.7.20 port 22:2: Too many authentication failures
Disconnected from 172.31.7.20 port 22
código: 255

La solución está del lado del cliente: dile qué llave usar para ese servidor, con IdentitiesOnly:

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 ana@servidor "echo entré a la primera"
salida · tu laptopentré a la primera

O, mejor, en ~/.ssh/config: IdentityFile ~/.ssh/id_ed25519 y IdentitiesOnly yes en el bloque de ese servidor.

Permission denied (publickey) con la llave correcta

Casi siempre son permisos: sshd ignora un authorized_keys que otros usuarios pueden modificar. Le dimos permiso de escritura a «otros»:

chmod o+w ~/.ssh/authorized_keys && ls -l ~/.ssh/authorized_keys
salida · el servidor-rw-----w- 1 ana ana 183 Nov 11 01:12 /home/ana/.ssh/authorized_keys
ssh ana@servidor true; echo "código: $?"
salida · tu laptopana@servidor: Permission denied (publickey).
código: 255
sudo journalctl -u ssh --since "-1min" --no-pager -o cat | grep -i "bad ownership"
salida · el servidorAuthentication refused: bad ownership or modes for file /home/ana/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys && ls -l ~/.ssh/authorized_keys
salida · el servidor-rw------- 1 ana ana 183 Nov 11 01:12 /home/ana/.ssh/authorized_keys

Curiosamente, con permiso de escritura solo para el grupo sí nos dejó entrar: en Debian cada usuario tiene su propio grupo, y el OpenSSH de Debian y Ubuntu trae un parche que lo tolera cuando nadie más pertenece a ese grupo. En otras distribuciones no cuentes con eso. La regla segura es la de siempre: chmod 700 ~/.ssh y chmod 600 ~/.ssh/authorized_keys. Si no es eso, revisa que el usuario esté en AllowUsers y busca la línea not allowed because… en el registro.

Connection refused o se queda colgado

Connection refused justo después de varios intentos suele ser fail2ban (o ufw) haciendo su trabajo; revisa sudo fail2ban-client status sshd desde otra IP o desde la consola del proveedor. Si se queda colgado sin respuesta, es un firewall que descarta, como el límite de nftables. Los demás errores de cliente (huella cambiada, permisos de la llave) están en la guía básica.

La configuración completa y la lista de revisión

Así quedó /etc/ssh/sshd_config.d/10-endurecer.conf al final del laboratorio. Cambia ana por tus usuarios (o usa AllowGroups) y quita el bloque Match si nadie necesita túneles:

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers ana
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
DebianBanner no
AllowTcpForwarding no
AllowAgentForwarding no

Match User ana
    AllowTcpForwarding remote

Y la CA, si la usas, en su propio archivo: TrustedUserCAKeys /etc/ssh/ca_usuarios.pub.

Lista de revisión

  1. Entras con llave ed25519 (con frase de paso) o con una llave FIDO2.
  2. Tu archivo en sshd_config.d tiene número bajo y sudo sshd -T confirma passwordauthentication no.
  3. PermitRootLogin no y AllowUsers o AllowGroups.
  4. Cada cambio: sesión de respaldo, sshd -t, sshd -T -C, reload, prueba en terminal nueva.
  5. fail2ban activo con tu IP en ignoreip, y un límite en el firewall.
  6. Túneles y reenvío de agente apagados salvo para quien los necesite.
  7. Las máquinas internas, sin SSH a internet: se entra por un bastión con ProxyJump.
  8. Ningún KexAlgorithms copiado de una guía vieja; OpenSSH al día, con actualizaciones automáticas de seguridad.
  9. Cada tanto: journalctl -u ssh | grep Accepted, para ver quién entró y cómo.

Con el acceso asegurado, lo siguiente suele ser dejar tus programas corriendo como servicio, con systemd y cron, y ponerles delante Nginx con HTTPS. La guía de PostgreSQL usa un túnel SSH para conectarse a la base de datos sin abrirla a internet, y en Redes básicas está qué son los puertos y el NAT que aparecen aquí. Para cuidar los archivos del servidor, copias de seguridad con Bash.

Fuentes: sshd_config(5), ssh_config(5), ssh-keygen(1) y notas de versión de OpenSSH (10.0, 10.1, 10.6 y 10.6p2); OpenSSH y la criptografía poscuántica; documentación de fail2ban; conjuntos dinámicos y límites en nftables; ufw(8). Laboratorio: Debian 13 con OpenSSH 10.0p2 (Debian-7+deb13u4), systemd 257, fail2ban 1.1.0, nftables 1.1.3 y ufw 0.36.2; Alpine edge con OpenSSH 10.6p2; todo en contenedores desechables.