DTLS y SCTP: el canal de datos sobre una pila de medios
El canal de datos va cifrado sobre un transporte heredado de la señalización telefónica, lo que te regala orden y fiabilidad configurables y te obliga a cargar con tres saludos apilados.
Cuando el procedimiento de conectividad encuentra por fin una pareja de direcciones que responde, lo que se tiene es un camino por el que pasan datagramas sueltos, sin orden, sin garantía de llegada y sin protección alguna. Falta todo lo que convierte eso en algo utilizable por una aplicación. WebRTC resuelve esa parte apilando dos protocolos que no se diseñaron para esto ni el uno para el otro: una adaptación del cifrado de la web a un transporte sin conexión, y encima de ella un protocolo de transporte nacido para llevar señalización telefónica sobre red de datos. El resultado tiene una virtud enorme y muy infravalorada, que es poder elegir por canal si los mensajes llegan en orden y si se reintentan, y arrastra un lastre igual de real: tres saludos encadenados antes del primer byte útil, un límite de tamaño de mensaje que hay que gestionar a mano y una implementación considerable de código heredado dentro del proceso del navegador. Esta lección examina las dos caras con el mismo detalle.
- Entender qué aporta el cifrado obligatorio y qué cuesta su saludo sobre un transporte sin conexión.
- Explicar por qué el canal de datos viaja sobre un protocolo de señalización telefónica y qué implica.
- Configurar orden y fiabilidad por canal, y elegir la combinación adecuada para cada clase de dato.
- Gestionar contrapresión y troceado de mensajes sin destruir la conexión ni la memoria.
- Reconocer la redundancia de capas y su relación con los transportes integrados modernos.
Cifrado obligatorio y tres saludos encadenados
Lo primero que ocurre sobre el camino recién abierto es un saludo criptográfico. WebRTC no permite transmitir en claro, no hay opción de configuración que lo desactive, y eso es una decisión excelente que conviene aplaudir sin matices: elimina de raíz una categoría entera de errores de despliegue y garantiza que ningún intermediario de la ruta lea los datos. La adaptación empleada es la del cifrado de la web llevada a un transporte sin conexión, lo que obliga a añadir por su cuenta numeración y reintentos porque los mensajes del saludo pueden perderse o llegar desordenados.
Los certificados son autofirmados y se generan en el propio navegador, sin autoridad que los avale. Como vimos en la segunda lección, la única comprobación posible es contrastar la huella presentada con la que llegó por señalización, de modo que la solidez del cifrado depende por completo de la integridad de aquel canal. Aquí interesa la otra cara: el coste temporal. El saludo requiere sus propias idas y vueltas, y ocurre después de que las comprobaciones de conectividad hayan terminado, no en paralelo.
// Los tres saludos son secuenciales y sus latencias se suman
// 1. conectividad: probar parejas hasta que una responde -> variable, cola larga
// 2. cifrado: saludo sobre datagramas con reintentos -> una o dos idas y vueltas
// 3. transporte: apertura del canal fiable por encima -> otra ida y vuelta
// Y solo despues de los tres se puede enviar el primer byte util.
pc.addEventListener("connectionstatechange", () => {
console.log(pc.connectionState); // connecting -> connected
});
Ese apilamiento explica por qué una conexión entre pares tarda en estar lista bastante más de lo que la intuición sugiere, incluso cuando la red es buena y la pareja de direcciones se encuentra al primer intento. No es un problema de implementación: es la suma aritmética de tres protocolos que negocian por separado porque se escribieron por separado y en épocas distintas.
Por omisión, abrir un canal de datos implica un intercambio adicional para que ambos extremos se pongan de acuerdo en su identificador y sus parámetros. Se puede evitar entero declarando el canal como pre-acordado y fijando el identificador a mano en los dos lados, ya que los parámetros los conoce cada uno de antemano por ser parte del diseño de la aplicación. La ganancia es una ida y vuelta menos y, más importante, la desaparición de una condición de carrera clásica en la que un extremo empieza a enviar antes de que el otro haya visto abrirse el canal. Para una aplicación que siempre usa los mismos canales, no hay razón para no hacerlo.
Un transporte de señalización telefónica dentro de un datagrama
Encima del cifrado va el transporte que de verdad reparte los mensajes, y su procedencia es la parte más sorprendente de toda la pila. Se diseñó para llevar señalización de telefonía sobre red de datos, un caso de uso con requisitos muy peculiares: mensajes discretos en lugar de un flujo continuo de bytes, muchas conversaciones independientes dentro de una misma asociación, tolerancia a caminos que se caen y una hostilidad explícita hacia los ataques de apertura masiva de conexiones.
De ese origen vienen tres propiedades que sí resultan útiles aquí y una que no se usa. La primera útil es que es orientado a mensajes: lo que envías es lo que el otro recibe, con sus fronteras intactas, sin necesidad de inventarse un marco de longitud como haría falta sobre un flujo de bytes. La segunda es el multiplexado en flujos independientes dentro de una sola asociación, que permite que un mensaje grande atascado en un flujo no bloquee a los demás. La tercera es la fiabilidad parcial, y es la joya de la corona. La que no se usa es la capacidad de mantener varios caminos simultáneos, porque de eso ya se encarga el procedimiento de conectividad de la lección anterior.
// Tres canales sobre la misma asociacion, con politicas deliberadamente distintas
const ops = pc.createDataChannel("ops", {
negotiated: true, id: 1, ordered: true // fiable y ordenado: operaciones
});
const presencia = pc.createDataChannel("presencia", {
negotiated: true, id: 2,
ordered: false, maxPacketLifeTime: 500 // caduca: cursores y estado efimero
});
const bloques = pc.createDataChannel("bloques", {
negotiated: true, id: 3,
ordered: false // fiable pero sin orden: trozos
});
Merece la pena detenerse en el tercer canal porque es el que más gente configura mal. Un trozo de fichero identificado por su posición no necesita llegar en orden: necesita llegar. Pedir orden cuando no hace falta activa el bloqueo por cabecera de línea, en el que un solo mensaje perdido detiene la entrega de todos los que ya habían llegado detrás mientras se espera su reintento. En una red con pérdidas, esa diferencia de configuración cambia el rendimiento de forma drástica y no cuesta nada.
Elegir la política de entrega dato por dato
Las dos palancas son independientes y dan cuatro combinaciones, de las cuales la mayoría de las aplicaciones usa solo una. Conviene tener la tabla mental completa porque es la capacidad más valiosa que ofrece este transporte y la que no tiene equivalente en un canal ordinario contra un servidor.
Fiable y ordenado
El comportamiento por omisión y el equivalente a un canal clásico. Correcto para operaciones que dependen unas de otras y deben aplicarse en secuencia.
Fiable sin orden
Todo llega pero cada cosa en cuanto puede. Ideal para trozos identificados, bloques direccionados por contenido y actualizaciones que ya llevan su propia causalidad.
Caduca por tiempo
Se reintenta durante un plazo y luego se abandona. Es lo correcto para posiciones de cursor, presencia y cualquier dato que envejece peor de lo que se recupera.
Caduca por reintentos
Un número fijo de intentos y se descarta. Útil cuando importa acotar el trabajo y no el retraso, aunque en la práctica el plazo suele ser el criterio más natural.
Las dos últimas combinaciones son mutuamente excluyentes y son también la razón principal por la que este transporte tiene sentido para una aplicación colaborativa. Un cursor de otra persona que llegó con dos segundos de retraso no es información útil, es información equivocada: la persona ya está en otro sitio. Reintentar su entrega consume ancho de banda para empeorar lo que se ve. Un canal ordinario contra un servidor no puede expresar eso, porque su contrato es entregarlo todo aunque sea tarde, y por eso las aplicaciones que lo usan acaban implementando a mano un descarte que el transporte de debajo sigue empeñado en evitar.
La contrapresión, en cambio, no es opcional y es donde se rompen las implementaciones descuidadas. Enviar sin comprobar cuánto queda pendiente hace crecer un buffer interno sin límite hasta que el canal se cierra o el proceso se queda sin memoria, y ocurre con facilidad al volcar un historial entero al conectar.
const LIMITE = 1 << 20; // un megabyte pendiente como techo
canal.bufferedAmountLowThreshold = 256 * 1024;
async function enviarConControl(canal, mensajes) {
for (const m of mensajes) {
if (canal.bufferedAmount > LIMITE) {
await new Promise((r) =>
canal.addEventListener("bufferedamountlow", r, { once: true }));
}
canal.send(m);
}
}
Al mismo problema pertenece el tamaño máximo de mensaje. Aunque las implementaciones actuales admiten mensajes bastante grandes y anuncian su límite, lo prudente en una aplicación real es trocear por tu cuenta en fragmentos pequeños y reensamblar arriba, tanto por compatibilidad como porque un mensaje enorme monopoliza su flujo durante todo el envío y estropea la latencia de lo demás.
Lo que arrastras y no ibas a usar
Puesto el balance sobre la mesa, hay que nombrar el pasivo con la misma franqueza. El primer apunte es la latencia acumulada de tres negociaciones encadenadas, que ya hemos visto y que no se puede recortar salvo con canales pre-acordados. El segundo es la superficie de código: la implementación del transporte es una biblioteca considerable escrita hace años para otro propósito, que vive dentro del navegador y ha tenido su historial de vulnerabilidades como cualquier código de red de ese tamaño. No es un argumento decisivo, pero conviene saber que aceptar conexiones de pares desconocidos expone esa superficie a datos que envía cualquiera.
El tercer apunte es de redundancia conceptual, y es el más interesante. El control de congestión, los reintentos, la numeración y el reensamblado están implementados aquí, en el espacio de la aplicación, sobre una capa de cifrado que también lleva su propia numeración y sus propios reintentos para su saludo, sobre un transporte sin conexión que no lleva nada. Tres capas que se escribieron sin conocerse, cada una resolviendo una parte del mismo problema con su propio vocabulario.
flowchart TD subgraph pila de WebRTC A1[mensajes de la aplicacion] --> A2[transporte con flujos y fiabilidad parcial] A2 --> A3[cifrado adaptado a datagramas] A3 --> A4[datagramas sin conexion] end subgraph transporte integrado moderno B1[mensajes de la aplicacion] --> B2[flujos y cifrado en una sola capa] B2 --> B4[datagramas sin conexion] end style A2 fill:#f9e2af,color:#11111b style A3 fill:#f9e2af,color:#11111b style B2 fill:#a6e3a1,color:#11111b
El transporte integrado que hoy sostiene la tercera versión del protocolo de la web hace exactamente el mismo trabajo con una sola negociación, porque se diseñó desde el principio con el cifrado y el multiplexado dentro en lugar de apilados encima. Esa comparación no invalida a WebRTC, que sigue siendo lo único disponible en el navegador para hablar directamente con otro cliente, pero sí explica por qué las pilas de la generación siguiente, que veremos en el nivel próximo, no se parecen a esta.
La lección que hay que extraer de este apilamiento no va de protocolos sino de una confusión que atraviesa toda la ingeniería de sistemas: tratar como propiedades técnicas inevitables lo que son decisiones de producto congeladas en una capa. Que un dato perdido se reintente hasta llegar, y que hasta entonces se retenga todo lo que venía detrás, no es una ley de las redes: es una política concreta, tomada en los años setenta para un caso de uso concreto, que resultó tan acertada para la transferencia de ficheros que dejó de percibirse como una elección. Cada vez que envías un mensaje por un canal ordinario estás aceptando esa política sin haberla examinado, y estás aceptando con ella su consecuencia inevitable, que es que la latencia de lo que llegó bien queda rehén de lo que se perdió. Este transporte hace algo poco habitual: te devuelve la decisión. Y en cuanto la tienes en la mano, se descubre que la respuesta correcta no es única para toda la aplicación sino distinta para cada clase de dato, porque lo que gobierna la elección no es la red sino el significado. Un dato que representa un instante —dónde está el cursor de alguien, quién está mirando esta página, cuál es el nivel de un indicador— envejece más rápido de lo que se recupera, y reintentarlo es gastar ancho de banda en mostrar una mentira; su política correcta es caducar. Un dato que representa un hecho —una operación, un bloque, un evento— tiene que llegar sí o sí, pero muy a menudo no necesita llegar en orden, porque ya lleva dentro su propia causalidad, que es justo lo que llevas veinte niveles construyendo con relojes lógicos, grafos de eventos y nombres derivados del contenido. Pedirle orden al transporte cuando el dato ya sabe ordenarse solo es pagar dos veces por lo mismo, y la segunda se paga en bloqueo por cabecera de línea. La consecuencia práctica es un ejercicio que casi nadie hace y que cambia el rendimiento percibido de una aplicación colaborativa más que cualquier optimización de código: recorre tu modelo, clasifica cada tipo de mensaje según envejezca o no, según se ordene solo o no, y asígnale la política que le corresponde. Descubrirás que la mayor parte de lo efímero no necesita fiabilidad, que la mayor parte de lo permanente no necesita orden, y que el caso fiable y ordenado que estabas usando para todo era en realidad la excepción. Ese inventario sobrevive al transporte que uses: sigue siendo válido si mañana cambias a un transporte integrado, a un relevo por servidor o a un intercambio por fichero, porque no describe la red sino tus datos.
- Enumera los tipos de mensaje que tu aplicación envía y marca cuáles envejecen peor de lo que tardan en recuperarse.
- Marca cuáles llevan ya su propia causalidad dentro y por tanto no necesitan orden del transporte.
- Reparte esos tipos en canales separados con la política adecuada y mide la diferencia en una red con pérdidas.
- Implementa contrapresión en el envío masivo inicial y comprueba qué ocurre sin ella con un historial grande.
- Trocea un fichero de varios megabytes, envíalo sin orden y reensámblalo por posición en el receptor.
- Mide por separado cuánto tarda cada uno de los tres saludos y calcula cuánto ahorras con canales pre-acordados.