En cside ponemos un fuerte énfasis en la seguridad de memoria y el rendimiento. Todos nuestros servicios principales están escritos en Rust, incluido el servicio Edge.
El Edge se sitúa en una frontera sensible. Tiene que ser rápido, seguro y resiliente, porque forma parte del camino en el que recogemos señales de alta calidad para productos como la detección de VPN y la detección de bots.
Para muchas aplicaciones web, la configuración de TLS más sencilla es dejar que un balanceador de carga en la nube termine TLS y reenvíe HTTP plano a la aplicación. Ese es un buen valor por defecto cuando la aplicación solo necesita la petición HTTP final.
El Edge tiene un trabajo distinto.
Algunas señales de detección existen antes de que la petición se convierta en HTTP corriente, en la capa 4 (la capa de transporte) en lugar de en la capa 7. Si TLS se termina por completo antes de que el tráfico llegue al Edge, esas señales dejan de estar disponibles en la misma forma. Así que, para esta parte de la plataforma, el Edge tiene que operar en la capa 4 y gestionar el camino de TLS directamente.
Eso le da a cside mejor calidad de señal para la detección, pero también significa que el Edge necesita gestionar él mismo el comportamiento real de TLS.
Este post trata sobre un pequeño arreglo de fiabilidad en ese camino: sacar el trabajo del handshake de TLS del camino de accept de Axum y llevarlo a tareas de Tokio acotadas.
Qué empezó a fallar
El parche en sí no era grande, pero entender el fallo requirió algo de investigación.
El Edge estaba sano. Los certificados se cargaban. El puerto era accesible. La mayor parte del tráfico se comportaba con normalidad.
Pero bajo más carga, algunas comprobaciones HTTPS y conexiones de clientes parecían quedarse colgadas o agotar el tiempo de espera.
Al principio, eso puede parecer un problema de TLS. En la práctica, el patrón importante era más específico: algunos clientes abrían una conexión pero no completaban el handshake de TLS.
Eso es normal en un servicio expuesto a internet. Los endpoints públicos ven conexiones incompletas todo el tiempo:
el cliente conecta y luego no envía nada
el cliente inicia TLS y luego desaparece
el cliente inicia TLS y luego se atasca
Esos fallos no eran la parte sorprendente.
La parte sorprendente era cuánto impacto podía tener un único handshake incompleto sobre el tráfico sano cercano.
Antes del arreglo
Antes del arreglo, una parte del camino de TLS del Edge hacía demasiado trabajo en un solo paso.
Conceptualmente, se comportaba así:
aceptar una conexión
terminar el trabajo de TLS para esa conexión
y luego aceptar la siguiente conexión
En Rust simplificado, la forma era más o menos esta:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
let (mut tcp_stream, addr) = self.tcp_listener.accept().await;
let hello = read_tls_hello(&mut tcp_stream).await;
let tls_stream = complete_tls_handshake(hello, tcp_stream).await;
(tls_stream, addr)
}
}
Ese código es fácil de razonar, pero mete todo el handshake de TLS dentro de accept().
Para Axum, accept() es la puerta de entrada. Si está ocupado esperando una conexión, el servidor no está recibiendo la siguiente conexión completada de ese listener.
Eso parece simple, pero crea head-of-line blocking.
Si una conexión iniciaba TLS y luego se atascaba, el Edge esperaba el timeout de esa conexión antes de seguir adelante. Durante esa espera, las conexiones sanas podían quedarse retenidas detrás de ella.
El problema no era:
TLS no puede funcionar
Era:
un handshake de TLS incompleto puede retrasar handshakes sanos posteriores
Esa distinción importaba.
Aumentar el timeout no habría arreglado el problema. Habría hecho que el camino lento mantuviera la línea bloqueada durante más tiempo.
El arreglo
El arreglo consistió en separar la aceptación de una conexión de la finalización de su handshake de TLS.
Ahora el Edge acepta nuevas conexiones rápidamente y gestiona cada handshake de TLS de forma independiente. Un handshake atascado o incompleto todavía puede agotar su tiempo de espera, pero no impide que las conexiones sanas posteriores avancen.
Conceptualmente, el nuevo flujo se ve así:
aceptar conexiones rápidamente
gestionar cada handshake de TLS de forma independiente
devolver al manejo de peticiones solo las conexiones seguras completadas
La nueva forma usa tareas de Tokio más un canal de conexiones seguras completadas:
let (ready_tx, ready_rx) = tokio::sync::mpsc::channel(limit);
tokio::spawn(async move {
loop {
let (tcp_stream, addr) = tcp_listener.accept().await;
let ready_tx = ready_tx.clone();
tokio::spawn(async move {
if let Some(tls_stream) = finish_tls(tcp_stream).await {
let _ = ready_tx.send((tls_stream, addr)).await;
}
});
}
});
Entonces el listener orientado a Axum se vuelve mucho más pequeño:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
self.ready_rx
.recv()
.await
.expect("TLS accept loop terminated")
}
}
La parte importante es la frontera: accept() ya no realiza por sí mismo el lento trabajo del handshake. Recibe handshakes que ya se han completado.
Así que el modo de fallo cambió de esto:
un handshake atascado
-> retrasa la siguiente conexión
a esto:
un handshake atascado
-> agota su tiempo de espera de forma independiente
-> las conexiones sanas continúan
Esa es la mejora de fiabilidad importante.
El arreglo no eliminó los timeouts. Los timeouts siguen siendo necesarios. Un handshake incompleto no debería vivir para siempre.
El arreglo cambió dónde se paga el timeout. Ahora una conexión defectuosa paga su propio timeout en lugar de hacer que otras conexiones lo paguen por ella.
Manteniéndolo acotado
Hay una segunda parte importante del arreglo.
Si cada nueva conexión puede crear trabajo ilimitado, entonces el servicio se vuelve receptivo pero no seguro bajo presión. Por eso el Edge también acota el número de handshakes de TLS que pueden estar en curso al mismo tiempo.
La versión simplificada se ve así:
let permits = Arc::new(tokio::sync::Semaphore::new(limit));
loop {
let (tcp_stream, addr) = tcp_listener.accept().await;
let permit = permits.clone().acquire_owned().await.expect("TLS semaphore closed");
let ready_tx = ready_tx.clone();
tokio::spawn(async move {
let _permit = permit;
if let Some(tls_stream) = finish_tls(tcp_stream).await {
let _ = ready_tx.send((tls_stream, addr)).await;
}
});
}
El permiso es propiedad de la tarea. Cuando la tarea termina, Rust libera el permiso y devuelve capacidad al semáforo. Eso mantiene el límite de concurrencia ligado al tiempo de vida del trabajo real del handshake.
Eso nos da las dos propiedades que queríamos:
los handshakes lentos no bloquean los handshakes sanos
y:
los handshakes lentos no pueden crear trabajo ilimitado
Este es el tipo de compromiso que nos importa en el Edge: mejorar la fiabilidad sin renunciar a un uso de recursos predecible.
Haciendo visible el fallo
También reforzamos un camino de fallo interno.
Si la parte del Edge responsable de aceptar conexiones seguras llegara a detenerse de forma inesperada, el servicio no debería esperar en silencio para siempre. Los bloqueos silenciosos son difíciles de operar y difíciles de razonar.
El canal hace este estado explícito. Si todos los emisores han desaparecido, recibir del canal devuelve None. Eso no debería tratarse como tiempo de inactividad normal, así que el cuerpo final de accept() reemplaza el anterior .expect() por un fallo explícito y registrado:
self.ready_rx.recv().await.unwrap_or_else(|| {
tracing::error!("TLS accept loop terminated");
panic!("TLS accept loop terminated")
})
Ahora el camino de fallo se vuelve visible de inmediato en lugar de convertirse en una espera oculta.
Eso no cambia el tráfico normal de los clientes, pero hace que el sistema sea más fácil de confiar durante los incidentes.
Qué aprendimos
La lección principal es que los timeouts no bastan si el timeout se paga en el lugar equivocado.
Un timeout alrededor del trabajo de TLS suena razonable. Pero si una conexión lenta puede hacer que conexiones no relacionadas esperen detrás de ella, el timeout se convierte en un dolor compartido.
El mejor modelo es:
aceptar rápidamente
aislar el trabajo lento
acotar la concurrencia
hacer visible el fallo inesperado
Otra lección es que los servicios expuestos a internet deberían tratar las conexiones incompletas como algo normal. Los clientes se desconectan. Las comprobaciones de salud reintentan. Las redes fluctúan. Algunos handshakes nunca terminan.
El Edge no debería dar por hecho que internet es ordenado.
Antes del arreglo:
un handshake incompleto
-> el tráfico sano cercano puede esperar
Después del arreglo:
un handshake incompleto
-> timeout aislado
-> el tráfico sano continúa
El parche final no hizo que las conexiones defectuosas desaparecieran.
Hizo que el Edge las gestionara en el lugar correcto, con los límites correctos, manteniendo a la vez las garantías de rendimiento y seguridad de memoria que esperamos de nuestros servicios en Rust.







