Fase 1.4
Rocky Linux 9 · SSH

SSH Hardening · Sin Password, Solo Keys

La última capa del hardening base. SSH es el único servicio que acepta conexiones entrantes desde fuera de la VM. Tiene que estar blindado. Sin root login, sin autenticación por contraseña, puerto no estándar y fail2ban para fuerza bruta.

¿Por qué tanto rebuild por SSH?

SSH es el único servicio que escucha fuera de la VM. Si alguien quiere entrar, ese es el camino. Y los bots lo saben. Escaneos automáticos al puerto 22 corren 24/7 probando combinaciones de usuario/contraseña. Así que: sin contraseña que adivinar, sin root al que acceder, y los intentos fallidos se bloquean solos.

Antes Root login habilitado
Después Root login deshabilitado
Antes Password auth habilitado
Después Solo SSH key
Antes Puerto 22 (escaneos masivos)
Después Puerto 2222 (fuera del radar automático)
Antes 6 intentos de auth
Después 3 intentos → fail2ban
Antes 120s para autenticar
Después 30s → conexión cerrada
Orden de aplicación: firewalld → SELinux → ssh-hardening. Primero se prepara la red y el kernel, después se cambia el servicio. Si ssh-hardening se ejecutara antes que firewalld, el puerto 2222 no estaría abierto y la conexión SSH se cortaría al reiniciar sshd.

Lo que se cambia en sshd_config

Cinco líneas y se acabó. Cada una se valida con sshd -t antes de tocar el archivo. Si algo queda mal, Ansible revierte al estado anterior.

PermitRootLogin no default: yes

Chau root por SSH. Obliga a entrar como usuario normal y escalar con sudo. Es la que más impacto tiene de las cinco.

Si alguien consigue la password de root, tiene el sistema entero. Con esto, ni siquiera puede intentar.
PasswordAuthentication no default: yes

Solo claves SSH, nada de passwords. No hay contraseña que adivinar: el ataque de fuerza bruta queda descartado de entrada.

Si se activa sin tener la clave SSH configurada en la VM, chau acceso. Hay que planificar antes de ejecutar.
Port 2222 default: 22

No es seguridad por oscuridad, es reducir el ruido. Los bots escanean el 22 las 24 horas. Moverlo al 2222 saca los falsos positivos de los logs y baja el tráfico basura.

Bajo. Solo hay que acordarse de -p 2222. Un escaneo dirigido lo encuentra igual, pero esos no son los que llenan los logs.
MaxAuthTries 3 default: 6

3 intentos y se corta la conexión. Con fail2ban, a los 5 fallos se banea la IP entera por una hora. No da para probar combinaciones.

Ninguno. Si fallás 3 veces, abrís otra conexión y listo.
LoginGraceTime 30 default: 120

30 segundos para autenticarte o se cierra la conexión. Contra conexiones colgadas que consumen recursos y ataques de timing.

Ninguno. Con SSH key la autenticación es instantánea.

El rol en Ansible

Modifica sshd_config, registra el puerto 2222 en SELinux y reinicia el servicio solo si es necesario. Los handlers de Ansible evitan reinicios sin motivo.

Defaults

roles/ssh-hardening/defaults/main.yml
ssh_port: 2222
ssh_password_auth: false
ssh_max_auth_tries: 3
ssh_login_grace_time: "30"

Tasks

roles/ssh-hardening/tasks/main.yml
- name: Instalar el servicio SSH
  ansible.builtin.dnf:
      name: openssh-server
      state: present

- name: Deshabilitar login root
  ansible.builtin.lineinfile:
      path: /etc/ssh/sshd_config
      regexp: "^#?PermitRootLogin"
      line: "PermitRootLogin no"
      validate: "sshd -t -f %s"

- name: Configurar autenticación por password
  ansible.builtin.lineinfile:
      path: /etc/ssh/sshd_config
      regexp: "^#?PasswordAuthentication"
      line: "PasswordAuthentication {{ 'yes' if ssh_password_auth else 'no' }}"
      validate: "sshd -t -f %s"

- name: Configurar MaxAuthTries
  ansible.builtin.lineinfile:
      path: /etc/ssh/sshd_config
      regexp: "^#?MaxAuthTries"
      line: "MaxAuthTries {{ ssh_max_auth_tries }}"
      validate: "sshd -t -f %s"

- name: Configurar LoginGraceTime
  ansible.builtin.lineinfile:
      path: /etc/ssh/sshd_config
      regexp: "^#?LoginGraceTime"
      line: "LoginGraceTime {{ ssh_login_grace_time }}"
      validate: "sshd -t -f %s"

- name: Cambiar puerto SSH (solo si es diferente de 22)
  ansible.builtin.lineinfile:
      path: /etc/ssh/sshd_config
      regexp: "^#?Port "
      line: "Port {{ ssh_port }}"
      validate: "sshd -t -f %s"
  when: ssh_port != 22
  notify: Restart sshd

- name: Configurar SELinux para el nuevo puerto SSH
  ansible.builtin.command:
      cmd: semanage port -a -t ssh_port_t -p tcp {{ ssh_port }}
  when:
      - ssh_port != 22
      - selinux_ssh_port_check.rc != 0

Dos cosas a notar. El validate: "sshd -t -f %s" prueba la sintaxis antes de guardar. Si algo se rompe, Ansible revierte. Y la task de SELinux usa semanage port para que el puerto 2222 quede registrado como ssh_port_t, que es lo que se configuró en el rol anterior.

Handler

roles/ssh-hardening/handlers/main.yml
- name: Restart sshd
  ansible.builtin.systemd:
    name: sshd
    state: restarted

Solo se dispara si el puerto cambió (when: ssh_port != 22). Si ya era 2222 de una ejecución anterior, sshd no se reinicia: y no se cortan sesiones SSH activas.

Conectarse después

Terminado el hardening, solo hay una forma de entrar: clave SSH en el puerto 2222. Ni password, ni root. Punto.

conexión post-hardening
# Ya no funciona: password auth deshabilitado
$ ssh usuario@vm-ip
usuario@vm-ip: Permission denied (publickey).
# Tampoco funciona: root login deshabilitado
$ ssh root@vm-ip -p 2222
root@vm-ip: Permission denied (publickey).
# Única forma correcta: usuario + key + puerto custom
$ ssh -i ~/.ssh/id_ed25519 -p 2222 usuario@vm-ip
Last login: Sat May 31 09:00:00 2026 from 192.168.64.1
[usuario@rocky-aiops ~]$

Para simplificar, se puede agregar al ~/.ssh/config de la máquina local:

~/.ssh/config
Host rocky-lab
    HostName 192.168.64.10
    Port 2222
    User agustinm
    IdentityFile ~/.ssh/id_ed25519

Con esta configuración, conectarse es simplemente ssh rocky-lab.

Precaución con el orden de deploy. Antes de ejecutar este rol via SSH remoto, la clave pública debe estar copiada en ~/.ssh/authorized_keys de la VM. Si el playbook se ejecuta desde fuera de la VM y la key no está configurada, la conexión SSH se pierde al terminar el playbook. Sin forma de recuperarla remotamente.

Chequear que anda

Playbook terminado → verificar. Cada directiva tiene su comando.

verify-ssh.sh
# Root login debe estar deshabilitado
$ grep "^PermitRootLogin" /etc/ssh/sshd_config
PermitRootLogin no
# Password auth debe estar deshabilitado
$ grep "^PasswordAuthentication" /etc/ssh/sshd_config
PasswordAuthentication no
# Puerto debe ser 2222, no 22
$ grep "^Port" /etc/ssh/sshd_config
Port 2222
# MaxAuthTries 3
$ grep "^MaxAuthTries" /etc/ssh/sshd_config
MaxAuthTries 3
# LoginGraceTime 30
$ grep "^LoginGraceTime" /etc/ssh/sshd_config
LoginGraceTime 30
# sshd debe estar escuchando en 2222, no en 22
$ ss -tlnp | grep sshd
LISTEN 0 128 *:2222 *:* users:(("sshd",pid=891,fd=3))
# SELinux debe tener el puerto 2222 registrado como ssh_port_t
$ semanage port -l | grep ssh_port_t
ssh_port_t tcp 2222

Cómo juega con fail2ban

SSH corta la conexión a los 3 intentos. fail2ban banea la IP entera tras 5 fallos en 10 minutos. Juntos: un bot no tiene chance.

1
Atacante intenta SSH al puerto 2222
2
Falla 3 veces → SSH cierra la conexión
3
Vuelve a intentar, 2 fallos más = 5 en total
4
fail2ban banea la IP por 1 hora

La configuración de fail2ban se cubre en detalle en la Fase 8 (Seguridad para Producción). Para la Fase 1, el rol de fail2ban ya está incluido en site.yml y funciona con los valores por defecto.

Siguiente: Rocky Linux vs Ubuntu

Con el hardening completo aplicado, la última página de la Fase 1 compara las decisiones de seguridad entre Rocky Linux (RHEL) y Ubuntu. El porqué de cada elección y cómo posiciona el proyecto.

Rocky Linux vs Ubuntu