Resumen: «Portero» es un demonio en Python que escribí para leer los logs de Nginx en tiempo real y banear direcciones IP hostiles a nivel de red (no solo devolverles un error HTTP), a la vez que envía telemetría de seguridad a un sistema de métricas. Nació porque un WAF, por bueno que sea, solo mira una petición cada vez: no ve patrones de comportamiento repetidos por la misma IP a lo largo de docenas de peticiones. Este proyecto cuenta la arquitectura del demonio, la filosofía que lo sostiene y cómo me aseguro de que el bloqueo se aplica de verdad en todo el parque.

Por qué otra pieza más, si ya hay un WAF

El WAF analiza cada petición de forma aislada contra un conjunto de reglas: es excelente detectando payloads maliciosos dentro de una petición concreta. Pero hay ataques que no llevan ningún payload — un barrido de rutas que no existen, un intento tras otro de login con credenciales distintas — donde lo sospechoso no está en ninguna petición individual, sino en el patrón que forman muchas seguidas desde el mismo origen. Para eso necesitaba algo que mantuviera memoria de corto plazo por IP y actuara a nivel de red, cortando la conexión antes de que llegue siquiera al backend, no solo devolviendo un error de aplicación.

Arquitectura: tres piezas que no se pueden bloquear entre sí

El demonio corre en el mismo nodo que hace de proxy de entrada y tiene tres módulos concurrentes con una regla de oro: ninguno puede bloquear a los otros dos.

  • Lector de logs. Sigue el fichero de acceso de Nginx línea a línea, en el proceso principal, sin acumular nada en memoria más allá de lo imprescindible.
  • Motor de reglas + cortafuegos. Por cada IP mantiene una ventana deslizante con sus últimas respuestas. Si el ratio de errores dentro de esa ventana supera un umbral, o si detecta una ráfaga de intentos de login en poco tiempo (aunque WordPress responda con un 200 normal al fallar, que es lo habitual), banea la IP a nivel de red con ipset/iptables y limpia su historial.
  • Worker de métricas. Un hilo aparte consume una cola en memoria y manda la telemetría a la base de series temporales sin bloquear jamás el análisis de seguridad. Si ese envío falla o se acumula, se descarta la métrica más vieja y se sigue baneando igual — la seguridad nunca espera a la observabilidad.

Arquitectura de Portero: lector de logs, motor de reglas con ventana deslizante, ipset/iptables y worker de métricas con cola en memoria

Filosofía: fail-safe, nunca se para

La premisa de diseño es que ningún error individual puede tumbar el demonio: protege toda la plataforma, así que un fallo cualquiera es peor que una métrica perdida. Cada línea de log se procesa dentro de su propio bloque de manejo de errores, de forma que una línea mal formada o una excepción inesperada se registran y se saltan, sin cortar el bucle. Si el proceso que sigue el log muere por lo que sea, se reinicia solo a los pocos segundos. Si la librería de red para hablar con la base de métricas no está instalada, el demonio arranca igual y sigue baneando, solo que sin telemetría. Cada IP se compara antes contra una lista blanca (direcciones propias, redes conocidas de buscadores legítimos, LAN) y contra un conjunto de rutas de sondeo habituales que no cuentan como señal de ataque. Direcciones IPv4 se banean una a una; en IPv6, dado lo fácil que es rotar dentro del mismo bloque, el baneo se aplica a todo el segmento, no solo a la dirección exacta.

El límite detrás de Cloudflare, y cómo lo resolví

El bloqueo por IP a nivel de red (ipset/iptables) es contundente, pero tiene un techo: mis webs van detrás de Cloudflare, así que los paquetes que llegan al servidor traen la IP de Cloudflare, no la del atacante real. Un ipset sobre la IP real de un atacante no bloquea nada si ese atacante llega a través del proxy — solo sirve contra quien ataca directamente al origen, saltándose el dominio.

La solución efectiva no es de red, es de aplicación: un mapa en la configuración de Nginx que reconstruye la IP real del cliente a partir de la cabecera que aporta Cloudflare, y corta la conexión sin respuesta si esa IP está en la lista de baneo. Eso sí funciona tanto si el atacante pasa por el proxy como si ataca directo, porque no depende de qué IP ve el sistema operativo, depende de la que reconstruye la propia aplicación. Mantengo ambas capas en paralelo — ipset a nivel de red para tráfico directo, el mapa de Nginx para tráfico proxied — porque cada una cubre un caso que la otra no ve.

Que el bloqueo se aplique de verdad en todo el parque

Nginx no comparte condiciones entre distintos bloques de sitio: el fragmento que corta el tráfico baneado hay que incluirlo explícitamente en cada bloque de configuración. Con decenas de sitios, unos generados por la herramienta de aprovisionamiento y otros escritos a mano, es justo el tipo de detalle que no conviene dejar a la memoria de nadie.

Así que el bloqueo vive en un único fragmento fuente, incluido desde todos los sitios, y una auditoría automática diaria recorre la configuración efectiva del servidor y avisa (con opción de autocorregir y recargar) si algún bloque se queda sin él. Que el baneo esté en la lista y en el ipset no significa que esté aplicado: solo lo está si el bloque que atiende ese dominio incluye el fragmento, y eso ahora se comprueba solo cada día.

El colector de eventos de seguridad

Portero está deliberadamente enfocado: lee, decide, banea, manda una métrica mínima. No quería meterle más responsabilidad porque cuanto más hace un proceso que no puede caerse, más difícil es mantenerlo simple. Así que el detalle forense — qué regla exacta del WAF saltó, con qué datos, para cada transacción bloqueada — vive en un segundo proceso independiente y también fail-safe: sigue el log de auditoría del WAF (un formato de texto con secciones delimitadas, pensado para grep, no para parsear en caliente), reconstruye cada transacción en un evento estructurado, y lo manda tanto a un buscador de logs centralizado como a un sistema de logs ligero para consultas recientes. Guarda su posición de lectura en disco para reanudar donde se quedó si se reinicia, y distingue una rotación de fichero de un simple corte del propio log. El mismo proceso también recoge, por sondeo periódico, los baneos que genera Portero y los añade al mismo flujo — así el detalle de «qué regla saltó» y el de «a quién se baneó por comportamiento» acaban juntos, aunque los generen dos sistemas que no se hablan entre sí.

Resultado

Portero lleva baneando por comportamiento de forma continua desde hace meses, con varios miles de baneos y direcciones distintas acumuladas, sin una sola caída del proceso. Los ajustes que ha necesitado han sido de afinado —como la lista blanca de buscadores legítimos que cuento en el proyecto de threat hunting—, nunca del propio demonio. La auditoría diaria del bloqueo por red y el colector de eventos son las dos piezas que más tranquilidad me han dado: la primera convierte «tengo una lista de bloqueo» en «sé que se aplica de verdad», y la segunda une el forense del WAF con los baneos de Portero sin tener que mirar dos sitios distintos.

Stack

  • Python 3 (librería estándar; opcionalmente requests para métricas, con degradación segura si no está)
  • ipset / iptables (bloqueo a nivel de red, IPv4 y IPv6)
  • Nginx (mapa geo con la IP real vía cabecera de Cloudflare)
  • Base de series temporales + panel de métricas para la telemetría
  • Buscador de logs centralizado + sistema de logs ligero para el colector de eventos
  • systemd (persistencia y arranque de ambos procesos)

Relacionado: LILXEON: mi nueva infra con Proxmox, donde este demonio corre delante de todo el clúster web.