
🌐 También en: English · Deutsch · Français
Deep Dive de la serie en torno al Ansible Tour. Nivel: administración Linux avanzada. El Tour explica los roles; los deep dives explican la tecnología que hay debajo – cómo funciona, por qué está aquí, qué hace exactamente esta instalación con ella, cómo se opera en el día a día y qué otras opciones habría habido.
nftables sustituyó a iptables en el kernel hace años, pero la mayoría de las guías siguen traduciendo las viejas reglas de iptables línea por línea y ahí se quedan. Eso funciona hasta que un segundo programa también escribe reglas – y en un host K3s lo hacen varios. Este artículo explica el modelo de datos de nftables con los detalles que importan en la práctica, cómo el rol common_firewall construye una tabla para el host, fail2ban, la detección de escaneos y el filtro SSH por países, qué pone K3s al lado, y cuatro fallos de los últimos meses que enseñaron cada uno uno de esos detalles por las malas. La migración en sí se describe en un artículo anterior, el filtro por países en otro; este trata de cómo encaja todo.
🧭 Cómo decide nftables
Tablas, cadenas, hooks
En iptables, las tablas (filter, nat, mangle) y sus cadenas eran fijas. En nftables, una tabla es solo un espacio de nombres que uno crea, y pertenece a una familia de direcciones: ip, ip6, inet (ambas), arp, bridge, netdev. Una cadena solo ve paquetes si es una cadena base – enganchada a un hook de netfilter (prerouting, input, forward, output, postrouting) con una prioridad y una política. A las cadenas normales sin hook solo se llega con jump o goto.
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
...
}
}Prioridades: varias tablas en un mismo hook
En el mismo hook pueden colgar tantas cadenas base como se quiera, de tantas tablas como se quiera. El kernel las ejecuta por orden de su número de prioridad – la menor primero: raw es −300, el seguimiento de conexiones corre en −200, mangle −150, dstnat −100, filter 0, srcnat 100. De ahí salen dos hechos fáciles de pasar por alto:
- Un accept solo es definitivo para su propia cadena. Si nuestra cadena
inputacepta un paquete, la siguiente cadena base del mismo hook – por ejemplo la de K3s – lo sigue viendo y puede descartarlo. Solo un drop termina el recorrido del paquete en todas partes. - Importa dónde está una cadena respecto a conntrack. Una cadena en −300 corre antes del seguimiento de conexiones y no puede saber si un paquete es una respuesta. Una en −150 corre después y puede usar
ct state. Uno de los fallos de abajo vino justo de esta diferencia.
Sets y maps
Los sets son colecciones tipadas (ipv4_addr, ipv6_addr, números de puerto, concatenaciones) guardadas en el kernel y consultadas en tiempo constante. Con flags interval contienen rangos, con flags timeout cada elemento caduca por sí solo, y con flags dynamic la propia regla puede añadir elementos desde la ruta del paquete (add @set, update @set). Eso es lo que hizo innecesario ipset: una lista de bloqueo con caducidad de 24 horas son tres líneas.
set banned4 {
type ipv4_addr
flags timeout
timeout 1d
}
...
ip saddr @banned4 dropSustitución atómica – y por qué «flush ruleset» es peligroso
nft -f fichero aplica todo el fichero como una única transacción: o se carga todo o nada, y no hay ningún momento sin reglas. Muchas configuraciones de ejemplo empiezan con flush ruleset. En un host donde solo un programa escribe reglas, no pasa nada. En un host donde kube-proxy, flannel y el controlador de NetworkPolicy de Kubernetes mantienen sus propias tablas, borra también sus reglas – y no lo notan hasta su siguiente sincronización, segundos o minutos después.
iptables-nft e iptables-legacy
La mayoría de los componentes de Kubernetes siguen hablando iptables. En las distribuciones actuales, el comando iptables es en realidad iptables-nft: la misma sintaxis, pero escribe en tablas de nftables llamadas ip filter, ip nat, etc., con una capa de compatibilidad para los viejos módulos de coincidencia. El otro backend, iptables-legacy, escribe en las antiguas estructuras x_tables del kernel. Ambos pueden estar activos a la vez – y entonces ni nft list ruleset ni iptables-legacy -L muestran el cuadro completo.
🛡️ Por qué está aquí
- Un único fichero legible para IPv4 e IPv6 (
table inet), versionado en Git, generado desde una plantilla. - Drop por defecto en ambos sentidos. De entrada solo web, SSH y lo que necesita K3s; de salida solo DNS, NTP, HTTP(S), envío de correo y el tráfico propio del clúster.
- Listas de bloqueo en el kernel con caducidad para fail2ban, la detección de escaneos y la lista SSH por países – ningún demonio en la ruta de los paquetes.
🔧 Qué hace este repositorio
Una tabla propia
El rol genera fw.nft.j2 en /etc/sysconfig/nftables.conf – el fichero que la unidad nftables de AlmaLinux lee realmente (no /etc/nftables.conf). El fichero empieza así:
table inet filter
delete table inet filter
table inet filter {
...
}La primera línea crea la tabla si no existe (para que el delete no falle en un arranque limpio), la segunda la borra con todo su contenido y después se reconstruye – todo en una transacción. Solo se sustituye nuestra tabla; las de K3s quedan intactas. Un drop-in de systemd acota la unidad de la misma manera: ExecReload carga el fichero, ExecStop ejecuta nft delete table inet filter en lugar del flush por defecto de la distribución. Antes de que el fichero sustituya al activo, Ansible valida el candidato generado con nft -c -f; un error de sintaxis nunca llega al cortafuegos.
La cadena input, por orden
- Loopback, established/related, descartar invalid; descartar paquetes XMAS y NULL, responder con reset a SYN+ACK en conexiones nuevas, limitar los RST.
- Primero las listas de bloqueo:
banned4/6(fail2ban, 24 h) yscanban4/6(escáneres de puertos, 1 h) – antes de cualquier accept, para que una dirección bloqueada no llegue a nada. - Excepciones de K3s: los pods pueden llegar al API server y al kubelet (6443, 10250), al HTTP(S) del propio host para la autocomprobación de cert-manager, y en los hosts Nextcloud a MariaDB y Redis en el host.
- Descartar rangos de origen privados y reservados desde fuera, permitir el ICMP útil y los tipos ICMPv6 sin los que IPv6 no funciona.
- Web en 80/443 (sin UDP 443 – el controlador ingress no soporta HTTP/3), SSH en 10022 solo desde la lista de países.
- Detección de escaneos de puertos: lo que aún llega aquí va a chocar con el drop de la política – un puerto cerrado. Un origen que envía más de 30 SYN por minuto a puertos cerrados acaba en
scanban4por la propia regla. - Línea de log limitada, drop de la política.
tcp flags & (fin|syn|rst|ack) == syn tcp dport != 22 \
update @portscan4 { ip saddr limit rate over 30/minute burst 30 packets } \
add @scanban4 { ip saddr } dropfail2ban no puede hacer esto: solo lee logs, y el log de descartes está limitado a propósito a dos líneas por minuto. El puerto 22 queda excluido para que olvidar unas cuantas veces -p 10022 nunca deje fuera al administrador.
fail2ban escribe en los mismos sets
fail2ban no tiene tabla propia. Su acción son dos líneas:
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; trueCada bloqueo lleva su propia caducidad y expira en el kernel aunque fail2ban no esté corriendo. En el blog, al escribir esto, banned4 tenía 22 direcciones y la lista SSH ssh_geo4 7.498 rangos de direcciones alemanas (con flags interval y auto-merge, reconstruida cada semana a partir de datos de RIPE). Desde el 19 de octubre de 2026, la misma lista protege también el inicio de sesión de WordPress: ssh-geo-update la escribe una segunda vez como fichero geo de nginx, y nginx en el pod del blog responde con 403 a /wp-login.php y /wp-admin/ para todo lo que quede fuera. Desde el 20 de octubre de 2026 la misma lista llega también, en cada servidor, al ingress controller, que responde con 403 en /grafana fuera de ella.
Lo que K3s escribe al lado
En el blog, nft list tables muestra siete tablas. Solo inet filter es nuestra (49 reglas). Las demás vienen de K3s a través de iptables-nft: ip filter con unas 190 reglas – KUBE-SERVICES, KUBE-NODEPORTS, KUBE-PROXY-FIREWALL de kube-proxy y, del controlador de NetworkPolicy, KUBE-ROUTER-*, KUBE-NWPLCY-* y una cadena KUBE-POD-FW-* por pod –, ip nat con unas 120 (DNAT de los Services, masquerading de flannel) y pequeñas tablas ip6. Nuestra cadena forward solo tiene que dejar pasar la red de pods; si un pod puede hablar con otro se decide en las cadenas de K3s en el mismo hook.
🐛 Cuatro fallos, cuatro detalles
1. «flush ruleset» rompía los Services
La primera versión de la plantilla empezaba con flush ruleset. Cada recarga del cortafuegos borraba también las reglas de kube-proxy y flannel; Nextcloud perdía durante 30 a 90 segundos la conexión con MariaDB y Redis a través de la ClusterIP, hasta que kube-proxy volvía a sincronizar. La solución es el delete limitado a la tabla que se ve arriba.
2. Una recarga desbloqueaba a todos en silencio
Sustituir nuestra tabla sustituye también los sets de bloqueo – vacíos. fail2ban no se enteraba y seguía teniendo las direcciones como «bloqueadas» en su propia base de datos mientras el cortafuegos las volvía a dejar pasar. Descubierto el 30 de septiembre de 2026: tras una ejecución del playbook, 15 de 17 bloqueos en uno de los servidores Nextcloud y 17 de 24 en el otro solo existían ya en fail2ban. El handler del cortafuegos reinicia ahora fail2ban justo después de cada recarga (systemctl try-restart fail2ban), y fail2ban vuelve a aplicar desde su base todos los bloqueos aún activos.
3. La cadena de NodePort descartaba las respuestas a nuestro propio tráfico
kube-proxy hace accesible cada NodePort (30000–32767) en cada dirección local – también en la pública –, y ese tráfico se reescribe por DNAT hacia un pod y pasa después por la cadena forward, no por input. Los exportadores eran accesibles desde Internet. La primera solución fue una cadena con prioridad raw (−300) que descartaba todo lo dirigido a ese rango en la dirección pública. Funcionaba – y descartaba también alrededor del 4 % de las respuestas a conexiones salientes de los pods: flannel enmascara el tráfico de los pods con --random-fully sobre todo el rango de puertos, así que algunas conexiones recibían un puerto de origen entre 30000 y 32767, y su SYN-ACK se tiraba antes de que conntrack pudiera reconocerlo como respuesta. El síntoma durante dos meses: timeouts esporádicos hacia GitHub y tiendas de aplicaciones desde cada host K3s. La cadena corre ahora en −150, después de conntrack, y solo descarta ct state new.
chain prerouting {
type filter hook prerouting priority -150; policy accept;
ip daddr <public-ipv4> tcp dport 30000-32767 ct state new drop
ip6 daddr <public-ipv6> tcp dport 30000-32767 ct state new drop
}4. El motor de políticas que no aplicaba nada, y un bloqueo de módulos demasiado amplio
Durante dos meses, Calico corrió junto a K3s como supuesto motor de NetworkPolicy. Una prueba deny-all el 19 de octubre de 2026 mostró que nunca había coincidido con nada: Felix, el componente de Calico, solo gestiona interfaces cali* – las de su propio CNI –, mientras que Flannel en K3s crea veth*. Toda la aplicación de políticas siempre la había hecho el controlador integrado de K3s (las cadenas KUBE-NWPLCY-*). Calico se retiró; el blog, que hasta entonces usaba iptables-legacy, pasó a solo nftables, y los módulos de tablas legacy se bloquearon con install … /bin/false en modprobe.d. La primera versión de ese bloqueo incluía también ip_tables e ip6_tables – tras el siguiente reinicio, el controlador de políticas de K3s entró en pánico, porque iptables-nft sigue usando la vieja coincidencia icmp a través de la capa de compatibilidad, y esta necesita esos módulos. El blog respondió con 502 durante unos cinco minutos. Ahora solo están bloqueados los módulos de tablas iptable_* e ip6table_*.
🧰 Administración en el día a día
# solo nuestra tabla (el conjunto completo incluye ~300 reglas de K3s)
nft list table inet filter
# listas de bloqueo: quién, y cuánto tiempo queda
nft list set inet filter banned4
nft list set inet filter scanban4
# ¿está una dirección en la lista SSH?
nft get element inet filter ssh_geo4 '{ 203.0.113.7 }'
# levantar un bloqueo (el registro de fail2ban: fail2ban-client unban <ip>)
nft delete element inet filter banned4 '{ 203.0.113.7 }'
# lo que ha escrito K3s
nft list tables
iptables-nft-save | grep -c '^-A'
# seguir un paquete por todas las cadenas (primero una regla de traza)
nft add rule inet filter prerouting ip saddr 203.0.113.7 meta nftrace set 1
nft monitor trace
nft flush chain inet filter prerouting # quita la regla de traza - el rol restaura la cadena en la siguiente ejecuciónCambios a mano y la siguiente ejecución de Ansible
Los elementos añadidos a mano a banned4 se mantienen hasta que caducan – pero solo hasta la siguiente recarga, que reconstruye la tabla. Las reglas añadidas a mano desaparecen en la siguiente ejecución, a propósito: la plantilla es la verdad. Las excepciones permanentes van en la plantilla o en el inventario (por ejemplo fw_ssh_geo_extra para una dirección fuera de la lista de países).
Leer el log de descartes
Los paquetes descartados se registran con los prefijos nftables DROP IN:, DROP FWD: y DROP OUT:, como mucho dos por minuto y cadena. Desde octubre de 2026 se pueden buscar en Loki: {job="journal", transport="kernel"} |= "nftables DROP". La línea interesante es la DROP OUT – significa que algo en el servidor intentó conectarse a un sitio al que no tiene permitido.
🔭 Lo que viene
- Contadores en Prometheus. Contadores con nombre en las reglas de descarte y el tamaño de los sets como métricas mostrarían el volumen de ataques y el crecimiento de las listas de bloqueo con el tiempo.
- Flowtables para la ruta forward podrían descargar las conexiones establecidas de los pods – innecesario con este nivel de tráfico.
- Menos reglas de K3s: kube-proxy tiene su propio modo nftables; en cuanto K3s lo entregue por defecto, la capa iptables-nft podrá desaparecer.
⚖️ Alternativas – y por qué nftables a secas
| Opción | Punto fuerte | Por qué no aquí |
|---|---|---|
| iptables + ipset | Lo que aún hablan la mayoría de los componentes de Kubernetes | Dos herramientas, conjuntos de reglas separados para IPv4/IPv6, sin sustitución atómica del fichero completo. |
| firewalld | Zonas, runtime frente a permanente, CLI cómoda | Es dueño del conjunto de reglas y lo reescribe; en hosts K3s es conocido por chocar con kube-proxy. El rol lo elimina. |
| eBPF (Cilium, XDP) | Muy rápido, integración profunda con Kubernetes | Otro modelo de operación y otro CNI – demasiado para tres clústeres de un solo nodo. |
| nftables a secas | Un fichero legible, carga atómica, sets del kernel, ningún demonio | Elegido. |
La verdadera lección de los últimos meses no es un detalle de sintaxis. Es que un cortafuegos en un host Kubernetes nunca está solo: cada suposición sobre quién escribe qué regla, y en qué orden las ejecuta el kernel, tiene que probarse – una prueba deny-all encontró en una tarde más que dos meses leyendo ficheros de configuración.


