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.
scp, ~/.ssh/config y el túnel -L).- Antes de empezar: qué vas a cambiar
- El laboratorio de esta guía
- Llaves ed25519 con frase de paso
- Dónde va la configuración: gana el primero
- La configuración, línea por línea
- Comprobar y recargar sin cortar
- Si te quedas fuera
- Leer los intentos de entrada
- fail2ban: bloquear a quien insiste
- Límites en el firewall: nftables y ufw
- Cambiar el puerto: lo que sí y lo que no
- Llaves físicas FIDO2
- Entrar por un bastión con ProxyJump
- Túneles -D y -R, y cómo limitarlos
- Intercambio de llaves poscuántico
- Certificados SSH, en breve
- Mantener OpenSSH al día
- Errores típicos
- La configuración completa y la lista de revisión
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
rootsin entrada directa. - Una lista de usuarios permitidos (
AllowUsersoAllowGroups). - 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.
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 enyes, quitarPasswordAuthenticationno basta. Debian ya lo trae enno; ponerlo explícito no cuesta nada.PermitRootLogin no: nadie entra directo como root. Entras con tu usuario y usassudo, 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, usaAllowGroups sshusersy mete a cada quien en ese grupo consudo 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.
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
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 conssh-keygen -lf ~/.ssh/id_ed25519.puben 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: conAllowUsers, root se rechaza ahí, antes de llegar aPermitRootLogin.Connection closed by authenticating user ana … [preauth]: alguien se conectó comoanay 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.10sudo journalctl -u ssh --since today --no-pager | grep -oE "(Invalid user|User) [^ ]+ from" | awk "{print \$(NF-1)}" | sort | uniq -c | sort -rnsalida · el servidor 2 root
1 ubuntu
1 test
1 oracle
1 luis
1 adminsudo journalctl -u ssh --since today --no-pager | grep Accepted | awk "{print \$7, \$9, \$11}" | sort | uniq -csalida · el servidor 1 password ana 172.31.7.10
1 password ana 172.31.7.66
4 publickey ana 172.31.7.10La ú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; conbantime.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)}}"; donesalida · 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}}"; donesalida · 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}}"; donesalida · 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
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ñadeport = 2222en la sección[sshd]dejail.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 ejecutarsudo systemctl daemon-reloadysudo 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 conssh-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.
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
---
internossh -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.
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 remoteremote 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):
WarnWeakCryptollega también asshd_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 2026Ojo: 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 remoteY la CA, si la usas, en su propio archivo: TrustedUserCAKeys /etc/ssh/ca_usuarios.pub.
- Entras con llave ed25519 (con frase de paso) o con una llave FIDO2.
- Tu archivo en
sshd_config.dtiene número bajo ysudo sshd -Tconfirmapasswordauthentication no. PermitRootLogin noyAllowUsersoAllowGroups.- Cada cambio: sesión de respaldo,
sshd -t,sshd -T -C,reload, prueba en terminal nueva. - fail2ban activo con tu IP en
ignoreip, y un límite en el firewall. - Túneles y reenvío de agente apagados salvo para quien los necesite.
- Las máquinas internas, sin SSH a internet: se entra por un bastión con
ProxyJump. - Ningún
KexAlgorithmscopiado de una guía vieja; OpenSSH al día, con actualizaciones automáticas de seguridad. - 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.
