
🌐 También en: English · Deutsch · Français
Parte 5 de la visita a Ansible. La parte 1 explicó el marco, la parte 2 el servidor y la plataforma, las partes 3 y 4 el blog y Nextcloud. Esta parte reúne todos los roles de seguridad en un mismo sitio, para ambas pilas. Describe la versión v3.3.0.
Defense in Depth describió la idea: nueve capas entre internet y los datos, cada una atrapa lo que se le escapó a la anterior. Aquel artículo decía cuáles son las capas. Este muestra cómo están construidas – rol a rol, con los handlers, las entradas declaradas, las plantillas y los errores que les dieron forma. Nueve roles, de fuera hacia dentro:
| Capa | Rol | Protege contra |
|---|---|---|
| Red | common_firewall | Todo lo que no esté permitido explícitamente, escaneos de puertos, una puerta trasera a través de Kubernetes |
| Inicio de sesión | common_ssh | Adivinar contraseñas, criptografía débil, sesiones de administrador que pueden demasiado |
| Reacción | common_fail2ban | Intentos repetidos – adivinar inicios de sesión, escáneres, rutas de ataque conocidas |
| Sistema operativo | common_cis_hardening | Funciones sin usar, valores por defecto débiles, fugas de información del sistema |
| Contención | common_selinux | Un proceso comprometido que llega a archivos que no le incumben |
| Detección | common_auditd, common_aide_refresh, common_rkhunter, common_clamav | Cambios que nunca deberían ocurrir, rootkits, malware en las subidas – detectados y notificados |
Otras dos capas se trataron en otras partes y solo se resumen al final: el endurecimiento de red del kernel (common_sysctl, parte 2) y el endurecimiento de los propios pods (partes 3 y 4). Y una tiene su propio artículo: la alarma de inicio de sesión SSH.
Una nota sobre el nivel de detalle: el código es público y la serie lo explica, así que un atacante también puede leerlo. No pasa nada – la protección debe venir de claves, capas y alarmas, no del secreto. Pero aquí mantengo vagos los umbrales exactos. Están en el repositorio para quien los necesite.
🧱 common_firewall: una tabla, una lista de permitidos y unas cuantas trampas
El cortafuegos es nftables, el sucesor de iptables en el kernel de Linux. Si conoces iptables, las diferencias que importan aquí son: una sola herramienta (nft) para IPv4 e IPv6 en lugar de dos conjuntos de reglas; tablas y cadenas que creas tú mismo y enganchas a un punto del recorrido de los paquetes en el kernel (un hook, con una prioridad); sets de direcciones integrados con caducidad automática, en lugar de la herramienta aparte ipset; y – lo más importante para la automatización – un archivo de reglas se carga como una única transacción atómica: las reglas antiguas siguen activas hasta que las nuevas están completamente en su sitio. La migración desde iptables es un artículo más antiguo.
El rol, paso a paso
- Instalar nftables.
- ¿DHCP o estática? El rol pregunta a NetworkManager cómo obtiene sus direcciones la interfaz de salida y guarda la respuesta en dos facts (la parte 1 lo usó como ejemplo de «detectar en lugar de declarar»). Solo donde se usa DHCP genera la plantilla las reglas de DHCP – sin ellas, la renovación de la concesión falla y se pierde la dirección.
- El filtro SSH por país, si el host tiene
fw_ssh_geo_countriesdefinido (los tres servidores solo permiten SSH desde un país – la historia está aquí). Un script descarga los rangos de direcciones de ese país del registro regional y los escribe en un archivo de nftables; un temporizador de systemd lo renueva cada semana. Este bloque se ejecuta a propósito antes de escribir el conjunto de reglas: el conjunto incluye el archivo, y si la primerísima descarga falla, el play se detiene antes de que exista ninguna regla que pueda dejar fuera a SSH. Todo es un únicoblockcon un únicowhen, y un bloque espejo lo elimina todo cuando se desactiva el filtro. - El conjunto de reglas de
fw.nft.j2a/etc/sysconfig/nftables.conf– el archivo que de verdad lee el servicio de AlmaLinux, no el/etc/nftables.confque mencionan muchas guías. Convalidate: nft -c -f %s: Ansible escribe el archivo generado en una ruta temporal, ejecuta sobre él la comprobación de sintaxis y solo sustituye el archivo real si la comprobación pasa. Una errata nunca llega a la configuración del servidor. - Un drop-in de systemd que cambia la recarga y la parada. Más sobre esto abajo – es la línea más importante del rol.
- Activar nftables y arrancarlo – salvo en la primerísima ejecución.
- Limpiar: un antiguo script de iptables de antes de este rol (el blog funcionó un tiempo con dos cortafuegos superpuestos, y el antiguo descartaba las renovaciones de DHCP), el
/etc/nftables.confsin usar, y firewalld, que se enmascara en lugar de eliminarse – eliminar el paquete eliminaría también nftables y fail2ban, porque dependen unos de otros en el empaquetado.
Nunca «flush ruleset»
El servicio de nftables de serie recarga con flush ruleset – borrar todas las reglas del kernel – y luego carga el archivo. En un nodo de Kubernetes eso es una trampa. kube-proxy, Flannel y el controlador de network policies integrado en K3s también escriben sus propias reglas en nftables, en sus propias tablas. Un flush las borra también, y durante 30 a 90 segundos, hasta que lo notan y las reescriben, los Services de Kubernetes dejan de funcionar – Nextcloud no llegaba a su base de datos tras cada recarga del cortafuegos. Así que el conjunto de reglas solo reemplaza su propia tabla, y el drop-in le enseña a systemd a hacer lo mismo:
# fw.nft.j2, first lines
table inet filter
delete table inet filter
table inet filter {
...
}
# /etc/systemd/system/nftables.service.d/10-own-table-only.conf
[Service]
ExecReload=
ExecReload=/sbin/nft -f /etc/sysconfig/nftables.conf
ExecStop=
ExecStop=/sbin/nft delete table inet filterLa primera línea parece extraña: ¿por qué declarar una tabla solo para borrarla? Porque delete falla si la tabla no existe – en un arranque limpio. Declararla primero hace que el borrado sea siempre seguro, y como todo el archivo es una sola transacción, no hay ni un instante sin cortafuegos. En el drop-in, el ExecReload= vacío borra el comando de serie antes de fijar el nuevo, el mismo truco que con el temporizador de actualizaciones de la parte 2.
Qué hace el conjunto de reglas
La plantilla es larga, pero su estructura es sencilla. Cada cadena tiene policy drop: lo que no está permitido explícitamente se descarta – tráfico saliente incluido.
- Input: loopback; paquetes que pertenecen a una conexión existente (
ct state established,related– el seguimiento de conexiones recuerda lo que se permitió); después se descarta la basura (estado inválido, paquetes con combinaciones de flags imposibles, respuestas de handshake falsificadas, inundaciones de RST); luego los sets de bloqueo; luego las excepciones que necesita el clúster (los pods llegando al servidor de API, y en los hosts de Nextcloud la base de datos y Redis del host – el ejemplo destackde la parte 1); luego el tráfico web, SSH (desde la lista de países) y los tipos de ICMP que IPv4 y sobre todo IPv6 necesitan para funcionar. Por último, antes del descarte por política, una línea de log con límite de tasa. - Sets de bloqueo:
banned4/banned6para fail2ban, con un tiempo de espera para que las entradas caduquen solas, yscanban4/scanban6para el detector de escaneos de puertos. - Detección de escaneos de puertos, dentro del cortafuegos: un paquete que llega al final de la cadena input está a punto de descartarse – un puerto cerrado. La regla cuenta esos intentos de conexión por dirección de origen en un set dinámico; un origen que supera unas pocas docenas por minuto acaba durante una hora en
scanbany queda entonces bloqueado en todos los puertos. Una persona que se equivoca de puerto envía un puñado; un escáner envía cientos. El puerto SSH por defecto no se cuenta, así que olvidar unas cuantas veces el puerto propio nunca deja fuera al administrador. fail2ban no podría hacer esto – solo lee logs, y el log de descartes está limitado en tasa a propósito. - Prerouting: cierra una puerta trasera que abre Kubernetes. kube-proxy reenvía el rango de NodePort en cada dirección local, la pública incluida, y ese tráfico pasa por la cadena forward, no por input. Los exporters de métricas de las partes 3 y 4 eran accesibles desde internet de ese modo. Esta cadena descarta las conexiones nuevas a ese rango en las direcciones públicas. Su prioridad está elegida con precisión – después del seguimiento de conexiones, antes de que Kubernetes reescriba el destino – y esa precisión tiene historia: al principio se ejecutaba antes, previa al seguimiento de conexiones, y descartaba también las respuestas a las conexiones salientes de los propios pods cada vez que su puerto de origen elegido al azar caía en ese rango. Alrededor del 4 por ciento de las conexiones salientes de los pods agotaron el tiempo durante dos meses, hasta que lo descubrió una prueba de aceptación de un servidor nuevo.
- Forward: tráfico de pods en ambas direcciones; output: DNS, NTP, HTTP(S), envío de correo, DHCP donde haga falta, la red de pods y el servidor de API – nada más sale del servidor.
La primera ejecución: por qué se reinicia el servidor
En un servidor nuevo, SSH sigue escuchando en el puerto 22 y no hay ningún cortafuegos activo. El rol de SSH (abajo) mueve SSH a su nuevo puerto, este rol escribe el cortafuegos. Activar ambos en caliente mataría la conexión que usa Ansible: la conexión existente aún no está en el seguimiento de conexiones, no coincide con ninguna regla, y sus paquetes se descartan en silencio – el play simplemente se queda colgado. Así que en la primera ejecución el rol no arranca nftables, y termina en su lugar con un reinicio:
- name: Firewall | First run only – reboot to activate the new SSH port and firewall together
ansible.builtin.include_tasks:
file: first_run_reboot.yml
apply:
tags: [common_firewall]
when: ssh_first_runDentro de first_run_reboot.yml: enviar systemctl reboot por la conexión actual del puerto 22 con async: 1, poll: 0 (disparar y olvidar – la conexión va a morir), cambiar ansible_port al nuevo puerto con set_fact, descartar la conexión antigua con meta: reset_connection y esperar con wait_for_connection hasta que el servidor responda en el nuevo puerto. El play continúa entonces allí. Tres detalles de Ansible lo hacen posible:
include_tasks, noimport_tasks:meta: reset_connectionignorawhen. Dentro de un archivo importado estáticamente con una condición, se ejecutaba en cada ejecución e imprimía un aviso. Un include dinámico evalúa primero su condición y solo entonces carga el archivo.apply: tags: las etiquetas de un include dinámico no llegan a las tareas que contiene.applylas transmite, así que el include también funciona con--tags common_firewall.- El fact
ssh_first_runviene del rol de SSH, que se ejecutó justo antes: los valores deset_factsiguen disponibles para todos los roles posteriores en ese host. Es verdadero cuando el play se conectó por el puerto 22.
Una cosa sigue siendo manual, y el play imprime un recordatorio: el nuevo puerto debe escribirse en el vars.yml del host antes de la siguiente ejecución. Una versión anterior intentaba el reinicio por el puerto nuevo – y fallaba al instante, porque las variables de tarea también se aplican a la conexión que envía el reinicio. El orden actual (reiniciar por el puerto antiguo, luego cambiar) nació de ahí.
Handlers: recargar y luego restaurar los bloqueos
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesReemplazar la tabla reemplaza también los sets de bloqueo – vacíos. fail2ban no se daba cuenta y seguía considerando «bloqueadas» esas direcciones mientras el cortafuegos las dejaba entrar de nuevo: tras una ejecución del playbook, la mayoría de los bloqueos en dos servidores solo existían en la memoria de fail2ban. El segundo handler se suscribe a la misma notificación con listen y se ejecuta después del primero, porque los handlers se ejecutan en el orden en que están definidos. Un reinicio hace que fail2ban vuelva a aplicar cada bloqueo activo desde su base de datos. try-restart no hace nada donde fail2ban no está en marcha.
Actualización (19/10/2026): solo nftables, en cada host. El blog aún tenía los paquetes de EPEL iptables-legacy e iptables-services, así que iptables era el backend legacy y K3s guardaba sus reglas en tablas legacy, invisibles para nft list ruleset. common_firewall elimina ahora esos paquetes y common_cis_hardening bloquea los módulos de tablas legacy (iptable_*, ip6table_*). No ip_tables en sí: iptables-nft toma de él la coincidencia ICMP; bloquearlo también hizo que el controlador de policies de K3s fallara tras un reinicio y dejó el blog caído cinco minutos.
🔑 common_ssh: la puerta, y lo que se puede hacer una vez dentro
Puerto, SELinux y la configuración
SSH se muda a un puerto no estándar. Eso no es seguridad por sí mismo – un escáner lo encuentra –, pero elimina casi todo el ruido automatizado dirigido al puerto 22, lo que mantiene útiles los logs y fail2ban. SELinux solo permite a sshd escuchar en puertos etiquetados ssh_port_t, así que el rol lee una vez la lista de etiquetas y luego añade la etiqueta (semanage port -a) o modifica una existente (-m) – dos tareas con condiciones opuestas sobre la misma salida registrada, una de ellas con un regex_search que comprueba si el puerto ya figura con el tipo correcto.
El archivo de configuración es estricto: solo claves, sin contraseñas ni inicio de sesión keyboard-interactive, solo root puede entrar (no hay otras cuentas que necesiten SSH), muy pocos intentos y sesiones por conexión, poco tiempo para autenticarse, sin reenvío de puertos, X11 ni túneles, y una lista corta de algoritmos modernos de intercambio de claves, cifrado y MAC. LogLevel VERBOSE registra la huella de la clave usada en cada inicio de sesión.
Gana el primer valor
Una lección de dos servidores. sshd_config incluye /etc/ssh/sshd_config.d/*.conf al principio, y en sshd gana el primer valor de una palabra clave. La imagen del proveedor para los servidores de Nextcloud deja ahí un 50-cloud-init.conf que dice PasswordAuthentication yes – y se imponía en silencio al «no» del archivo principal. El propio 50-redhat.conf de AlmaLinux hacía lo mismo con el reenvío de X11 en los tres servidores. La solución es un drop-in llamado 00-hardening.conf: se ordena primero, así que sus valores ganan, añada lo que añada una imagen después.
El sandbox alrededor de sshd
Un drop-in de systemd restringe el servicio sshd – y todo lo que se arranca desde él hereda las restricciones, cada sesión SSH y cada tarea de Ansible incluidas. Ese es el propósito y el riesgo a la vez:
[Service]
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictRealtime=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINKNadie que entre por SSH puede cargar un módulo del kernel ni cambiar un parámetro del kernel – tampoco un atacante con una clave robada. Los cambios legítimos pasan por systemd-run, que explicó la parte 2. El comentario encima del drop-in enumera lo que se probó y se retiró, y cada retirada es una lección:
AF_NETLINKtiene que estar permitido: sin él,nft,ipyssfallan en cada sesión SSH – también cuando intentas reparar el cortafuegos.- Sin
RestrictSUIDSGID: bloqueaba poner bits setuid incluso a root, y fallaban las instalaciones de paquetes que traen esos archivos. - Sin
PrivateTmp: el/tmpprivado de sshd lo hereda cada sesión, así que los archivos dejados en/tmppor SSH eran invisibles para cron y otros servicios, y cada reinicio de sshd lo destruía bajo las sesiones en marcha –dnffalló dos veces por eso.
La regla general que deja escrita el rol: el sandbox por servicio corresponde a servicios que no inician sesiones. El drop-in de MariaDB de la parte 4 es el ejemplo de hacerlo por completo.
Reiniciar el servidor a través del que estás conectado
La parte más delicada del rol es su cadena de handlers. Reiniciar sshd mata la conexión por la que funciona Ansible. Los handlers, en el orden en que están definidos (que es el orden en que se ejecutan):
| Handler | Qué hace |
|---|---|
Validate sshd config | sshd -t – una configuración rota detiene el play antes de que sshd se reinicie con ella |
Reload systemd units for sshd | volver a leer el drop-in; sin esto, systemd reiniciaría sshd a partir de su unidad antigua en caché |
Reload sshd | el reinicio – con async: 30, poll: 0: lanzarlo y no esperar una respuesta que nunca puede llegar por un canal que se muere |
Reset connection after sshd restart | meta: reset_connection: tirar la conexión SSH en caché, ya muerta |
Confirm sshd is accepting connections again | wait_for_connection: volver a conectar; si sshd no volvió, el play falla aquí – de forma visible, mientras probablemente aún tienes otra sesión abierta |
Es un reinicio y no una recarga a propósito: systemd solo aplica los ajustes de sandbox al arrancar un proceso. Una recarga enviaría una señal al sshd en marcha, y los cambios en el drop-in nunca surtirían efecto. Antes de añadir async, la tarea de reinicio moría con «Module result deserialization failed» – la respuesta del módulo nunca llegaba porque su conexión ya no existía.
Una limitación está documentada en el rol: el sshd_config principal solo notifica al handler de validación. Un cambio en ese único archivo se comprueba, pero solo entra en vigor con el siguiente reinicio de sshd – por un cambio de drop-in o un reboot. Los drop-ins, que llevan los ajustes críticos para la seguridad, reinician sshd por sí mismos.
El rol termina con la alarma de inicio de sesión (STEP 8), que tiene su propio artículo, un banner de aviso y un /etc/issue sustituido que ya no le dice a nadie en la pantalla de inicio de sesión qué sistema operativo y qué versión del kernel se ejecutan aquí.
🚫 common_fail2ban: reaccionar a lo que dicen los logs
fail2ban lee archivos de log, reconoce ataques con expresiones regulares (filtros), los cuenta por dirección y, tras demasiados aciertos en una ventana de tiempo, ejecuta una acción – aquí: meter la dirección en el set de bloqueo del cortafuegos. Siete jails combinan un filtro, un log y unos límites:
| Jail | Vigila | Reacciona a |
|---|---|---|
sshd | el journal del sistema | inicios de sesión SSH fallidos |
nginx-k3s-wp-login | el log del Ingress | inicios de sesión de WordPress fallidos |
nginx-k3s-nc-login | el log del Ingress | inicios de sesión de Nextcloud fallidos |
nginx-k3s-grafana-login | el log del Ingress | inicios de sesión de Grafana fallidos (más estricto) |
nginx-k3s-scanner | el log del Ingress | ráfagas de peticiones típicas de los escáneres de vulnerabilidades |
nginx-k3s-bad-paths | el log del Ingress | rutas que aquí nunca son legítimas – /.env, /.git/, web shells, herramientas de administración que no existen – con tolerancia casi nula y un bloqueo largo |
recidive | el propio log de fail2ban | direcciones bloqueadas una y otra vez: un bloqueo mucho más largo en todos los puertos |
Los jails HTTP leen el log del controlador Ingress directamente en el host – nginx se ejecuta en un pod, pero su log acaba en /var/log/pods/, en un formato con un prefijo de containerd que los filtros propios tienen en cuenta. Eso crea un problema propio: el nombre del archivo de log cambia con cada reinicio del contenedor, y fail2ban solo resuelve la ruta al arrancar un jail. Tras un reboot, fail2ban arrancaba antes que el contenedor del Ingress y luego seguía un archivo muerto – los jails HTTP no bloquearon nada durante días. Un pequeño script con un temporizador de systemd recarga ahora esos jails cada vez que cambia el conjunto de archivos de log; su estado vive en /run, así que la primera comprobación tras cada arranque siempre recarga.
La acción de bloqueo – y un fallo encontrado al escribir esto
[Definition]
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; true
[Init]
nftset = banned4
[Init?family=inet6]
nftset = banned6La acción escribe la dirección directamente en el set del cortafuegos, con el tiempo de bloqueo como tiempo de espera del elemento – nftables la elimina por sí mismo; el desbloqueo es solo limpieza. Las dos secciones [Init] eligen el set según la familia de direcciones. Esa parte es nueva: al leer el rol para este artículo, me di cuenta de que los jails pasaban nftset=banned4 para todas las direcciones. Así que cada bloqueo IPv6 intentaba meter una dirección IPv6 en un set IPv4, y nft se negaba con «Address family for hostname not supported» – en los tres servidores. fail2ban registraba «Ban», el cortafuegos dejaba entrar la dirección, banned6 seguía vacío. La corrección se probó contra una copia de la configuración en producción (fail2ban-client -t y -d, y el comando nft en modo comprobación) antes de entrar en el repositorio. El detector de escaneos de puertos del propio cortafuegos nunca se vio afectado – maneja él mismo ambas familias. La lección para operadores: una línea de log que dice «Ban» no prueba que se haya bloqueado nada. Mira el set.
Dos detalles menores. El propio log de fail2ban tiene que existir antes de su primer arranque, porque el jail recidive lo lee y fail2ban comprueba todos los logs antes de crear el suyo – un servidor nuevo falló exactamente así. El rol lo crea con state: touch y modification_time: preserve, para que la tarea no sea «changed» en cada ejecución. Y el único handler reinicia fail2ban una vez al final del play, cambien los archivos que cambien – lo que además vuelve a aplicar todos los bloqueos activos.
Añadido el 19/10/2026: el inicio de sesión de WordPress está ahora detrás de la misma lista de países que protege SSH. nginx en el pod del blog responde con 403 a /wp-login.php y /wp-admin/ para direcciones de fuera de Alemania – antes de que se ejecute PHP, así que los intentos de fuerza bruta desde el extranjero no llegan ni a WordPress ni a fail2ban. ssh-geo-update escribe los mismos bloques de RIPE como fichero geo de nginx y recarga nginx tras su actualización semanal; admin-ajax.php y admin-post.php siguen abiertos porque los usan funciones para visitantes. Iniciar sesión desde el extranjero requiere una VPN con salida en Alemania o una entrada en fw_ssh_geo_extra.
🛡️ common_cis_hardening: el benchmark, aplicado con criterio
El CIS Benchmark es una larga lista de comprobación para endurecer un sistema operativo. Pasarlo sobre estos servidores produjo más de cien hallazgos. Este rol aplica los que son seguros – y su cabecera empieza con una lista de lo que deliberadamente no hace, cada punto con su motivo. Esa lista es la parte más útil para quien opere una instalación parecida:
- Cambios en PAM (calidad de contraseñas, bloqueo tras fallos): PAM nunca estuvo bajo la herramienta de gestión que da por supuesta el benchmark; activarla reescribe la pila de autenticación, y un error ahí bloquea todos los inicios de sesión locales. SSH funciona de todos modos solo con claves.
- Una contraseña para el cargador de arranque: protege contra el acceso físico, que un VPS no tiene, y complica la consola de rescate del proveedor.
- Una política criptográfica propia: cambia los algoritmos de TLS y SSH en todo el sistema y podría romper cosas difíciles de probar.
- Reenvío IP y filtrado de ruta inversa: los necesita Kubernetes, ver la parte 2.
Lo que sí aplica, cada punto un pequeño grupo de tareas:
- AIDE, un comprobador de integridad de archivos: mantiene una base de datos de sumas de comprobación y atributos de los archivos del sistema e informa de cada diferencia. El rol lo instala, excluye los directorios de logs de los pods (cambian con cada arranque de pod y no son objetivo de manipulación), construye una vez la primera base de datos (
stat→when: not ... exists, mediantesystemd-runporque el sandbox de SSH oculta partes del sistema de archivos) y programa dos tareas cron: la comprobación a medianoche, que solo envía correo si encuentra algo, y una renovación de la referencia a las 04:00 – después de las actualizaciones nocturnas y de la ventana de reinicio, para que una actualización del kernel instalada de noche no aparezca la medianoche siguiente como miles de «archivos cambiados». Esa falsa alarma ocurrió una vez, justo con una actualización así del kernel. - Los módulos del kernel para sistemas de archivos antiguos y almacenamiento USB se bloquean (
install <module> /bin/false). - journald guarda los logs de forma persistente y comprimida, con un tamaño máximo; un handler lo reinicia cuando cambia ese archivo.
- Sin volcados de memoria: el volcado de un proceso privilegiado puede contener contraseñas y tokens de sesión.
- cron y at: permisos restrictivos, y solo root puede usarlos.
- sudo: un pseudoterminal, un archivo de log y sin credenciales en caché – escrito con
validate: "visudo -cf %s". Es la red de seguridad más importante del rol: un archivo sudoers roto puede dejar sudo inservible, y la validación garantiza que una versión rota nunca reemplace a la real. - El usuario por defecto de la imagen: la imagen del proveedor para los servidores de Nextcloud crea un usuario con sudo sin contraseña. El rol elimina esa línea de sudo (de nuevo con validación de
visudo) y no le da al usuario shell de inicio de sesión. No puede borrar el usuario: el mismo identificador numérico lo usa un proceso dentro de un pod, así que el host muestra un proceso en marcha que le «pertenece».getentlee primero la base de datos de usuarios, y(getent_passwd | default({})).get('almalinux') is not nonees una forma segura de preguntar «¿existe este usuario?» sin error si no existe. - Servicios y paquetes sin usar: Bluetooth, el demonio de rsync (pero no el cliente rsync, que necesitan las copias de seguridad – una versión anterior eliminó el paquete entero y las rompió), la pila cliente de NFS/Samba que trae la imagen cloud, y cloud-init tras el aprovisionamiento.
- Opciones de montaje:
/tmp,/var/tmpy/dev/shmconnodev,nosuid,noexec– desde ahí no se puede ejecutar nada.ansible.posix.mountconstate: mountedescribe la línea en/etc/fstaby vuelve a montar al momento;presentsolo escribiría el archivo. Por eso el instalador de K3s de la parte 2 se lanza comosh /tmp/k3s-install.shen lugar de ejecutarse directamente.
🏷️ common_selinux: etiquetas que contienen una intrusión
SELinux da a cada archivo y a cada proceso un tipo, y la política dice qué tipos de proceso pueden tocar qué tipos de archivo. Un proceso de servidor web se ejecuta con un tipo y puede leer contenido web, pero no, por ejemplo, claves SSH – aunque se ejecute como root y aunque los permisos de los archivos lo permitieran. Para los contenedores la regla es sencilla: los procesos de contenedor se ejecutan como container_t y solo pueden escribir archivos de tipo container_file_t. Un contenedor que escapa de su propio sistema de archivos se encuentra un host donde casi nada lleva esa etiqueta.
Descubierto mientras escribía esta parte (19/10/2026): durante semanas, esa regla solo era cierta a medias aquí. SELinux estaba en enforcing y los directorios llevaban container_file_t, pero K3s arranca su runtime de contenedores sin soporte de SELinux mientras su configuración no diga selinux: true, y no lo decía. Cada pod se ejecutaba como spc_t o unconfined_service_t, sin ningún confinamiento. Un vistazo a ps -eZ lo mostró; ninguna comprobación lo había preguntado nunca. common_k3s instala ahora la política k3s-selinux, activa la opción y reinicia una vez las cargas. Desde entonces cada proceso de aplicación se ejecuta como container_t con sus propias categorías MCS; ya ningún pod se ejecuta como spc_t. La lección para cualquier instalación de SELinux: comprobar el dominio de los procesos en marcha, no solo el modo y las etiquetas.
Por eso también importa el orden del playbook (parte 1): los directorios del host que se montan en los pods deben llevar container_file_t antes de que arranquen los pods. Este rol los crea – con los propietarios que esperan los contenedores, según el perfil de la pila – y los etiqueta:
- name: SELinux | Read the local file-context rules
ansible.builtin.command: semanage fcontext -l -C
register: semanage_fc_local
changed_when: false
- name: SELinux | Label the HostPath directories as container_file_t
ansible.builtin.command: >
semanage fcontext -a -t container_file_t "{{ item.path }}(/.*)?"
loop: "{{ selinux_profiles[stack] }}"
when: >-
(item.path ~ '(/.*)?') not in
(semanage_fc_local.stdout_lines | select | map('split') | map('first') | list)semanage fcontext -a añade una regla a la política – «esta ruta y todo lo que hay debajo es container_file_t» – que sobrevive a un reetiquetado completo del sistema; restorecon -R aplica después la regla a los archivos que ya existen, y los archivos nuevos la heredan. La lógica de «leer primero» tiene una historia reciente: una actualización de las herramientas de SELinux dejó de fallar ante una regla existente y pasó a imprimir «already defined, modifying instead» en la salida normal – la comprobación antigua buscaba el error e informaba «changed» en cada ejecución. Ahora el rol lee las reglas locales (-C), toma la primera columna de cada línea con map('split') | map('first') y solo añade lo que falta.
Los argument_specs del rol muestran una función que conviene conocer: stack se declara con choices: [wordpress, nextcloud] – una errata en la variable de play del playbook falla antes de la primera tarea. El rol también pone SELinux en enforcing con el módulo de ansible.posix (el antiguo nombre integrado solo funciona ya a través de una redirección), y es honesto sobre un ajuste: un booleano cuyo comentario afirmaba antes que permitía conexiones salientes. No es así – las conexiones salientes funcionan sin él –, y si algo lo sigue necesitando es una pregunta abierta, anotada en lugar de adivinada.
🔎 Detección: auditd, AIDE, rkhunter, ClamAV
Las capas anteriores intentan mantener fuera a un atacante. Estas cuatro dan por hecho que alguien entró – o que cambió algo que no debía cambiar – y se aseguran de que se note. Su regla común: silencio cuando todo está bien, un correo cuando no. Un detector que manda correo cada noche te acostumbra a ignorarlo.
common_auditd: el log de auditoría del kernel, y cuatro claves que envían correo
auditd registra lo que se le dice al kernel que vigile. El archivo de reglas vigila las escrituras en los archivos de usuarios y grupos, la configuración de sudo, la configuración de SSH, cron, la configuración de red, la propia configuración de la auditoría y, de K3s, la configuración, los tokens, los certificados y el directorio de autodespliegue; y registra cada ejecución de las herramientas de carga de módulos y de sudo. Cada regla tiene una clave, así que ausearch -k <key> encuentra los eventos después. Un handler compila las reglas en el kernel con augenrules --load cuando cambia el archivo.
auditd solo registra. Un pequeño script, cada diez minutos desde cron, convierte cuatro claves en correos: el directorio de autodespliegue de K3s (K3s aplica todo lo que se escriba ahí – el sitio perfecto para una puerta trasera en el clúster, y este repositorio nunca escribe ahí), el token del clúster, los certificados del clúster y la base de datos de usuarios. Un punto de control por clave garantiza que cada evento se notifique exactamente una vez. Las claves ruidosas – sudo, módulos, configuración de SSH, cron – a propósito no se envían por correo: saltan durante la administración normal y las ejecuciones de Ansible, y siguen disponibles para investigar.
Actualización (19/10/2026): cada ejecución de AIDE generaba una serie de denegaciones de SELinux, porque AIDE llama a statfs() sobre el /boot/efi en vfat aunque esté excluido. Un módulo de política local de una sola regla, aide_dosfs (allow aide_t dosfs_t:filesystem getattr;), construido con checkmodule/semodule_package y cargado solo cuando cambia su fuente, acabó con ello: los hosts ya no registran ningún AVC.
common_aide_refresh: el último rol de cada ejecución
Cada ejecución del playbook cambia archivos que vigila AIDE – basta una actualización de paquetes. Sin una referencia nueva, la siguiente comprobación de medianoche informa de los propios cambios de la ejecución como una intrusión. Así que el último rol de cada playbook renueva la referencia, pero solo si algo cambió de verdad:
- name: AIDE refresh | Compare with the baseline and write the new database (one scan)
ansible.builtin.command: systemd-run --wait --pipe --quiet --collect -- aide --update
register: aide_update
changed_when: false
failed_when: false
- name: AIDE refresh | Activate the new baseline (differences were found)
ansible.builtin.command: mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
changed_when: true
when:
- aide_update.rc is defined
- aide_update.rc in range(1, 8)
- aide_db_new.stat.existsEl código de salida de AIDE es una máscara de bits: 1 significa que se añadieron archivos, 2 que se eliminaron, 4 que cambiaron – así que de 1 a 7 significa «diferencias encontradas y registradas», lo normal justo después de una ejecución del playbook. 0 significa idéntico (la base de datos nueva se descarta), y 14 o más es un error (se notifica y la referencia no se toca). range(1, 8) es Jinja para 1 a 7 – el final queda excluido. failed_when: false es esencial: la primera versión trataba un código distinto de cero como fallo y abortaba el play antes de poder activar la base de datos nueva. Solo el mv informa «changed», así que una referencia renovada aparece exactamente una vez en el resumen. Y un solo escaneo hace a la vez la comparación y la base de datos nueva – la versión antigua lo escaneaba todo dos veces.
Aquí hay un compromiso que un operador debe entender: renovar la referencia acepta todas las diferencias actuales – incluidas las que no causó el playbook. Esas las atrapa la comprobación de medianoche anterior a la ejecución, y por eso importan sus correos.
common_rkhunter: rootkits, cada noche
rkhunter compara los binarios del sistema con una base de datos guardada de sus propiedades y busca rastros de rootkits conocidos. El rol fija sus opciones con lineinfile en un bucle sobre pares clave/valor, construye la base de datos (--propupd) y programa una comprobación nocturna que solo informa de avisos. Dos detalles:
- Un pequeño plugin de dnf, que el rol escribe en el directorio de plugins de dnf, ejecuta
--propupddespués de cada transacción de paquetes – tanto las actualizaciones automáticas nocturnas como las manuales. Sin él, cada actualización sería un aviso de «archivo cambiado» la noche siguiente. - El paquete trae su propio script cron diario, así que cada servidor hacía dos escaneos por noche. Solo borrarlo no duraría – la siguiente actualización del paquete lo devolvería. Una macro de rpm de una línea,
%_netsharedpath, le dice a rpm que no vuelva a instalar nunca esa ruta.
common_clamav: malware en los archivos de otros
Un escaneo nocturno de ClamAV de lo que suben los usuarios – los medios del blog, los datos de usuario de Nextcloud – con baja prioridad de CPU y disco, que solo envía correo si encuentra algo y nunca borra nada por su cuenta. La prueba de que de verdad encuentra cosas es un artículo más antiguo. El rol elige sus ajustes de un perfil, como common_selinux, y tiene un bonito fragmento de Jinja: la lista de extensiones de medios que se omiten se convierte, letra a letra, en un patrón que no distingue mayúsculas – jpg se convierte en [jJ][pP][gG] – porque el escáner no tiene opción para exclusiones sin distinguir mayúsculas. En Nextcloud se omiten las vistas previas generadas (se crean a partir de archivos que se escanean de todos modos, y con ellas un escaneo completo tardaba dieciséis horas), y el escaneo se ejecuta a las 04:30, después de la ventana de reinicio – antes, el reinicio lo mataba cada noche de actualizaciones.
📦 Las capas de otras partes, de un vistazo
| Capa | Dónde | En resumen |
|---|---|---|
| Endurecimiento de red del kernel | parte 2, common_sysctl | sin redirecciones ICMP, sin enrutamiento por origen, SYN cookies, ASLR, ptrace restringido, log del kernel solo para root, RFC 1337, archivos protegidos en /tmp |
| Endurecimiento de los pods | partes 3 y 4 | todas las capabilities retiradas y solo devueltas las necesarias, sin escalada de privilegios, sistemas de archivos raíz de solo lectura, usuarios no root donde es posible, sin tokens de service account |
| NetworkPolicies | partes 3 y 4 | base de datos y caché solo desde la aplicación; Collabora aislado de ambas |
| Política de imágenes | partes 3 y 4 | solo pueden ejecutarse repositorios de imágenes aprobados |
| Pod Security Standards | partes 3 y 4 | avisar y auditar, no imponer – los directorios del host serían rechazados |
| Log de auditoría del clúster | parte 2 | quién hizo qué en Kubernetes, con los secretos registrados solo como metadatos |
| Alarma de inicio de sesión | artículo propio | un correo por cada inicio de sesión SSH interactivo |
🛠️ En operación: la seguridad en el día a día
Echar un vistazo
# fail2ban: which jails, how many bans
fail2ban-client status
fail2ban-client status nginx-k3s-bad-paths
# What is really blocked right now (fail2ban and the port-scan detector)
nft list set inet filter banned4
nft list set inet filter banned6
nft list set inet filter scanban4
# Audit events of one key
ausearch -k k3s_manifests -i
# The whole firewall as it is loaded
nft list table inet filterCorreos de esta parte
| Correo | Significado | Qué hacer |
|---|---|---|
| Alarma de inicio de sesión SSH | Alguien inició sesión de forma interactiva | Si no fuiste tú: actúa ya – revisa last -i, cambia las claves |
| AIDE encontró cambios de integridad | Archivos del sistema difieren de la referencia | Lee la lista. Tras un cambio planificado: esperado. Si no: investiga antes de que la siguiente ejecución del playbook renueve la referencia |
| Evento de auditd (k3s_manifests, token, tls, identity) | Algo que nunca debería cambiar ha cambiado | Tratarlo como un incidente hasta que se explique |
| Avisos de rkhunter | Un binario o un ajuste parece sospechoso | A menudo un cambio de paquete – compáralo con las actualizaciones recientes; si no, investiga |
| ClamAV encontró algo | Un archivo de las subidas o de los datos de usuario coincidió con una firma | Mira el archivo y quién lo subió; ClamAV no ha borrado nada |
Cuando te quedas fuera
- Bloqueaste tu propia dirección:
fail2ban-client set <jail> unbanip <address>desde otra conexión, o espera a que caduque el bloqueo. Para el bloqueo por escaneo de puertos:nft delete element inet filter scanban4 { <address> }. Nunca pruebes rutas de ataque desde la máquina desde la que administras – me bloqueé a mí mismo dos veces. - Fuera del país permitido: añade tu dirección a
fw_ssh_geo_extray ejecuta el rol del cortafuegos desde dentro de la zona permitida – o usa la consola web del proveedor. - SSH no vuelve tras un cambio: la consola web del proveedor con la contraseña de root. Por eso dice la parte 2: prueba la consola antes de necesitarla.
🧭 Lo que viene
La parte 6 cierra la visita con la monitorización: los exporters, Prometheus, Grafana con sus alarmas y el perro guardián Monit – qué vigila cada uno y qué significan sus correos.

