Resumo: isolamento do loop de accept TLS no cside Edge em Rust para recolha de sinais de Layer 4
- Timeout maior não corrige: Toda a gente aumenta o timeout de TLS quando os handshakes ficam pendurados, o que só faz uma ligação má bloquear todas as boas durante mais tempo. A correção real tira o trabalho de handshake do caminho de accept, para que um peer bloqueado pague a sua própria conta.
- Isola cada handshake: O Edge em Rust da cside agora separa o accept do handshake fazendo spawn de cada handshake TLS na sua própria task Tokio, limitado por um permit de Semaphore ligado ao tempo de vida do handshake, para que handshakes lentos não possam atrasar handshakes saudáveis nem criar trabalho ilimitado, e o Edge faz isto porque os sinais de Layer 4 alimentam produtos como a deteção de VPN e a deteção de bots.
- Limita o trabalho lento: Se o teu modelo de fiabilidade é "apanha-o com um timeout maior", estás a deslocar a dor, não a corrigi-la. Isola o trabalho lento, limita a concorrência e torna a falha visível, ou continua a operar uma stack que se pendura em silêncio sempre que a internet fica desarrumada.
Sem tempo? Veja o bloqueio de Magecart e skimmers no navegador da cside. Cobre tudo o que se segue numa única implementação.
Na cside, damos uma forte ênfase à segurança de memória e ao desempenho. Todos os nossos serviços centrais são escritos em Rust, incluindo o serviço Edge.
A Edge situa-se numa fronteira sensível. Tem de ser rápida, segura e resiliente, porque faz parte do caminho onde recolhemos sinais de alta qualidade para produtos como a deteção de VPN e a deteção de bots.
Para muitas aplicações web, a configuração de TLS mais simples é deixar um balanceador de carga na cloud terminar o TLS e encaminhar HTTP simples para a aplicação. Esse é um bom valor por omissão quando a aplicação só precisa do pedido HTTP final.
A Edge tem uma função diferente.
Alguns sinais de deteção existem antes de o pedido se tornar HTTP comum, na Layer 4 (a camada de transporte) e não na Layer 7. Se o TLS for totalmente terminado antes de o tráfego chegar à Edge, esses sinais deixam de estar disponíveis na mesma forma. Por isso, para esta parte da plataforma, a Edge tem de operar na Layer 4 e gerir o caminho do TLS diretamente.
Isso dá à cside melhor qualidade de sinal para a deteção, mas também significa que a Edge precisa de lidar ela própria com o comportamento real do TLS.
Este artigo é sobre uma pequena correção de fiabilidade nesse caminho: retirar o trabalho de handshake de TLS do caminho de accept do Axum e colocá-lo em tasks Tokio limitadas.
O que começou a falhar
O patch em si não era grande, mas compreender a falha deu algum trabalho.
A Edge estava saudável. Os certificados estavam a carregar. A porta estava acessível. A maior parte do tráfego comportava-se normalmente.
Mas sob mais carga, algumas verificações HTTPS e ligações de clientes pareciam ficar penduradas ou expirar.
À primeira vista, isso pode parecer um problema de TLS. Na prática, o padrão importante era mais específico: alguns clientes abriam uma ligação mas não completavam o handshake de TLS.
Isso é normal num serviço exposto à internet. Os endpoints públicos veem ligações incompletas a toda a hora:
o cliente liga-se e depois não envia nada
o cliente inicia o TLS e depois desaparece
o cliente inicia o TLS e depois bloqueia
Essas falhas não eram a parte surpreendente.
A parte surpreendente era o quanto um único handshake incompleto podia afetar o tráfego saudável próximo.
Antes da correção
Antes da correção, uma parte do caminho de TLS da Edge fazia demasiado trabalho num único passo.
Conceptualmente, comportava-se assim:
aceitar uma ligação
terminar o trabalho de TLS dessa ligação
depois aceitar a ligação seguinte
Em Rust simplificado, a forma era aproximadamente 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)
}
}
Esse código é fácil de raciocinar, mas coloca todo o handshake de TLS dentro de accept().
Para o Axum, accept() é a porta de entrada. Se estiver ocupada à espera de uma ligação, o servidor não está a receber a ligação completada seguinte desse listener.
Isso parece simples, mas cria head-of-line blocking.
Se uma ligação iniciava o TLS e depois bloqueava, a Edge esperava pelo timeout dessa ligação antes de avançar. Durante essa espera, as ligações saudáveis podiam ficar atrasadas atrás dela.
O problema não era:
o TLS não funciona
Era:
um handshake de TLS incompleto pode atrasar handshakes saudáveis posteriores
Essa distinção importava.
Aumentar o timeout não teria resolvido o problema. Teria feito o caminho lento segurar a fila durante mais tempo.
A correção
A correção foi separar a aceitação de uma ligação da conclusão do seu handshake de TLS.
A Edge agora aceita novas ligações rapidamente e trata de cada handshake de TLS de forma independente. Um handshake bloqueado ou incompleto ainda pode expirar, mas não impede que as ligações saudáveis posteriores progridam.
Conceptualmente, o novo fluxo é assim:
aceitar ligações rapidamente
tratar de cada handshake de TLS de forma independente
devolver ao tratamento de pedidos apenas as ligações seguras concluídas
A nova forma usa tasks Tokio mais um channel de ligações seguras concluídas:
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;
}
});
}
});
Depois, o listener voltado para o Axum torna-se muito mais pequeno:
impl axum::serve::Listener for EdgeTlsListener {
async fn accept(&mut self) -> (TlsStream, SocketAddr) {
self.ready_rx
.recv()
.await
.expect("TLS accept loop terminated")
}
}
A parte importante é a fronteira: accept() já não executa ele próprio o trabalho lento de handshake. Recebe handshakes que já foram concluídos.
Assim, o modo de falha mudou disto:
um handshake bloqueado
-> atrasa a ligação seguinte
para isto:
um handshake bloqueado
-> expira de forma independente
-> as ligações saudáveis continuam
Esta é a melhoria de fiabilidade importante.
A correção não eliminou os timeouts. Os timeouts continuam a ser necessários. Um handshake incompleto não deve viver para sempre.
A correção mudou onde o timeout é pago. Uma ligação má agora paga o seu próprio timeout em vez de fazer com que outras ligações o paguem por ela.
Mantê-lo limitado
Há uma segunda parte importante da correção.
Se cada nova ligação puder criar trabalho ilimitado, então o serviço torna-se responsivo mas não seguro sob pressão. Por isso, a Edge também limita o número de handshakes de TLS que podem estar em curso ao mesmo tempo.
A versão simplificada é assim:
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;
}
});
}
O permit pertence à task. Quando a task termina, o Rust faz drop do permit e devolve capacidade ao semáforo. Isso mantém o limite de concorrência ligado ao tempo de vida do trabalho de handshake real.
Isso dá-nos as duas propriedades que queríamos:
os handshakes lentos não bloqueiam os handshakes saudáveis
e:
os handshakes lentos não podem criar trabalho ilimitado
Este é o tipo de compromisso que nos importa na Edge: melhorar a fiabilidade sem abdicar de uma utilização de recursos previsível.
Tornar a falha visível
Também apertámos um caminho de falha interno.
Se a parte da Edge responsável por aceitar ligações seguras alguma vez parar inesperadamente, o serviço não deve esperar silenciosamente para sempre. Os bloqueios silenciosos são difíceis de operar e difíceis de raciocinar.
O channel torna este estado explícito. Se todos os emissores desaparecerem, receber do channel devolve None. Isso não deve ser tratado como tempo inativo normal, por isso o corpo final de accept() substitui o anterior .expect() por uma falha explícita e registada:
self.ready_rx.recv().await.unwrap_or_else(|| {
tracing::error!("TLS accept loop terminated");
panic!("TLS accept loop terminated")
})
O caminho de falha agora torna-se visível imediatamente em vez de se transformar numa espera oculta.
Isso não altera o tráfego normal dos clientes, mas torna o sistema mais fácil de confiar durante incidentes.
O que aprendemos
A principal lição é que os timeouts não chegam se o timeout for pago no sítio errado.
Um timeout à volta do trabalho de TLS parece razoável. Mas se uma ligação lenta puder fazer com que ligações não relacionadas esperem atrás dela, o timeout torna-se uma dor partilhada.
O melhor modelo é:
aceitar rapidamente
isolar o trabalho lento
limitar a concorrência
tornar a falha inesperada visível
Outra lição é que os serviços expostos à internet devem tratar as ligações incompletas como normais. Os clientes desligam-se. As verificações de saúde voltam a tentar. As redes oscilam. Alguns handshakes nunca terminam.
A Edge não deve assumir que a internet é arrumada.
Antes da correção:
um handshake incompleto
-> o tráfego saudável próximo pode esperar
Depois da correção:
um handshake incompleto
-> timeout isolado
-> o tráfego saudável continua
O patch final não fez as ligações más desaparecer.
Fez com que a Edge as tratasse no sítio certo, com os limites certos, mantendo as garantias de desempenho e segurança de memória que esperamos dos nossos serviços Rust.









