De 68 a 76 puntos: hardening real de un VPS Ubuntu 24.04

Monitores en sala de servidores mostrando auditoría Lynis con puntuación inicial 68 y final 92 en VPS Ubuntu 24.04

En el artículo anterior identificamos los problemas. Ahora toca resolverlos. Este es el registro de todo lo que hice en los dos servidores: qué arreglé, en qué orden, qué decidí ignorar conscientemente y cómo quedó la puntuación de Lynis al final.

No hay recetas mágicas. Cada decisión depende del contexto — un servidor de producción con webs de clientes no se hardening igual que un servidor de pruebas. Lo que sí hay es una priorización clara: primero lo que puede comprometer el servidor hoy, luego lo que puede comprometer el servidor mañana, luego las mejoras de calidad.

¿Hardening?

Hardening no tiene traducción al español — en el sector se usa directamente, como tantos otros términos técnicos. Significa literalmente "endurecimiento", y la analogía más clara es la de una casa: cerrar las ventanas que no usas, cambiar la cerradura de la puerta principal, quitar la llave de debajo del felpudo y poner una alarma. No garantiza que nadie entre nunca, pero hace que entrar sea mucho más difícil y costoso. En términos de servidor: eliminar servicios innecesarios, restringir accesos, endurecer configuraciones y añadir capas de detección. Lo que viene a continuación.

Las prioridades, ordenadas

Esta fue la lista de problemas, ordenada por urgencia real:

Prioridad Servidor Problema
🔴 Crítico VPS web Sin fail2ban — 13.000 intentos SSH sin banear a nadie
🔴 Crítico VPS web Login SSH como root permitido
🔴 Crítico Ambos Actualizaciones de seguridad pendientes + reboot
🔴 Crítico VPS casa Sin UFW — sin firewall en el host
🟠 Importante VPS web Promtail escuchando en todas las interfaces
🟠 Importante VPS web TLS 1.0 y 1.1 activos en nginx.conf global
🟠 Importante VPS web PHP 8.3 y 8.4 corriendo a la vez
🟠 Importante VPS web Journal ocupando 1.3 GB
🟠 Importante VPS casa Panel de administración NPM expuesto en internet
🟡 Mejora Ambos Sin swap
🟡 Mejora VPS web MySQL con buffer pool de 128 MB para tres webs pequeñas
🟡 Mejora Ambos Snapd, cloud-init y multipathd instalados sin usar
🟡 Mejora VPS web server_tokens y gzip_types sin configurar en Nginx

Lo crítico: primero y sin excusas

fail2ban en el servidor de producción

Con 13.000 intentos fallidos en 25 días y sin ninguna protección activa, esto era lo primero. La configuración que uso:

apt install -y fail2ban

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 24h
findtime = 10m
maxretry = 3
ignoreip = 127.0.0.1/8 10.100.0.0/24
backend  = systemd

[sshd]
enabled  = true
port     = ssh
maxretry = 3
bantime  = 24h
EOF

systemctl enable --now fail2ban

Resultado inmediato: 9 IPs baneadas en los primeros minutos. backend = systemd en lugar de leer /var/log/auth.log directo — más eficiente y no depende de que logrotate no haya rotado el fichero en el momento equivocado.

UFW en el servidor proxy

El VPS casa no tenía UFW instalado. Solo tenía las reglas que fail2ban había escrito directamente en iptables:

apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw default allow routed
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 51821/udp  # WireGuard
ufw enable

Actualizaciones y reboot

Ambos servidores tenían paquetes de seguridad pendientes y el flag de reboot activo:

apt update && apt upgrade -y
reboot

Después del reboot, siempre verifico:

ls /var/run/reboot-required 2>/dev/null && echo "REBOOT AÚN NECESARIO" || echo "OK"

Deshabilitar login SSH como root

Este fue el paso que más preparación requirió. Antes de tocar SSH, verifiqué que había un usuario alternativo con acceso y sudo configurado. El usuario deployer ya existía para los deploys via rsync, pero no tenía permisos de administración. Creé un usuario jaume específicamente para la administración del servidor:

adduser jaume
usermod -aG sudo jaume
mkdir -p /home/jaume/.ssh
cp /root/.ssh/authorized_keys /home/jaume/.ssh/
chown -R jaume:jaume /home/jaume/.ssh
chmod 700 /home/jaume/.ssh
chmod 600 /home/jaume/.ssh/authorized_keys

Solo después de verificar que podía entrar y hacer sudo, apliqué el hardening SSH:

cat > /etc/ssh/sshd_config.d/hardening.conf << 'EOF'
PasswordAuthentication no
MaxAuthTries 3
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
MaxSessions 2
ClientAliveCountMax 2
LogLevel VERBOSE
EOF

sshd -t && systemctl reload ssh

PermitRootLogin no lo dejé en el sshd_config principal, donde rkhunter puede leerlo directamente sin procesar includes.

Lo importante: esta semana

Promtail a localhost

Promtail — el agente que envía logs a Loki — escuchaba en 0.0.0.0:9080 y 0.0.0.0:46697. UFW lo bloqueaba desde fuera, pero la configuración era incorrecta. Dos líneas en /etc/promtail/promtail-config.yml:

http_listen_address: 127.0.0.1
grpc_listen_address: 127.0.0.1

TLS en Nginx

El nginx.conf principal tenía ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3. Los vhosts individuales lo sobreescribían correctamente, pero cualquier vhost nuevo que no definiera los protocolos heredaría TLS 1.0 y 1.1. Un sed y listo:

sed -i 's/ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3/ssl_protocols TLSv1.2 TLSv1.3/' /etc/nginx/nginx.conf
nginx -t && systemctl reload nginx

PHP 8.3

Ningún vhost usaba PHP 8.3. Eliminado:

systemctl stop php8.3-fpm && systemctl disable php8.3-fpm
apt purge php8.3-fpm -y

Journal

1.3 GB de logs en un VPS de producción es excesivo:

journalctl --vacuum-size=200M
sed -i 's/#SystemMaxUse=/SystemMaxUse=200M/' /etc/systemd/journald.conf
systemctl restart systemd-journald

Resultado: de 1.3 GB a 175 MB. 1 GB liberado de un plumazo.

Panel NPM solo via WireGuard

El panel de administración de Nginx Proxy Manager (puerto 81) estaba expuesto en 0.0.0.0. Cualquiera podía intentar acceder. El cambio fue en el docker-compose.yml:

# Antes:
- "81:81"

# Después:
- "10.100.0.1:81:81"

Para que esto fuera posible sin perder acceso al panel, primero configuré WireGuard en mi máquina principal (Solarium). Desde entonces accedo al panel en http://10.100.0.1:81 — solo disponible si estoy conectado a la VPN.

Las mejoras de calidad

Swap

Ninguno de los dos servidores tenía swap. Con MySQL, PHP-FPM y Promtail consumiendo memoria en el servidor de producción, un pico inesperado podría hacer que el kernel matara procesos sin aviso:

fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' >> /etc/sysctl.conf
sysctl -p

swappiness=10 significa que el kernel solo usará swap cuando la RAM esté casi llena — no como caché de disco, sino como red de seguridad.

MySQL buffer pool

El innodb_buffer_pool_size estaba en 128 MB por defecto. Para tres webs de bajo tráfico es el doble de lo necesario:

echo "innodb_buffer_pool_size = 64M" >> /etc/mysql/mysql.conf.d/mysqld.cnf
systemctl restart mysql

Servicios sin utilidad

Snapd, cloud-init y multipathd eliminados en ambos servidores:

# Snapd (sin snaps instalados: 128 MB liberados)
apt purge snapd -y && rm -rf /var/lib/snapd /snap

# cloud-init (el servidor ya está configurado, no volverá a hacer nada útil)
systemctl disable cloud-init cloud-config cloud-final cloud-init-local
touch /etc/cloud/cloud-init.disabled

# multipathd (gestión de discos SAN/iSCSI, sin sentido en un VPS con un solo disco)
systemctl stop multipathd && systemctl disable multipathd
apt purge multipath-tools -y

Nginx: server_tokens y gzip_types

Dos configuraciones estándar que faltaban:

# /etc/nginx/conf.d/security.conf
server_tokens off;
# /etc/nginx/conf.d/gzip.conf
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript
           text/xml application/xml application/xml+rss text/javascript
           image/svg+xml font/woff2;

server_tokens off evita que Nginx incluya su versión en las cabeceras HTTP. gzip_types asegura que CSS, JS y JSON se comprimen antes de enviarse — con gzip on pero sin gzip_types, solo se comprime HTML.

Lo que decidí no tocar

Lynis tiene 39 sugerencias pendientes. Muchas las ignoré conscientemente:

Particiones separadas para /home, /tmp y /var: en un VPS con disco único no tiene sentido reparticionarlo en producción.

Contraseña en GRUB: en un servidor sin acceso físico es irrelevante.

kernel.modules_disabled = 1: desactivaría la carga dinámica de módulos, lo que incluye WireGuard. No.

net.ipv4.conf.all.forwarding = 0: necesario para el routing de WireGuard. Tampoco.

Separación de particiones en /proc con hidepid: útil en servidores multiusuario. En un servidor con dos usuarios controlados, añade complejidad sin beneficio real.

La clave es que ignorar una sugerencia de Lynis no es lo mismo que no conocerla. La diferencia está en saber por qué la ignoras.

El resultado

Después de aplicar todas las mejoras, rkhunter sin warnings y Lynis con la puntuación final:

Hardening index : 76
Tests performed : 269
Warnings        : 1
Suggestions     : 39
Métrica Antes Después
Hardening index 68 76
Suggestions 46 39
fail2ban
Login SSH como root ✅ permitido ❌ bloqueado
TLS 1.0 / 1.1 ✅ activos ❌ eliminados
Swap ✅ 1 GB
PHP 8.3 ✅ corriendo ❌ eliminado
Promtail expuesto ❌ solo localhost
Panel NPM público ❌ solo WireGuard
Journal 1.3 GB 175 MB
rkhunter ✅ limpio

76 sobre 100 no es perfecto. Pero es un servidor que ahora tiene protección activa, sin servicios innecesarios, con SSH endurecido y con una línea base documentada para comparar en la próxima auditoría.

El 100 de Lynis es una utopía en la práctica — algunas sugerencias son contradictorias con un servidor funcional. El objetivo real no es la puntuación: es conocer el estado del sistema, entender qué se puede mejorar y tener un registro de cómo evoluciona. La próxima auditoría programada es en tres meses.

Jaume Ferré

Jaume Ferré

Tengo un trabajo que no tiene nada que ver con esto. Pero me gusta cacharrear con webs, Linux y el homelab. De vez en cuando alguien me paga por ello, lo cual siempre sorprende. Canon R7 en mano y Arch Linux de fondo.

¿Te ha sido útil?

Ayúdame a mejorar con tu puntuación.

0.0 (0 votos)
Comentarios