Danila (Dayfing)
Volver a publicaciones
3536 palabras18 min

Asegurar un VPS Linux: SSH, firewall, parches y monitorización

En la primera hora con un VPS Ubuntu o Debian recién creado, cierre las puertas que los escáneres automáticos prueban a los pocos minutos de que una dirección IP entre en línea. Actualice el sistema, entre con un usuario sin privilegios de root que tenga sudo y una clave Ed25519, desactive el acceso por contraseña y como root en un archivo drop-in de SSH y active un firewall con denegación por defecto que solo exponga SSH y los puertos web, idealmente solo a su CDN. Después active las actualizaciones de seguridad desatendidas con reinicios planificados, añada fail2ban o CrowdSec, aísle la aplicación con systemd y configure copias de seguridad y alertas antes de que el DNS apunte al servidor.

Los comandos toman Ubuntu 24.04 LTS como referencia e indican dónde difieren Debian 12 y 13. Mantenga abierta la consola web del proveedor mientras trabaja. Es la vía de regreso si un cambio en SSH o en el firewall le deja fuera.

Actualice el sistema base y cree un usuario con sudo

Muchos proveedores entregan un acceso root con contraseña, así que lo primero es sustituir ambas cosas. Aplique las actualizaciones pendientes y cree una cuenta personal con permisos de sudo:

apt update && apt full-upgrade -y
adduser deploy
usermod -aG sudo deploy

Las imágenes cloud de Ubuntu ya incluyen una cuenta ubuntu con sudo. En una instalación mínima de Debian con contraseña de root puede faltar sudo, así que ejecute primero apt install sudo. Dé a cada persona una cuenta nominal para que el registro de auditoría apunte a un ser humano. UTC simplifica la correlación de logs entre servidores y paneles del CDN: timedatectl set-timezone Etc/UTC.

En qué se diferencia Debian de Ubuntu 24.04

Aspecto Ubuntu 24.04 LTS Debian 12 y 13
Escucha de SSH ssh.socket arranca el demonio bajo demanda normalmente ssh.service; compruébelo con systemctl status
Firewall ufw instalado pero inactivo nada activado; nftables es el framework por defecto
Actualizaciones automáticas unattended-upgrades instalado y activo puede faltar; instálelo y actívelo
Log de autenticación /var/log/auth.log y el journal solo el journal; rsyslog no se instala por defecto desde Debian 12
sudo instalado puede faltar en instalaciones mínimas
OpenSSH 9.6 9.2 en Debian 12, 10.0 en Debian 13

SSH con claves Ed25519 y un drop-in de hardening

Cree e instale la clave

Genere la clave en su estación de trabajo, nunca en el servidor. Las claves Ed25519 son cortas, rápidas y compatibles con cualquier versión actual de OpenSSH. Proteja la clave privada con una frase de contraseña y déjela en ssh-agent. Con una llave de seguridad FIDO2, ssh-keygen -t ed25519-sk vincula la clave al hardware.

ssh-keygen -t ed25519 -C "deploy@laptop-2026"
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

ssh-copy-id necesita un inicio de sesión con contraseña. Si root ya acepta su clave y las contraseñas están desactivadas, copie el archivo de claves en el propio servidor:

sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys

Los permisos importan: con StrictModes activado, que es el valor por defecto, sshd ignora los archivos de claves que otros usuarios pueden modificar.

Escriba el drop-in

En Ubuntu, /etc/ssh/sshd_config empieza con Include /etc/ssh/sshd_config.d/*.conf, y el manual de sshd_config indica que para cada palabra clave se usa el primer valor obtenido. Los drop-ins se leen en orden léxico y prevalecen sobre el resto del archivo principal. Las imágenes cloud suelen traer su propio archivo, como 50-cloud-init.conf o 60-cloudimg-settings.conf, que fija PasswordAuthentication, así que dé al suyo un prefijo bajo:

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no

KbdInteractiveAuthentication no cierra la vía keyboard-interactive de PAM, que todavía puede pedir una contraseña cuando PasswordAuthentication está desactivado. AllowUsers limita el acceso a cuentas concretas. El archivo principal de Ubuntu fija X11Forwarding yes, y el drop-in prevalece porque se lee primero. Si nadie necesita reenvíos, añada AllowAgentForwarding no y AllowTcpForwarding no.

Valide, muestre los valores efectivos y aplique:

sudo sshd -t
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|authenticationmethods|allowusers'
sudo systemctl restart ssh

sshd -t detecta errores de sintaxis y sshd -T muestra la configuración que realmente se aplica, incluido si un archivo de cloud-init ha ganado. Reiniciar ssh mantiene las sesiones establecidas. En Ubuntu 24.04 el socket de escucha pertenece a ssh.socket, así que cambiar Port o ListenAddress requiere además sudo systemctl daemon-reload y sudo systemctl restart ssh.socket, después de que el firewall permita el nuevo puerto. La guía de OpenSSH server de Ubuntu cubre el resto de opciones.

Pruebe en una segunda sesión antes de cerrar la primera

Mantenga abierta la sesión actual. Desde otro terminal, entre como deploy con la clave y ejecute sudo -v. Después confirme que las vías cerradas fallan:

ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password [email protected]
ssh [email protected]

Ambos intentos deben terminar con Permission denied (publickey). Cierre la sesión original solo después de comprobarlo. Mover SSH a otro puerto reduce el ruido en los logs, pero no es un control de seguridad.

Un firewall con denegación por defecto: ufw o nftables

El firewall del host es la segunda capa tras el firewall de red del proveedor, si este lo ofrece. Descarte todo el tráfico entrante salvo SSH y los puertos web, permita el saliente y cubra IPv6 igual que IPv4.

Ubuntu: ufw

Añada la regla de SSH antes de activar el firewall, o ufw enable cortará su sesión:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH
sudo ufw allow proto tcp from any to any port 80,443 comment 'web'
sudo ufw enable
sudo ufw status verbose

Según el manual de ufw, una regla limit bloquea una dirección que abre 6 o más conexiones en 30 segundos. Eso frena a los escáneres, pero puede molestar a la automatización que abre muchas conexiones SSH en paralelo, donde encaja mejor un allow simple. Con una dirección de administración fija o una VPN, permita SSH solo desde ella: sudo ufw allow from 198.51.100.7 to any port 22 proto tcp. Las reglas cubren IPv6 mientras IPV6=yes siga en /etc/default/ufw, que es el valor por defecto.

La documentación de Docker advierte que los puertos publicados por Docker se saltan las reglas de ufw y firewalld. Publique los contenedores en loopback, por ejemplo -p 127.0.0.1:3000:3000, y deje que el proxy inverso dé la cara a Internet.

Debian: nftables

Debian usa nftables como framework de firewall. Este /etc/nftables.conf aplica la misma política y prepara conjuntos con nombre para los rangos del CDN de la sección siguiente:

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
  set cloudflare_v4 {
    type ipv4_addr
    flags interval
  }

  set cloudflare_v6 {
    type ipv6_addr
    flags interval
  }

  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif "lo" accept
    meta l4proto { icmp, ipv6-icmp } accept
    tcp dport 22 accept
    ip saddr @cloudflare_v4 tcp dport { 80, 443 } accept
    ip6 saddr @cloudflare_v6 tcp dport { 80, 443 } accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
  }
}

Compruebe el archivo con sudo nft -c -f /etc/nftables.conf y ejecute sudo systemctl enable --now nftables. Deje ICMPv6 abierto, porque de él dependen el descubrimiento de vecinos y el descubrimiento de la MTU de ruta en IPv6. flush ruleset también elimina las tablas creadas por fail2ban o Docker, así que reinícielos tras una recarga. Use una sola herramienta de gestión del firewall por host.

Deje que solo el CDN llegue a los puertos web

Detrás de Cloudflare u otro CDN, el origen no debe responder a nadie más en los puertos 80 y 443. De lo contrario, cualquiera que encuentre la dirección del origen, por registros DNS antiguos, un servidor de correo en la misma IP o escaneos de Internet, se salta el WAF, los límites de peticiones y la caché del CDN. Cloudflare publica sus rangos en https://www.cloudflare.com/ips-v4 y https://www.cloudflare.com/ips-v6, y su documentación de direcciones IP afirma que los rangos nuevos se añaden a la lista antes de entrar en producción.

Con ufw, añada una regla por rango. Los archivos no terminan en salto de línea, así que el bucle debe tratar la última línea de forma explícita o se saltará un rango sin avisar:

#!/usr/bin/env bash
set -euo pipefail
for list in ips-v4 ips-v6; do
  curl -fsS "https://www.cloudflare.com/$list" |
    while read -r cidr || [ -n "$cidr" ]; do
      ufw allow proto tcp from "$cidr" to any port 80,443 comment 'cloudflare'
    done
done

Luego elimine la regla abierta con sudo ufw delete allow proto tcp from any to any port 80,443. Con nftables, renueve ambos conjuntos en una sola transacción, porque nft -f aplica el lote completo o nada. Si una descarga falla, set -e detiene el script y los conjuntos anteriores se conservan:

#!/usr/bin/env bash
set -euo pipefail
v4=$(curl -fsS https://www.cloudflare.com/ips-v4 | paste -sd, -)
v6=$(curl -fsS https://www.cloudflare.com/ips-v6 | paste -sd, -)
nft -f - <<EOF
flush set inet filter cloudflare_v4
flush set inet filter cloudflare_v6
add element inet filter cloudflare_v4 { $v4 }
add element inet filter cloudflare_v6 { $v6 }
EOF

Ejecútelo al arrancar, después de nftables.service, y cada semana con un timer de systemd. Nginx necesita entonces set_real_ip_from para los mismos rangos y real_ip_header CF-Connecting-IP;, o los logs y los límites verán direcciones de Cloudflare en lugar de visitantes. La guía de caché con Nginx y Cloudflare cubre la parte del servidor web.

Una lista de IP permitidas demuestra que la conexión llega de la red de Cloudflare, no que pertenezca a su zona. La guía de protección del origen de Cloudflare la considera vulnerable a la suplantación de IP y describe opciones más sólidas: Authenticated Origin Pulls, que requiere el modo de cifrado Full o Full (strict), y Cloudflare Tunnel, que no necesita ningún puerto web entrante.

Actualizaciones desatendidas y reinicios del kernel

Ubuntu Server instala unattended-upgrades y activa las actualizaciones de seguridad por defecto, como describe la guía de actualizaciones automáticas. Compruébelo:

cat /etc/apt/apt.conf.d/20auto-upgrades
sudo unattended-upgrade --dry-run -v

El archivo debe contener APT::Periodic::Update-Package-Lists "1"; y APT::Periodic::Unattended-Upgrade "1";. En Debian, ejecute sudo apt install unattended-upgrades y sudo dpkg-reconfigure -plow unattended-upgrades.

Los parches del kernel solo surten efecto tras un reinicio. En Ubuntu 24.04, needrestart reinicia los servicios que usan bibliotecas actualizadas, pero no puede sustituir el kernel en ejecución. Cuando un paquete necesita reinicio, aparece /var/run/reboot-required. Guarde los ajustes locales en un archivo que se ordene después del 50unattended-upgrades del paquete:

// /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";

Elija una hora de poco tráfico y asegúrese de que la aplicación vuelve tras el arranque sin pasos manuales. Los logs están en /var/log/unattended-upgrades/. Canonical Livepatch, gratuito para uso personal en hasta cinco máquinas, corrige en memoria las vulnerabilidades críticas y altas del kernel, pero Canonical aclara que no sustituye al reinicio.

fail2ban o CrowdSec

Con el acceso por contraseña desactivado, la fuerza bruta contra SSH no puede tener éxito. Una herramienta de bloqueo es una segunda capa que reduce el ruido de los logs, ahorra CPU en los handshakes y frena a los escáneres. Elija una.

fail2ban está empaquetado en ambas distribuciones y no requiere cuenta. En Ubuntu 24.04 el paquete activa la jail sshd con el backend del journal de systemd y acciones de nftables, y ese backend también sirve en Debian, que no tiene auth.log. Instálelo con sudo apt install fail2ban y ajuste la jail en un archivo local en lugar de editar jail.conf:

# /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
bantime.increment = true

Aplique el archivo con sudo systemctl restart fail2ban y revise la jail con sudo fail2ban-client status sshd.

CrowdSec analiza los mismos logs, comparte señales con una lista de bloqueo comunitaria y aplica sus decisiones mediante un componente de remediación independiente. Ubuntu 24.04 incluye la versión 1.4.6, y la guía de instalación de CrowdSec pide una versión más reciente del repositorio del fabricante. Lea el script de instalación antes de ejecutarlo:

curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections list
sudo cscli decisions list

Detrás de un CDN, las peticiones web llegan desde direcciones del CDN. Bloquear a un atacante HTTP en el firewall del host o corta un nodo de Cloudflare o apunta a una dirección que nunca se conecta directamente. Reserve los bloqueos del host para SSH y use las reglas de WAF y de limitación de peticiones del CDN para HTTP.

Aísle la aplicación con systemd

Ejecute el backend con su propio usuario de sistema en una unidad de systemd, no en una shell ni con nohup, y deje que systemd le quite todo lo que el proceso no necesita. Cree el usuario con sudo useradd --system --no-create-home --shell /usr/sbin/nologin webapp. Esta unidad ejecuta un servicio Node.js en localhost detrás de Nginx:

# /etc/systemd/system/webapp.service
[Unit]
Description=Example web application
After=network-online.target
Wants=network-online.target

[Service]
User=webapp
Group=webapp
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/server.js
Environment=HOST=127.0.0.1 PORT=3000
LoadCredential=db_password:/etc/webapp/db_password
Restart=on-failure
StateDirectory=webapp
UMask=0077

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target

NoNewPrivileges=yes impide que el proceso y sus hijos obtengan privilegios mediante binarios setuid o capabilities de archivo. ProtectSystem=strict monta el sistema de archivos en solo lectura para el servicio, salvo /dev, /proc y /sys, de modo que el único lugar escribible es /var/lib/webapp, creado por StateDirectory=. Añada ReadWritePaths= para cualquier otra ruta. ProtectHome=yes oculta /home, /root y /run/user, y PrivateTmp=yes da al servicio sus propios /tmp y /var/tmp. Un CapabilityBoundingSet= vacío elimina todas las capabilities, y @system-service es la lista de llamadas permitidas que el manual de systemd.exec recomienda como punto de partida.

MemoryDenyWriteExecute=yes se omite a propósito: el manual indica que es incompatible con los motores JIT, y V8 en Node.js genera código en tiempo de ejecución. Añádalo en servicios escritos en Go, Rust o C, y pruebe.

En Ubuntu 24.04 con systemd 255, systemd-analyze security --offline=true puntuó este archivo con 1.5, frente a 9.0 para la misma unidad con solo User=. Arránquelo y compruebe:

sudo systemctl daemon-reload
sudo systemctl enable --now webapp
systemd-analyze security webapp.service
journalctl -u webapp -b -n 50 --no-pager

Si el servicio no arranca, el journal suele nombrar la ruta o la llamada al sistema bloqueada. Relaje una opción cada vez en lugar de borrar el bloque entero. Los servicios empaquetados como Nginx arrancan como root y necesitan capabilities concretas, así que endurézcalos con sudo systemctl edit nginx, también de una opción en una.

Sincronización horaria, journald y retención de logs

La validación TLS, los códigos TOTP, los horarios de copia de seguridad y la correlación de logs dependen de un reloj correcto. Ubuntu 24.04 y Debian 12 usan systemd-timesyncd por defecto. Ejecute timedatectl status y busque System clock synchronized: yes y NTP service: active. Si no hay ningún servicio NTP activo, instale systemd-timesyncd o chrony.

Con el valor por defecto Storage=auto, el journal solo se guarda en disco si existe /var/log/journal, y puede ocupar hasta el 10 % del sistema de archivos, con un máximo de 4 GB. Fije límites explícitos para que un servicio ruidoso no llene un disco pequeño y para que la retención coincida con su política de privacidad:

# /etc/systemd/journald.conf.d/50-retention.conf
[Journal]
Storage=persistent
SystemMaxUse=1G
MaxRetentionSec=30day

Aplíquelo con sudo systemctl restart systemd-journald y compruebe journalctl --disk-usage. logrotate rota los logs de Nginx y de la aplicación en /var/log, así que ajuste /etc/logrotate.d/ al mismo periodo. Las notas de la versión de Debian 12 confirman que rsyslog ya no se instala por defecto, por lo que en Debian el journal es el único log del sistema. Los logs de acceso contienen direcciones IP, que pueden ser datos personales según el RGPD, así que una retención más corta suele ser la correcta.

Secretos y permisos de archivos

En un VPS, los secretos suelen filtrarse por vías corrientes: un archivo .env que cualquier usuario puede leer, una clave en el historial de Git o una variable de entorno impresa en un informe de fallo. El manual de systemd afirma que las variables de entorno no son adecuadas para secretos, porque se exponen a clientes sin privilegios por D-Bus y las heredan los procesos hijos. Use LoadCredential=, como en la unidad anterior. El archivo de origen solo lo puede leer root, y el servicio recibe una copia de solo lectura en $CREDENTIALS_DIRECTORY:

sudo install -d -m 0700 -o root -g root /etc/webapp
sudo install -m 0600 -o root -g root /dev/null /etc/webapp/db_password
sudoedit /etc/webapp/db_password

systemd-creds encrypt junto con LoadCredentialEncrypted= mantiene además el archivo cifrado en reposo con una clave del host o el TPM. El código de la aplicación debe pertenecer a root o a un usuario de despliegue, no al usuario del servicio, para que un proceso comprometido no pueda reescribirse. Tras instalar software de terceros, busque archivos con escritura para todos y binarios setuid inesperados:

sudo find / -xdev -type f -perm -0002 -print
sudo find / -xdev -type f -perm -4000 -print

Dé a la clave de despliegue de CI su propia línea en authorized_keys con la opción restrict y, cuando sea posible, una lista de direcciones from=. Los agentes de programación que ejecutan comandos en el servidor necesitan la misma disciplina: un usuario propio, ningún secreto de producción y aprobación para los comandos destructivos. La guía de programación con agentes compara los controles de permisos de las CLI actuales.

Copias de seguridad y ganchos de monitorización

El hardening reduce la probabilidad de un incidente, mientras que las copias de seguridad y las alertas deciden cuánto dura. Las instantáneas del proveedor sirven para volver atrás rápido, pero viven en la misma cuenta que el servidor, así que no son una copia independiente. Guarde una copia cifrada y versionada fuera del proveedor y pruebe una restauración. La guía de copias de seguridad 3-2-1 con restic cubre la estructura del repositorio, la retención y los simulacros de restauración.

Un VPS aislado necesita pocas señales: una comprobación HTTP externa a través del CDN, otra que verifique que el origen rechaza las conexiones directas, el uso de disco, la caducidad de los certificados, las unidades de systemd fallidas y un heartbeat de cada tarea programada, para que una copia que deja de ejecutarse en silencio genere una alerta. systemd puede llamar a un notificador cada vez que falla una unidad:

# /etc/systemd/system/[email protected]
[Unit]
Description=Alert on failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i

Añada OnFailure=notify-failure@%n.service a la sección [Unit] de las unidades de la aplicación y de las copias de seguridad. El script puede enviar el nombre de la unidad y las últimas líneas del journal a un webhook de chat o de guardias. Los exportadores de métricas, como el node exporter de Prometheus, deben escuchar en localhost o en una red privada. Si el servidor ejecuta agentes de IA o herramientas MCP con acceso a la shell, deles su propio usuario y su sandbox, y trate sus entradas como no confiables, como explica la guía de prompt injection y seguridad en MCP.

La checklist de la primera hora

  1. Aplique las actualizaciones, cree un usuario sudo nominal y mantenga abierta la consola del proveedor.
  2. Instale una clave Ed25519 y añada 00-hardening.conf con el acceso root y por contraseña desactivados.
  3. Ejecute sshd -t y sshd -T, reinicie ssh y pruebe una sesión nueva antes de cerrar la anterior.
  4. Active un firewall con denegación por defecto en IPv4 e IPv6 que permita SSH, 80 y 443.
  5. Limite los puertos web a los rangos de Cloudflare o use Authenticated Origin Pulls o Tunnel, y restaure las IP de los visitantes en Nginx.
  6. Confirme las actualizaciones de seguridad desatendidas y programe reinicios automáticos.
  7. Active fail2ban o CrowdSec para SSH y use el WAF del CDN para HTTP.
  8. Ejecute la aplicación con su propio usuario en una unidad systemd aislada y revise systemd-analyze security.
  9. Confirme la sincronización horaria y fije límites de tamaño y retención del journal.
  10. Mueva los secretos a archivos credentials que solo root pueda leer y revise los permisos.
  11. Configure copias fuera del proveedor, pruebe una restauración y añada alertas de fallo y heartbeats.
  12. Reinicie una vez a propósito y confirme que todos los servicios vuelven sin pasos manuales.

Más publicaciones