wandres.dev
P2P II · libp2p, iroh y QUIC

libp2p: la pila modular que salió de los ficheros distribuidos

libp2p no es un protocolo sino un catálogo de piezas intercambiables para transporte, cifrado, multiplexado y descubrimiento, y esa modularidad se paga con una negociación que conviene entender antes de depurarla.

⏱ 20 min

Casi todo lo que hoy llamamos redes entre pares en producción desciende de un mismo gesto de ingeniería: alguien construyó un sistema de ficheros distribuido, descubrió que la parte difícil no era el almacenamiento sino conseguir que dos máquinas cualesquiera hablaran entre sí, y decidió sacar esa parte a una biblioteca aparte. Esa biblioteca es libp2p, y su documentación la define sin adornos como un sistema modular de protocolos, especificaciones y bibliotecas para desarrollar aplicaciones entre pares a escala global. La palabra decisiva de esa definición es modular, porque libp2p no te entrega una red: te entrega los ejes sobre los que se puede construir una, y la obligación de elegir un valor para cada eje. Esa elección es su mayor virtud cuando sabes lo que quieres y su mayor coste cuando no. Antes de comparar libp2p con nada, conviene entender qué ejes existen, cuándo se resuelven —casi todos durante el establecimiento de la conexión, no en tiempo de compilación— y qué factura llega después.

🎯 Al terminar esta lección sabrás
  • Situar libp2p como pila extraída de un caso de uso real y no como diseño de laboratorio.
  • Enumerar los ejes intercambiables de la pila y el momento exacto en que cada uno se decide.
  • Distinguir descubrimiento de pares de enrutado y saber qué mecanismo cubre cada necesidad.
  • Poner precio a la modularidad en negociación, superficie de interoperabilidad y depuración.

Una pila extraída, no diseñada desde cero

El detalle histórico importa más de lo que parece. libp2p no nació de una especificación previa a la implementación, sino de la necesidad concreta de un sistema de ficheros distribuido que tenía que funcionar sobre redes reales, con navegadores, móviles, servidores y toda la fauna de cortafuegos que hay entre ellos. Cuando la lógica de red se separó del resto, heredó una propiedad que ninguna pila diseñada en abstracto suele tener: cada pieza existe porque un despliegue concreto la necesitó. Eso explica su heterogeneidad, que a primera vista parece desorden.

La consecuencia práctica es que libp2p tiene hoy varias implementaciones de primera fila —en Go, Rust, JavaScript, Nim y Python— y que todas siguen las mismas especificaciones precisamente para poder hablar entre ellas. Esa disciplina de especificación compartida es lo que convierte a libp2p en algo más que una biblioteca: es un contrato entre lenguajes. Un nodo escrito en Rust y otro escrito en el navegador pueden abrir una conexión porque ambos implementan el mismo documento, no porque compartan código.

El objetivo declarado del proyecto es que tus aplicaciones de red funcionen con independencia del entorno de ejecución y de la ubicación de los nodos. Traducido a decisiones de diseño: nada en la pila puede asumir que existe una dirección estable, ni un puerto abierto, ni un sistema operativo concreto, ni siquiera un sistema operativo. De ahí sale casi todo lo demás.

Las capas intercambiables y el momento en que se eligen

La pila se organiza en ejes, y cada eje admite varios valores simultáneos. El eje del transporte mueve los bits: TCP, QUIC, WebSocket, WebRTC y WebTransport son las opciones documentadas, y ser agnóstico respecto al transporte figura como requisito fundacional del proyecto. Una aplicación puede habilitar varios a la vez; la elección real se hace al marcar, según lo que el otro extremo anuncie.

El eje del canal seguro cifra y autentica. Todas las conexiones van cifradas por defecto con Noise o con TLS 1.3, sin excepción y sin modo inseguro para producción. El eje del multiplexado permite abrir muchos flujos lógicos sobre una única conexión, con Yamux y mplex como implementaciones. Y por encima de todo eso vive el componente que la documentación llama switch —o swarm, según la implementación—, que guarda el estado de los pares conocidos y de las conexiones abiertas, y que ofrece una interfaz de marcado y escucha que abstrae qué multiplexor se acabó usando en cada conexión.

// La pila se declara eje a eje: cada campo es una lista de candidatos
import { createLibp2p } from "libp2p";
import { tcp } from "@libp2p/tcp";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";

const nodo = await createLibp2p({
  transports: [tcp(), webSockets()],   // el marcado elige uno segun el par
  connectionEncrypters: [noise()],     // Noise o TLS 1.3, nunca nada
  streamMuxers: [yamux()],             // Yamux o mplex, negociado
  services: { identify: identify() },
});

Lo que hay que retener no es la lista de opciones sino cuándo se resuelve la elección. No es en tiempo de compilación: es durante el establecimiento de la conexión, mediante negociación de protocolo. Los pares acuerdan un multiplexor que ambos soporten y ese acuerdo promociona una conexión de transporte cruda a una conexión multiplexada capaz de abrir flujos nuevos. La documentación es explícita en un detalle que ahorra muchos dolores: si marcas a un par con el que el switch ya tiene una conexión abierta, el flujo nuevo se multiplexa automáticamente sobre la existente en lugar de abrir otra.

ℹ️
QUIC colapsa tres pasos en uno y por eso cambia las cuentas

La secuencia canónica de libp2p es transporte, luego canal seguro, luego multiplexor, cada paso con su negociación. QUIC llega con cifrado TLS 1.3 y multiplexado de flujos ya dentro del propio protocolo, de modo que al elegirlo desaparecen dos rondas de promoción y la conexión queda utilizable mucho antes. Existe además la negociación temprana del multiplexor precisamente para recortar ese coste en los transportes que no lo traen incorporado. Cuando midas tiempos de establecimiento, lo primero que debes anotar no es la latencia de red sino cuántas promociones has encadenado.

Descubrimiento y enrutado: encontrar a quien no tiene dirección estable

Un par en libp2p se identifica por su PeerId, derivado de su clave pública, y se alcanza mediante una multiaddr, una dirección compuesta que describe el camino completo capa por capa. La separación es deliberada: la identidad es criptográfica y permanente, la dirección es circunstancial y múltiple. Un mismo par puede anunciar varias direcciones a la vez y cambiarlas sin dejar de ser quien es, que es exactamente lo que la documentación llama itinerancia nativa.

Queda entonces el problema de averiguar qué direcciones tiene alguien. Aquí conviene separar dos preguntas que se confunden a menudo. El descubrimiento responde a qué pares existen: mDNS los encuentra en la red local por multidifusión, y el protocolo de encuentro los reúne alrededor de un punto conocido. El enrutado responde a cómo llego a este par concreto: la DHT de Kademlia mantiene una tabla distribuida donde consultar las direcciones asociadas a un identificador. Y hay un tercer protocolo, identify, que suele pasar desapercibido y es central: es el mecanismo por el que un nodo aprende qué direcciones suyas ve el mundo desde fuera.

flowchart TD
APP[tu aplicacion habla un protocolo con su identificador] --> SW[switch o swarm que guarda pares y conexiones]
SW --> MUX[multiplexado negociado yamux o mplex]
MUX --> SEC[canal seguro noise o TLS 1.3]
SEC --> TR[transporte TCP QUIC WebSocket WebRTC WebTransport]
SW --> DISC[descubrimiento mDNS y protocolo de encuentro]
SW --> RUT[enrutado DHT de Kademlia]
DISC --> SW
RUT --> SW
style APP fill:#a6e3a1,color:#11111b
style SEC fill:#f9e2af,color:#11111b
style TR fill:#89b4fa,color:#11111b
style DISC fill:#cba6f7,color:#11111b

Ese bucle del diagrama no es decorativo. El switch necesita direcciones para marcar, el descubrimiento y el enrutado se las suministran, y ambos necesitan conexiones abiertas para funcionar, que solo el switch puede darles. Arrancar un nodo desde cero exige romper la circularidad con pares semilla configurados de antemano, y esa dependencia inicial es el punto donde más redes entre pares se atascan en producción sin que nadie lo haya escrito en el diagrama de arquitectura.

El precio de la modularidad

La primera factura es de negociación. Cada eje que dejas abierto es una conversación más antes de poder enviar el primer byte útil, y esas conversaciones ocurren en el peor momento posible: cuando el usuario está esperando. La segunda es de interoperabilidad combinatoria: cinco transportes por dos canales seguros por dos multiplexores por cinco implementaciones de lenguaje no es una matriz que se pueda verificar a ojo. No es casualidad que el proyecto mantenga una batería de pruebas de interoperabilidad entre implementaciones y una página pública de estado; existen porque la modularidad las hace imprescindibles.

// En rust cada eje es un campo del tipo, y el compilador te obliga a decidir
#[derive(NetworkBehaviour)]
struct MiPila {
    identify: identify::Behaviour,
    kademlia: kad::Behaviour<kad::store::MemoryStore>,
    relay_cliente: relay::client::Behaviour,
    dcutr: dcutr::Behaviour,
}

La tercera factura, la más silenciosa, es de depuración. Cuando una conexión no se establece, la pregunta por qué tiene demasiadas respuestas posibles: no había transporte común, la negociación del canal seguro falló, el multiplexor no coincidió, el par anunciaba direcciones inalcanzables, la consulta a la tabla distribuida no devolvió nada, el cortafuegos descartó el paquete. Un sistema con una sola opción por eje no es más rápido, pero tiene un árbol de fallos que cabe en la cabeza de una persona.

🔌

Transporte

TCP, QUIC, WebSocket, WebRTC y WebTransport conviviendo. Ganas alcance a navegadores y móviles, pagas rondas de negociación y matriz de pruebas.

🔐

Canal seguro

Noise o TLS 1.3, cifrado por defecto sin modo inseguro. El eje más barato de todos porque casi nunca querrás tocarlo.

🧵

Multiplexado

Yamux o mplex sobre transportes que no lo traen. Con QUIC sobra, y eso es un argumento fuerte a favor de QUIC.

🧭

Descubrimiento y enrutado

mDNS en local, encuentro por punto conocido, tabla distribuida de Kademlia para el resto. Aquí vive la dependencia de pares semilla.

La modularidad de libp2p es una respuesta al hecho de que no existe una red, sino muchas

Es tentador leer el catálogo de ejes como indecisión de diseño, como si el proyecto no hubiera sabido elegir y hubiera dejado la elección al usuario para no equivocarse. Esa lectura es cómoda y es falsa, y entender por qué es falsa cambia cómo se evalúa cualquier pila entre pares. La modularidad de libp2p no responde a una duda técnica: responde a una observación empírica sobre el mundo. No existe una red sobre la que construir, existen decenas de redes con propiedades incompatibles, y un navegador que no puede abrir un socket crudo, un móvil que cambia de operador a media transferencia, un servidor con dirección pública, una máquina detrás de una traducción de direcciones a nivel de operador y un dispositivo empotrado sin pila TLS no admiten el mismo transporte por mucho que el diseñador lo desee. Cuando una pila declara un único transporte, no está simplificando: está eligiendo qué usuarios quedan fuera, y normalmente lo hace sin decirlo. libp2p hace exactamente lo contrario: pone la elección sobre la mesa, la documenta y te obliga a mirarla. El coste de esa honestidad es el que hemos enumerado —negociación, combinatoria, árbol de fallos ancho— y es un coste real que no conviene minimizar. Pero fíjate en lo que ocurre cuando alguien intenta eliminarlo: el resto del nivel estudia una pila, iroh, que reduce drásticamente los ejes visibles fijando QUIC con TLS 1.3 como base. Y lo interesante no es que haya eliminado la complejidad, porque no lo ha hecho: ha elegido por ti, ha escondido la elección detrás de una interfaz estrecha y ha aceptado la responsabilidad de acertar en tu nombre. Son dos posturas legítimas ante el mismo hecho irreductible, y la pregunta que debes hacerte al elegir entre ellas no es cuál es más elegante, sino quién quieres que asuma el riesgo de la heterogeneidad de la red: tú, con control total y una superficie que mantener, o la biblioteca, con menos control y menos superficie. Casi todas las discusiones entre pilas entre pares que verás en los próximos años son, por debajo, esta misma pregunta reformulada.

⚔️ Levanta la pila y mide lo que cuesta cada eje
  1. Arranca dos nodos con un único transporte TCP y mide el tiempo desde el marcado hasta el primer byte de aplicación entregado.
  2. Repite la medida habilitando QUIC y compárala: la diferencia que veas es el coste de las promociones que QUIC se ahorra.
  3. Añade identify y anota qué direcciones cree tener tu nodo antes y después de la primera conexión entrante.
  4. Habilita mDNS y comprueba cuánto tarda el descubrimiento local frente a configurar la dirección a mano.
  5. Provoca un fallo deliberado quitando el multiplexor de uno de los nodos y clasifica el mensaje de error que obtienes.
  6. Escribe en una página qué ejes dejarías abiertos en tu producto y justifica cada uno por el tipo de usuario que habilita.