netfilter y nftables: los ganchos donde nace el cortafuegos
Los cinco hooks de netfilter —`PREROUTING`, `INPUT`, `FORWARD`, `OUTPUT`, `POSTROUTING`— colocados en las junturas exactas que marca la decisión de ruta, dónde se filtra, se hace NAT y se manipula un paquete. De un `nf_hook_ops` en C a las cadenas base de nftables y al seguimiento de conexiones que convierte un filtro sin estado en un cortafuegos de verdad.
Ya viste el paquete subir por la pila como una cascada de tablas. Ahora, en puntos precisos de ese recorrido, el kernel se detiene y pregunta: ¿dejo pasar esto, lo tiro, lo reescribo? Esos puntos son los cinco hooks de netfilter, y no están puestos al azar: ocupan las junturas exactas que la decisión de ruta abre en el camino de un paquete. Sobre ese esqueleto de cinco ganchos se levanta todo lo que Linux sabe hacer con el tráfico —cortafuegos, NAT, balanceo, redes de contenedores—, y nftables es el lenguaje moderno con el que se programa. Aquí se cierra el nivel: el paquete deja de solo viajar y empieza a ser gobernado.
- Ubicar los cinco hooks de netfilter respecto a la decisión de ruta y la entrega local.
- Registrar un hook en C con
nf_hook_opsy devolver los veredictos correctos. - Escribir un cortafuegos básico con tablas, cadenas base y reglas de nftables.
- Entender el seguimiento de conexiones (conntrack) como base del estado y del NAT.
Los cinco ganchos: dónde se intercepta un paquete
Netfilter inserta cinco puntos de intercepción en el camino de todo paquete IP. Su colocación se deduce de dos preguntas binarias: ¿antes o después de enrutar?, y ¿el paquete es para esta máquina o de paso?
/* include/uapi/linux/netfilter.h: los cinco hooks, en orden de recorrido */
enum nf_inet_hooks {
NF_INET_PRE_ROUTING, /* recien llegado, ANTES de decidir ruta */
NF_INET_LOCAL_IN, /* la ruta dijo: es para un socket local */
NF_INET_FORWARD, /* la ruta dijo: es de paso, a otra interfaz */
NF_INET_LOCAL_OUT, /* generado aqui, antes de enrutarlo */
NF_INET_POST_ROUTING, /* a punto de salir por el cable */
};
Un paquete entrante cruza PRE_ROUTING; luego la ruta lo bifurca —lo viste en la lección 4— hacia LOCAL_IN si es nuestro o hacia FORWARD si es de paso. Uno que nace en la máquina cruza LOCAL_OUT y, tras enrutarse, POST_ROUTING. Todo lo que sale, sea reenviado o propio, pasa por POST_ROUTING; todo lo que entra, por PRE_ROUTING. Esos dos hooks embridan la máquina; los otros tres marcan el destino.
flowchart TB IN0[entra del cable] --> PRE[PRE_ROUTING] --> RT[la ruta decide] RT --> LIN[LOCAL_IN] --> S[socket local] RT --> FWD[FORWARD] --> POST[POST_ROUTING] S2[socket local emite] --> LOUT[LOCAL_OUT] --> POST POST --> OUT0[sale al cable]
Un hook en C: nf_hook_ops y los veredictos
Registrar un hook es declarar una función que el kernel llamará en uno de esos cinco puntos, y que devuelve un veredicto. La estructura nf_hook_ops dice qué función, en qué familia, en qué hook y con qué prioridad —las prioridades ordenan varios hooks en el mismo punto—.
static unsigned int mi_hook(void *priv, struct sk_buff *skb,
const struct nf_hook_state *state)
{
struct iphdr *iph = ip_hdr(skb); /* la cabecera IP, marcada en la leccion 2 */
if (iph->protocol == IPPROTO_ICMP)
return NF_DROP; /* descarta todo ICMP, en silencio */
return NF_ACCEPT; /* deja seguir al resto */
}
static const struct nf_hook_ops mi_ops = {
.hook = mi_hook,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_PRE_ROUTING,
.priority = NF_IP_PRI_FIRST, /* actua antes que otros hooks del punto */
};
static int __init mi_init(void)
{
return nf_register_net_hook(&init_net, &mi_ops); /* por cada namespace de red */
}
static void __exit mi_exit(void)
{
nf_unregister_net_hook(&init_net, &mi_ops);
}
Los veredictos son el vocabulario completo de una decisión: NF_ACCEPT deja seguir, NF_DROP descarta liberando el skb, NF_STOLEN se adueña del paquete y corta el procesado sin liberarlo —lo tomó el hook para uso propio—, NF_QUEUE lo entrega a un programa de espacio de usuario, y NF_REPEAT reejecuta el mismo hook. Con solo esos cinco verbos se expresa cualquier política.
nftables: tablas, cadenas base y reglas
Escribir hooks en C es potente pero rígido. nftables —el sucesor de iptables desde hace años— ofrece un lenguaje: defines tablas que contienen cadenas, y una cadena base se engancha a un hook de netfilter con un tipo, una prioridad y una política por defecto. Las reglas dentro emiten veredictos según lo que casen.
# Tabla de la familia inet: cubre IPv4 e IPv6 a la vez
nft add table inet filtro
# Cadena base enganchada al hook input, con politica por defecto de descarte
nft add chain inet filtro entrada '{ type filter hook input priority 0 ; policy drop ; }'
# Reglas: primero lo ya conocido, luego lo permitido; el resto cae por la politica
nft add rule inet filtro entrada ct state established,related accept
nft add rule inet filtro entrada iif lo accept
nft add rule inet filtro entrada tcp dport 22 accept
nft add rule inet filtro entrada tcp dport 443 accept
Ese puñado de líneas es un cortafuegos completo con política de denegación por defecto: solo entra lo que responde a conexiones que iniciamos, el loopback, y SSH y HTTPS. La cadena, al ser type filter hook input, corre exactamente en NF_INET_LOCAL_IN: nftables no reinventa nada, se cuelga de los cinco ganchos que ya conoces. Por dentro, nft habla con el subsistema nf_tables del kernel por netlink, y las reglas se compilan a un pequeño bytecode que una máquina virtual evalúa por paquete.
Donde nftables deja atrás de verdad a iptables es en los conjuntos. En lugar de una regla por cada puerto o dirección, defines un set y casas contra él en tiempo casi constante, no lineal en el número de elementos:
# Un conjunto de puertos permitidos, y una sola regla que casa contra el
nft add set inet filtro permitidos '{ type inet_service ; }'
nft add element inet filtro permitidos '{ 22, 80, 443, 8080 }'
nft add rule inet filtro entrada tcp dport @permitidos accept
Los verdict maps van un paso más allá: mapean un valor del paquete directamente a un veredicto, colapsando una cadena de comparaciones en una única búsqueda —la misma idea de tabla que gobierna la pila entera (lección 4), ahora en manos del administrador—.
Estado y NAT: conntrack como base de todo
La regla ct state established,related accept esconde la pieza que separa un filtro de juguete de un cortafuegos real: el seguimiento de conexiones. El módulo nf_conntrack observa los paquetes en PRE_ROUTING y LOCAL_OUT, reconstruye los flujos y etiqueta cada paquete con su estado —new, established, related, invalid—. Así puedes permitir las respuestas a tus conexiones salientes sin abrir esos puertos de entrada: el kernel recuerda qué conversaciones están en curso.
# NAT de salida: enmascara el origen de todo lo que sale por eth0 (SNAT dinamico)
nft add table ip nat
nft add chain ip nat postrouting '{ type nat hook postrouting priority srcnat ; }'
nft add rule ip nat postrouting oifname "eth0" masquerade
# Redireccion de puerto entrante: DNAT del 80 publico a un servidor interno
nft add chain ip nat prerouting '{ type nat hook prerouting priority dstnat ; }'
nft add rule ip nat prerouting iifname "eth0" tcp dport 80 dnat to 10.0.0.5:8080
El NAT se apoya por completo en conntrack: al reescribir la dirección de origen en POST_ROUTING (masquerade) o el destino en PRE_ROUTING (dnat), conntrack guarda la traducción y la aplica en espejo a los paquetes de vuelta, sin reglas para el sentido inverso. Por eso el NAT vive en los hooks extremos —PRE_ROUTING para el destino, POST_ROUTING para el origen—: son los únicos puntos donde reescribir una dirección aún respeta la decisión de ruta que ocurre entre ambos.
Cada struct net —cada namespace de red— tiene su propio conjunto de hooks y sus propias tablas nftables. Por eso nf_register_net_hook recibe un struct net: un contenedor con su namespace tiene un cortafuegos completamente independiente del anfitrión, sobre el mismo kernel. Cuando Docker o Kubernetes montan reglas por contenedor, están poblando los cinco ganchos de cada namespace por separado. La red del kernel no solo es extensible en protocolos (lección 4); es replicable en mundos aislados.
Detente en por qué son exactamente cinco, ni cuatro ni seis, porque en esa cifra hay una verdad que trasciende netfilter. Un paquete, en su tránsito por una máquina, solo puede estar en un número finito de posiciones significativas, y esas posiciones las definen dos preguntas independientes: ¿está el paquete antes o después de la decisión de ruta?, y ¿pertenece a esta máquina o solo la atraviesa? Cruza esas dos dimensiones y obtienes el mapa completo. Antes de enrutar, todo lo entrante confluye en un punto: PRE_ROUTING. Después de enrutar, lo local sube por LOCAL_IN y lo ajeno se desvía por FORWARD. Lo que la propia máquina engendra aparece en LOCAL_OUT, espejo exacto de LOCAL_IN. Y antes de partir, todo lo saliente —lo reenviado y lo propio— vuelve a confluir en POST_ROUTING. No hay una sexta posición porque no hay una tercera pregunta que hacer: los cinco hooks agotan el espacio lógico de dónde puede uno pararse a mirar un paquete. Son un sistema de coordenadas completo y mínimo. Y aquí está la revelación que corona el nivel: absolutamente todo lo que Linux hace con el tráfico —el cortafuegos que protege tu portátil, el NAT que comparte una IP entre cien dispositivos, el balanceador que reparte carga entre servidores, la malla de red que conecta mil contenedores, el filtrado que aísla un namespace— es lo mismo, una y otra vez: un veredicto emitido en uno de estos cinco puntos, quizá informado por el estado de la conexión que conntrack recuerda. No hay una tecnología de cortafuegos y otra de NAT y otra de balanceo; hay cinco ganchos, cinco veredictos y una tabla de conexiones, y toda la infraestructura de red del planeta que corre sobre Linux es combinatoria de esos elementos. Cuando ves que la seguridad, la traducción de direcciones y el enrutado de contenedores son un solo mecanismo mirado desde ángulos distintos, has dejado de aprender la pila de red: la comprendes.
- Escribe un módulo que registre un hook en
NF_INET_PRE_ROUTING, cuente los paquetes por protocolo y devuelvaNF_ACCEPT; cárgalo y observa los contadores con tráfico real. - Cambia el veredicto para descartar todo ICMP entrante con
NF_DROPy verifica que unpinga tu máquina deja de responder. - Monta con nftables un cortafuegos de política
dropeninputque solo admita lo establecido, el loopback y SSH; compruébalo sin quedarte fuera. - Configura
masqueradeenPOST_ROUTINGy comparte tu conexión con otra máquina o namespace; observa las traducciones conconntrack -L. - Explica, apoyándote en dónde vive conntrack, por qué el NAT no necesita reglas para el tráfico de vuelta, y por qué SNAT va en
POST_ROUTINGy DNAT enPRE_ROUTING.