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 fail2banResultado 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 enableActualizaciones y reboot
Ambos servidores tenían paquetes de seguridad pendientes y el flag de reboot activo:
apt update && apt upgrade -y
rebootDespué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_keysSolo 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 sshPermitRootLogin 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.1TLS 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 nginxPHP 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 -yJournal
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-journaldResultado: 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 -pswappiness=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 mysqlServicios 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 -yNginx: 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.